Skip to content

v1.3.23 -- Security panel core: multi-IP banner + audit IP capture

The first half of the security-panel work the v1.3.20+ scope queued. v1.3.23 lands the BACKEND + the SelfBlockBanner v2; v1.3.24 adds the full /security UI tabs (Banned IPs, Whitelist, Activity) and the dashboard widget that consume the new endpoints.

The split keeps each release smoke-gateable and lets the operator exercise the new endpoints from curl before committing to specific UI shapes.

What ships

Migration 030: sessions.client_ip + sessions.xff_chain

Two NULL-allowed columns added to the existing sessions table. The login handler captures the request's resolved IP + X-Forwarded-For header and persists them at session-create time. Pre-v1.3.23 sessions stay valid; their client_ip is NULL and the banner v2 just doesn't see those IPs (graceful degradation).

session.ListActiveIPsForUser(ctx, userID) returns the distinct non-NULL client_ip values from active sessions for a given user. SelfBlockBanner v2 uses this to enumerate the operator's IPs to probe LAPI for.

Audit log gains source IP context

Handlers.audit() now folds _source_ip and _xff_chain into the JSON payload that lands in log_entries.raw. Existing call sites stay unchanged (additive); the v1.3.24 Activity tab will render "admin did X from Y at Z" without joining a parallel table. Reserved keys: _source_ip, _xff_chain -- callers should not collide.

Public-IP detection (ipify by default)

New package backend/internal/security/publicip. Background poller hits https://api.ipify.org/?format=json (or any operator-overridden URL via the panel.public_ip_detect_url setting) every hour and caches the result in-memory + persists to settings. The detector survives offline panels gracefully: empty cache + last_error populated, no UI noise.

Detection can be disabled by setting panel.public_ip_detect_url to "" or by booting with ARGOS_PUBLIC_IP_DISABLE=1. The cache also rehydrates from settings at boot (LoadCached) so a fresh panel start has the previous value while the next poll runs.

Plaintext + JSON response shapes both supported (so an operator swapping the URL to icanhazip.com style works without code changes). The body parser rejects non-IP responses to keep a misconfigured URL from poisoning the cache.

/api/security/check-self multi-IP shape

The existing endpoint returns the v1.3.19 fields (client_ip, banned, decisions) AND the v1.3.23 multi-IP fields:

{
  "client_ip": "192.0.2.10",
  "banned": true,
  "decisions": [...],
  "current_session_ip": "192.0.2.10",
  "public_ip_self": "203.0.113.42",
  "active_session_ips": ["198.51.100.7"],
  "any_banned": true,
  "banned_count": 1,
  "banned_ips": [
    {
      "ip": "203.0.113.42",
      "source": "public_ip",
      "decisions": [{...}]
    }
  ]
}

source is one of current_session, public_ip, or active_session so the banner can label rows ("this session", "panel public IP", "other active session") clearly.

