Request flow¶
Four walk-throughs of the common paths: attacker blocked at the edge, legitimate user on a ForwardAuth-protected host, OIDC login, and background cron work.
Attacker hitting a WAF-protected host¶
A scanner probing /wp-login.php on a host argos fronts.
sequenceDiagram
participant C as Client (attacker)
participant Cad as Caddy
participant Bouncer as CrowdSec bouncer<br/>(in Caddy)
participant LAPI as CrowdSec LAPI
participant AppSec as CrowdSec AppSec<br/>:7422 (block)
participant U as Upstream
C->>Cad: GET /wp-login.php
Cad->>Bouncer: is this IP banned?
Bouncer->>Bouncer: check cached decisions
alt banned
Bouncer-->>Cad: 403
Cad-->>C: 403
else not banned
Cad->>AppSec: forward original request
AppSec->>AppSec: evaluate CRS rules
alt CRS match, anomaly >= threshold
AppSec-->>Cad: 403 (with rule id)
Cad-->>C: 403
Cad->>LAPI: emit http-probing event
LAPI->>LAPI: scenario fires -> create decision
else clean
Cad->>U: reverse_proxy
U-->>Cad: 404 (path does not exist)
Cad-->>C: 404
end
end Key properties:
- Bouncer check is cache-resident. No LAPI round-trip on the happy path; decisions are refreshed on a 15 s poll.
- WAF runs in-band. Caddy waits for AppSec to return a verdict before continuing. Latency budget is single-digit ms on matched rules, sub-ms on unmatched.
- CrowdSec scenarios aggregate. A single 404 does not ban; N of them in a time window does.
Legitimate user on a ForwardAuth-protected host¶
sequenceDiagram
participant C as Browser
participant Cad as Caddy
participant Argos as argos /auth/forward
participant DB as SQLite
participant U as Upstream
C->>Cad: GET https://myapp.example.com/dashboard<br/>(cookie: argos_session=...)
Cad->>Argos: forward_auth sub-request<br/>(X-Forwarded-*, Cookie)
alt cache hit (30s TTL)
Argos->>Argos: lookup(token) -> rec
else cache miss
Argos->>DB: SELECT session JOIN user WHERE token=?
DB-->>Argos: row
Argos->>DB: UPDATE sessions SET last_seen_at=NOW<br/>(throttled; skip if <5 min old)
Argos->>Argos: cache put(token, rec, exp=now+30s)
end
Argos-->>Cad: 200 OK<br/>X-Auth-User, X-Auth-Email, X-Auth-Name, X-Auth-Provider
Cad->>U: GET /dashboard<br/>(X-Auth-* copied on upstream request)
U-->>Cad: 200
Cad-->>C: 200 On a cookie miss the handler reconstructs the original URL from Caddy's X-Forwarded-{Proto,Host,Uri} headers and returns a 302 to panel.example.com/login?rd=<escaped-url>. The panel's OIDC (or password) flow mints a session cookie with Domain=example.com so the next request on myapp.example.com carries it.
OIDC login¶
sequenceDiagram
participant B as Browser
participant A as argos panel
participant IdP as OIDC provider
participant PS as PendingStore<br/>(in-memory, 10min TTL)
participant DB as SQLite
B->>A: GET /api/auth/oidc/login?rd=<url>
A->>A: randBytes(state), randBytes(nonce),<br/>randBytes(verifier)
A->>A: code_challenge = base64url(sha256(verifier))
A->>PS: store pending{state, nonce, verifier, returnTo}
A-->>B: 302 IdP?client_id=&redirect_uri=&state=&code_challenge=&nonce=
B->>IdP: authenticate (password, MFA, passkey)
IdP-->>B: 302 /api/auth/oidc/callback?code=&state=
B->>A: GET /api/auth/oidc/callback
A->>PS: lookup(state) -> pending
alt not found or expired
A-->>B: 302 /login?oidc_error=state_not_found
else found
A->>IdP: POST /token (code, code_verifier)
IdP-->>A: id_token + refresh_token
A->>A: Verify(id_token) -- issuer, audience,<br/>signature, expiry, nonce
A->>DB: UPSERT users WHERE external_id=sub
DB-->>A: user row
A->>A: CheckAllowlist(email) + RequireEmailVerified
A->>DB: INSERT sessions (token, user_id, expires_at)
A-->>B: 302 safeReturnTo(rd)<br/>Set-Cookie: argos_session=...
end Key integrity properties enforced in sequence:
stateis single-use and expires in 10 min.code_verifieris never transmitted on the initial redirect (only the challenge is), so a leaked auth URL cannot recover the token.nonceis compared against the id_token's nonce claim after signature verification.- Email allowlist +
email_verifiedgate fire AFTER the id_token is trusted. safeReturnTorejects backslash, control chars, and off-domain URLs before issuing the final redirect.
Background cron work¶
Argos runs several time-driven goroutines against the main context.
flowchart LR
subgraph hourly[Every 6 hours]
ret[retention cron]
end
subgraph daily[Daily at backup.schedule]
bak[backup scheduler]
end
subgraph monthly[Day 5 at 03:00 UTC]
geo[geoip downloader]
end
subgraph continuous[Continuous]
ingestor[log ingestor tail]
sweeper1[OIDC pending sweeper]
sweeper2[TOTP challenge sweeper]
sweeper3[ForwardAuth cache sweeper]
end
ret -->|DELETE old rows| db[(SQLite)]
bak -->|VACUUM INTO + tar.gz| data[/data/backups/]
bak -->|delete > retention_days| data
geo -->|download mmdb| geodir[/data/geoip/]
ingestor -->|batch INSERT| db
sweeper1 & sweeper2 & sweeper3 -->|evict expired| mem[in-memory maps] What each does:
- retention cron — every 6 h and once at boot: drops
log_entriesolder thanlogs.retention_daysOR beyondlogs.max_entriescap, dropslogin_attempts+totp_attemptsolder than 24 h. Also runsmaybeVacuum()which VACUUMs once a month on day 1 at 04:00 UTC. - backup scheduler — matches the
backup.schedulecron (default0 2 * * *= 02:00 UTC daily). RunsVACUUM INTOsnapshot, tars with metadata.json + caddy_data, SHA-256s, records abackupsrow, then appliesbackup.retention_days. - geoip downloader — hardcoded
0 3 5 * *(day 5, 03:00 UTC). Pullsdb-ip-city-lite.mmdb.gzanddb-ip-asn-lite.mmdb.gzfrom the DB-IP mirror, gunzips, atomic rename into place. Plus a non-blocking run at first boot if either file is missing. - log ingestor — constantly tails Caddy's access + error + WAF audit files, parses the structured JSON, batches 100-row INSERTs into
log_entries. Also picks up audit entries enqueued byRecorder.Record. - sweepers — garbage-collect per-subsystem in-memory maps. Low-frequency ticks (TTL/2 each).
All cron work logs to structured slog with the source=audit shape when it mutates state; you can trail them in the Logs tab with source = audit.
Related¶
- Components — the container map.
- Storage — SQLite tuning, migration runner.
- Threat model — what each handler is defending against.