The 2026-09-12 CT 101 502 incident fix (api-watchdog: detects the API container hanging while docker still reports it healthy, and restarts it) only ever landed in argus-appliance's docker/docker-compose.yml, the source-build dev compose. It never reached this repo, which is what update.sh/install.sh on every real Docker-based install actually use -- so the mitigation never shipped to a real install. Ported the service (built locally from a bundled Dockerfile, since there is no released, cosign-verified argus-api-watchdog image yet) plus its build context.
303 lines
11 KiB
YAML
303 lines
11 KiB
YAML
services:
|
|
# TimescaleDB (PostgreSQL with time-series optimization)
|
|
db:
|
|
image: timescale/timescaledb:latest-pg17
|
|
container_name: argus-db
|
|
restart: unless-stopped
|
|
logging:
|
|
driver: "json-file"
|
|
options:
|
|
max-size: "50m"
|
|
max-file: "3"
|
|
environment:
|
|
TZ: ${TZ:-UTC}
|
|
# Internal database credentials, not customer-configurable — nothing
|
|
# outside this compose stack ever connects to Postgres directly, so
|
|
# there's no real reason to let these vary. Only the password (below)
|
|
# needs to be a real secret; the username/db name are just labels.
|
|
POSTGRES_USER: postgres
|
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
|
|
POSTGRES_DB: argus
|
|
command: postgres -c timezone=${TZ:-UTC} -c log_timezone=${TZ:-UTC} -c shared_preload_libraries=timescaledb -c timescaledb.telemetry_level=off -c max_locks_per_transaction=512
|
|
volumes:
|
|
- postgres_data:/var/lib/postgresql/data
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 10
|
|
start_period: 30s
|
|
|
|
# Valkey (Redis-compatible) for caching
|
|
valkey:
|
|
image: valkey/valkey:9-alpine
|
|
container_name: argus-valkey
|
|
restart: unless-stopped
|
|
logging:
|
|
driver: "json-file"
|
|
options:
|
|
max-size: "10m"
|
|
max-file: "3"
|
|
environment:
|
|
TZ: ${TZ:-UTC}
|
|
command: >
|
|
valkey-server
|
|
--maxmemory 256mb
|
|
--maxmemory-policy allkeys-lru
|
|
--appendonly yes
|
|
--appendfsync everysec
|
|
volumes:
|
|
- valkey_data:/data
|
|
healthcheck:
|
|
test: ["CMD", "valkey-cli", "ping"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
start_period: 10s
|
|
|
|
# Restricts the api service's Docker Engine API access to exactly what it
|
|
# needs (list/inspect/exec/restart existing containers for log collection
|
|
# and nginx reload) — it cannot create new containers or touch the host
|
|
# beyond that. See docker-socket-proxy/README.md for the exact allowed set.
|
|
docker-socket-proxy:
|
|
image: tecnativa/docker-socket-proxy:latest
|
|
container_name: argus-docker-socket-proxy
|
|
restart: unless-stopped
|
|
security_opt:
|
|
- "no-new-privileges:true"
|
|
environment:
|
|
CONTAINERS: 1
|
|
IMAGES: 1
|
|
VOLUMES: 1
|
|
SYSTEM: 1
|
|
EXEC: 1
|
|
VERSION: 1
|
|
POST: 1
|
|
# Restart (stop/restart/kill on an *existing* container) only --
|
|
# create/prune stay denied by the haproxy template's own explicit
|
|
# deny-list regardless of POST, and start/stop stay off (ALLOW_START/
|
|
# ALLOW_STOP unset) since nothing needs them yet. This comment block's
|
|
# own claim of "restart" capability (above) was aspirational until
|
|
# this line existed -- appliance#43's Diagnostics recovery actions are
|
|
# the first real caller.
|
|
ALLOW_RESTARTS: 1
|
|
volumes:
|
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
- ./docker-socket-proxy/haproxy.cfg.template:/usr/local/etc/haproxy/haproxy.cfg.template:ro
|
|
logging:
|
|
driver: "json-file"
|
|
options:
|
|
max-size: "10m"
|
|
max-file: "3"
|
|
|
|
# Go API Server
|
|
api:
|
|
# ARGUS_VERSION is set by update.sh before `docker compose pull`, pinned
|
|
# to a specific already-fully-promoted version tag rather than the
|
|
# floating :latest (ADR-0051 -- argus-appliance repo). Manual/non-
|
|
# update.sh use (plain `docker compose pull`, no env var set) is
|
|
# unaffected: the default is :latest, identical to before this change.
|
|
image: git-cloud.weboria.eu/weboria/argus-api:${ARGUS_VERSION:-latest}
|
|
container_name: argus-api
|
|
restart: unless-stopped
|
|
# Container hardening: no-new-privileges blocks setuid privilege
|
|
# escalation; cap_drop ALL removes every Linux capability, re-adding only
|
|
# what's needed (DAC_OVERRIDE for the shared /etc/nginx volume owned by
|
|
# the nginx user, NET_BIND_SERVICE for the DNS Security Engine's :53).
|
|
security_opt:
|
|
- "no-new-privileges:true"
|
|
cap_drop:
|
|
- ALL
|
|
cap_add:
|
|
- DAC_OVERRIDE
|
|
- NET_BIND_SERVICE
|
|
logging:
|
|
driver: "json-file"
|
|
options:
|
|
max-size: "100m"
|
|
max-file: "5"
|
|
extra_hosts:
|
|
- "host.docker.internal:host-gateway"
|
|
environment:
|
|
TZ: ${TZ:-UTC}
|
|
PORT: "8080"
|
|
# DATABASE_URL is not set here — the API builds it internally from
|
|
# DB_PASSWORD using net/url (proper percent-encoding), instead of this
|
|
# file naively interpolating the password into a raw connection
|
|
# string. A raw '${DB_PASSWORD}@...' interpolation breaks outright if
|
|
# the password ever contains '@', ':', '/', or similar (confirmed
|
|
# live: an openssl-rand-base64-generated password containing '/'
|
|
# produced an unparseable URL and the API never became healthy).
|
|
DB_PASSWORD: ${DB_PASSWORD:-postgres}
|
|
REDIS_URL: redis://valkey:6379/0
|
|
ENVIRONMENT: ${ENVIRONMENT:-production}
|
|
NGINX_CONTAINER: argus-proxy
|
|
NGINX_SKIP_TEST: "false"
|
|
NGINX_STATUS_URL: "http://host.docker.internal:${NGINX_HTTP_PORT:-80}/nginx_status"
|
|
NGINX_ACCESS_LOG: "/etc/nginx/logs/access_raw.log"
|
|
BACKUP_PATH: ${BACKUP_PATH:-/app/data/backups}
|
|
DOCKER_API_VERSION: ${DOCKER_API_VERSION:-}
|
|
DOCKER_HOST: tcp://docker-socket-proxy:2375
|
|
NGINX_HTTP_PORT: ${NGINX_HTTP_PORT:-}
|
|
NGINX_HTTPS_PORT: ${NGINX_HTTPS_PORT:-}
|
|
API_HOST_PORT: ${API_HOST_PORT:-9080}
|
|
API_HOST: ${API_HOST:-}
|
|
# ANIS community intelligence — on by default against the public
|
|
# Community Edition hub (community tier, no signup required). There is
|
|
# no self-hosted-ANIS deployment model: the URL is fixed, not
|
|
# customer-configurable. A license key upgrades the tier, but is only
|
|
# ever set from Threat Intel -> ANIS Connection in the dashboard
|
|
# (write-only there — never round-trips back to the client), not from
|
|
# this file, so it's seeded empty (community tier) here.
|
|
# (No ANIS_ADMIN_KEY or ANIS_SHARE_ATTACKERS here: both confirmed dead
|
|
# via grep, zero references anywhere in internal/config.go or the ANIS
|
|
# client. A prior version of this file carried ANIS_ADMIN_KEY and
|
|
# falsely claimed ARGUS "fails closed at startup" without it.)
|
|
ANIS_ENABLED: ${ANIS_ENABLED:-true}
|
|
ANIS_URL: https://anis.weboria.eu
|
|
ANIS_LICENSE_KEY: ""
|
|
DNS_SECURITY_LISTEN_ADDR: ":53"
|
|
ports:
|
|
- "127.0.0.1:${API_HOST_PORT:-9080}:8080"
|
|
# DNS Security Engine. Host-only by default — a bare forwarding
|
|
# resolver reachable from the network is exactly the profile abused
|
|
# for DNS amplification attacks against third parties. Set
|
|
# DNS_LISTEN_HOST to an internal interface IP to route real client
|
|
# DNS traffic through it (never 0.0.0.0 on an untrusted network).
|
|
- "${DNS_LISTEN_HOST:-127.0.0.1}:53:53/udp"
|
|
- "${DNS_LISTEN_HOST:-127.0.0.1}:53:53/tcp"
|
|
volumes:
|
|
- nginx_data:/etc/nginx:rw
|
|
- api_data:/app/data:rw
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
valkey:
|
|
condition: service_started
|
|
docker-socket-proxy:
|
|
condition: service_started
|
|
healthcheck:
|
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 360s
|
|
|
|
# api-watchdog (2026-09-12, see docker/watchdog/README.md in the
|
|
# argus-appliance repo): closes a real production gap -- `restart:
|
|
# unless-stopped` above only ever reacts to the api container actually
|
|
# EXITING. A real incident (CT 101, 2026-09-11/12) had it hang/wedge
|
|
# internally for ~11 hours while `docker ps` still reported it `Up`; the
|
|
# HEALTHCHECK above correctly kept detecting the hang the entire time,
|
|
# but nothing was watching that status and acting on it. This polls the
|
|
# same already-scoped docker-socket-proxy (zero new grants) and restarts
|
|
# argus-api after several consecutive unhealthy checks.
|
|
#
|
|
# Built locally rather than pulled/pinned/cosign-verified like api/ui/
|
|
# nginx above: there is not yet a released, signed argus-api-watchdog
|
|
# image in the registry (tracked as a follow-up to extend ADR-0051's
|
|
# verification set to a 4th component). The build context ships in this
|
|
# repo so it stays fully reproducible from source, not a hand-built
|
|
# local image nobody can regenerate.
|
|
api-watchdog:
|
|
build:
|
|
context: ./watchdog
|
|
container_name: argus-api-watchdog
|
|
restart: unless-stopped
|
|
security_opt:
|
|
- "no-new-privileges:true"
|
|
cap_drop:
|
|
- ALL
|
|
logging:
|
|
driver: "json-file"
|
|
options:
|
|
max-size: "10m"
|
|
max-file: "3"
|
|
environment:
|
|
WATCHDOG_TARGET_CONTAINER: argus-api
|
|
WATCHDOG_INTERVAL_SECONDS: "30"
|
|
WATCHDOG_UNHEALTHY_THRESHOLD: "5"
|
|
depends_on:
|
|
docker-socket-proxy:
|
|
condition: service_started
|
|
api:
|
|
condition: service_started
|
|
|
|
# React UI (Admin Panel), served over HTTPS
|
|
ui:
|
|
# See the api service's own comment above -- same ADR-0051 pinning.
|
|
image: git-cloud.weboria.eu/weboria/argus-ui:${ARGUS_VERSION:-latest}
|
|
container_name: argus-ui
|
|
restart: unless-stopped
|
|
security_opt:
|
|
- "no-new-privileges:true"
|
|
cap_drop:
|
|
- ALL
|
|
cap_add:
|
|
- NET_BIND_SERVICE
|
|
logging:
|
|
driver: "json-file"
|
|
options:
|
|
max-size: "10m"
|
|
max-file: "3"
|
|
environment:
|
|
TZ: ${TZ:-UTC}
|
|
ports:
|
|
- "${UI_PORT:-81}:443"
|
|
volumes:
|
|
- ui_data:/app/ssl:rw
|
|
depends_on:
|
|
- api
|
|
|
|
# Nginx reverse proxy — host network mode, so real client IPs are visible
|
|
# directly without needing PROXY protocol.
|
|
nginx:
|
|
# See the api service's own comment above -- same ADR-0051 pinning.
|
|
image: git-cloud.weboria.eu/weboria/argus-proxy:${ARGUS_VERSION:-latest}
|
|
container_name: argus-proxy
|
|
restart: always
|
|
network_mode: host
|
|
# Not cap_drop'd: runs nginx in host-network mode with a root master that
|
|
# drops workers to the nginx user, needing CHOWN/SETUID/SETGID plus binds
|
|
# on privileged 80/443.
|
|
security_opt:
|
|
- "no-new-privileges:true"
|
|
logging:
|
|
driver: "json-file"
|
|
options:
|
|
max-size: "100m"
|
|
max-file: "5"
|
|
environment:
|
|
TZ: ${TZ:-UTC}
|
|
ulimits:
|
|
nofile:
|
|
soft: 65535
|
|
hard: 65535
|
|
volumes:
|
|
- nginx_data:/etc/nginx:rw
|
|
depends_on:
|
|
- api
|
|
healthcheck:
|
|
test: ["CMD", "curl", "-f", "http://127.0.0.1:${NGINX_HTTP_PORT:-80}/health"]
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 10s
|
|
|
|
volumes:
|
|
postgres_data:
|
|
name: argus_postgres_data
|
|
valkey_data:
|
|
name: argus_valkey_data
|
|
nginx_data:
|
|
name: argus_nginx_data
|
|
api_data:
|
|
name: argus_api_data
|
|
ui_data:
|
|
name: argus_ui_data
|
|
|
|
networks:
|
|
default:
|
|
name: argus-network
|
|
external: true
|