New read/write /api/security/* endpoints

GET    /api/security/decisions             list + filter + paginate
DELETE /api/security/decisions/{id}        unban single by LAPI ID
GET    /api/security/whitelist             list rows
DELETE /api/security/whitelist/{id}        delete single + rewrite sentinel
GET    /api/security/audit-log             paginated audit query
GET    /api/security/dashboard-stats       aggregate counts
GET    /api/security/public-ip-self        detector status snapshot

/decisions query params: scope, origin, q (substring), limit (default 100, max 1000), offset. Filters are client-side post-fetch from the cached LAPI list -- simple at homelab scale; the bouncer cache handles hot-path lookups.

/audit-log reads log_entries WHERE source='audit', parses the JSON raw column to surface user_id, _source_ip, _xff_chain, diff cleanly. v1.3.24's Activity tab is the primary consumer.

/dashboard-stats is a per-call rollup (no caching layer): total bans, bans by scope, bans by origin, top countries from country_ban_expansions, whitelist size, audit count last 24h.

/decisions/{id} DELETE: idempotent (LAPI 404 mapped to deleted=0). Audit-logged.

/whitelist/{id} DELETE: removes the DB row + rewrites the shared sentinel file. The operator still needs to run docker compose exec crowdsec /setup-appsec.sh for CrowdSec to drop the YAML entry; the response body surfaces the command.

crowdsec.Client.DeleteDecisionByID(ctx, id) is the new client method backing the per-row unban. LAPI's DELETE /v1/decisions/{id} shape; 404 mapped to "already gone" for idempotency.

SelfBlockBanner v2 (frontend)

Multi-IP rendering:

  • Headline: "1 of your IPs is currently banned." or "3 of your IPs are currently banned." (count-aware).
  • Per-IP rows with the IP, source label ("this session" / "panel public IP" / "other active session"), reason, and expiry. Each row gets its own Unban and Whitelist permanently buttons.
  • Optimistic remove: on successful unban / whitelist, the row drops from local state immediately; the next 60s probe confirms.
  • Backwards-compat: when the backend response lacks banned_ips (older panel, mixed-version env), the banner falls back to the v1.3.19 single-IP rendering using client_ip + decisions.

Smoke

The smoke gate exercises the operator-visible effect:

  1. Self-block test (current-session IP):

    docker exec argos-prod-crowdsec cscli decisions add \
      --ip <my_lan_ip> --duration 1h
    # Refresh panel (LAN); banner v2 appears with that IP under
    # "this session" source. Click Unban -> 200 OK.
    docker exec argos-prod-crowdsec cscli decisions list \
      --ip <my_lan_ip>
    # Empty.
    

  2. Self-block test (public IP):

    # Wait for first ipify poll (or restart panel; LoadCached
    # rehydrates from previous run).
    docker exec argos-prod-crowdsec cscli decisions add \
      --ip <my_wan_ip> --duration 1h
    # Refresh panel; banner v2 row appears under "panel public
    # IP". Click Unban -> 200 OK.
    

  3. Audit IP capture:

    # Add a whitelist entry via the panel UI (or curl).
    docker run --rm -v argos_prod_data:/data alpine sh -c '
      apk add --no-cache sqlite >/dev/null 2>&1
      sqlite3 /data/argos.db "
        SELECT raw FROM log_entries
         WHERE source=\"audit\"
         ORDER BY id DESC LIMIT 1;"'
    # raw JSON includes _source_ip + _xff_chain.
    

  4. public_ip_self status:

    curl -s -b 'argos_session=...' \
      https://<panel>/api/security/public-ip-self
    # { "ip": "<your-WAN-IP>", "last_at": "...", "detect_url": "..." }
    

  5. Multi-IP edge case:

    # Login from two networks (LAN browser + tethered phone).
    # Ban one of the two IPs via cscli.
    # Banner appears with that specific IP identified under
    # "other active session" source on the OTHER browser.
    

NO tag until smoke real PASSes against prod stack.

Tests

  • 6 publicip tests: ipify JSON parse, plaintext parse, non-IP rejection, disabled-mode no-op, upstream-error resilience, settings rehydrate at boot.
  • Migration 030 forward-shape pin (TestMigration030SessionsClientIP) + rollback chain (TestRollbackLastMigration).
  • Session test CREATE TABLE sessions schema updated to match the new columns.
  • All existing 22 backend test packages still green.

Files changed

Backend

  • backend/migrations/030_sessions_client_ip.up.sql (new)
  • backend/migrations/030_sessions_client_ip.down.sql (new)
  • backend/internal/db/migrate_test.go -- rollback chain + forward-shape pin
  • backend/internal/session/session.go -- CreateOpts, Session.ClientIP/XFFChain, ListActiveIPsForUser
  • backend/internal/session/session_test.go -- schema + Create signature
  • backend/internal/api/handlers.go -- enrichAuditDiff, Handlers.PublicIP field
  • backend/internal/api/auth.go, oidc.go, totp.go -- pass CreateOpts with IP context
  • backend/internal/api/security_self.go -- multi-IP CheckSelf + BannedIPDetail
  • backend/internal/api/security_panel.go (new) -- the 7 new read/write endpoints
  • backend/internal/security/files.go -- WhitelistEntry, ListWhitelist, DeleteWhitelistByID
  • backend/internal/security/publicip/ (new package) -- Detector + 6 tests
  • backend/internal/crowdsec/client.go -- DeleteDecisionByID
  • backend/internal/server/server.go -- 7 new routes + Config.PublicIP field
  • backend/cmd/argos/main.go -- detector wiring + Start
  • backend/internal/api/totp_test.go -- inline schema match

Frontend

  • frontend/src/api/client.ts -- SecurityBannedIPDetail + multi-IP fields on SecurityCheckSelfResponse
  • frontend/src/components/SelfBlockBanner.tsx -- full rewrite for multi-IP, per-IP rows, source labels, optimistic local-state updates, v1.3.19 fallback

Docs

  • docs/release-notes/v1.3.23.md (this file)
  • CHANGELOG.md, mkdocs.yml, version bump

Upgrade

cd argos-edge
git pull
docker compose build
docker compose up -d

Migration 030 applies automatically. Existing sessions stay valid; their client_ip is NULL until next login. The ipify poller starts on first boot; first detection within ~10 seconds.

To disable public-IP detection:

# Either set the env var:
docker compose exec argos sh -c 'ARGOS_PUBLIC_IP_DISABLE=1 /argos'
# Or set the setting to empty via the API or DB:
docker run --rm -v argos_prod_data:/data alpine sh -c '
  apk add --no-cache sqlite >/dev/null 2>&1
  sqlite3 /data/argos.db "
    INSERT OR REPLACE INTO settings (key, value)
    VALUES (\"panel.public_ip_detect_url\", \"\");"'

Not in v1.3.23

These items from the v1.3.20+ elevated scope are deferred to v1.3.24:

  • Full /security UI tabs (Banned IPs / Whitelist / Activity) built on the new endpoints.
  • Dashboard widget consuming /dashboard-stats.
  • Scenarios management UI.
  • AppSec threshold tuning UI.

The split was deliberate: the backend + banner v2 are independently smoke-testable, and tab UI work can iterate against stable endpoints rather than co-evolve and risk re-implementation.