diff --git a/bandwidth-worker/docker-compose.yml b/bandwidth-worker/docker-compose.yml index 61fc5f0..83b4883 100644 --- a/bandwidth-worker/docker-compose.yml +++ b/bandwidth-worker/docker-compose.yml @@ -6,6 +6,9 @@ services: restart: unless-stopped security_opt: - no-new-privileges:true + cap_drop: + - ALL + user: "1000:1000" env_file: .env networks: - edge diff --git a/bandwidth-worker/worker.py b/bandwidth-worker/worker.py index 8cde8b7..1692e85 100644 --- a/bandwidth-worker/worker.py +++ b/bandwidth-worker/worker.py @@ -24,6 +24,10 @@ FRPS_PASS = os.environ["FRPS_DASHBOARD_PASS"] SUPABASE_URL = os.environ.get("SUPABASE_URL", "http://supabase-kong:8000") SERVICE_ROLE = os.environ["SUPABASE_SERVICE_ROLE_KEY"] REDIS_URL = os.environ["REDIS_URL"] +# TTL for the edge active-state cache key (tunnel:active:) read by the +# Caddy forward_auth gate. Short so a deactivation propagates fast and a stale +# "1" self-heals within one poll cycle; refreshed every poll while active. +ACTIVE_TTL = int(os.environ.get("ACTIVE_STATE_TTL_SECONDS", "45")) logging.basicConfig( level=logging.INFO, @@ -98,6 +102,13 @@ def enforce_quota(client: httpx.Client, subdomain: str, row: dict[str, Any], new except Exception as e: log.warning("redis auth-cache invalidate failed for %s: %s", subdomain, e) + # Flip the edge active-state key so the Caddy forward_auth gate denies the + # next request (within ~1 request) even on a still-connected tunnel. + try: + rds.set(f"tunnel:active:{subdomain}", "0", ex=ACTIVE_TTL) + except Exception as e: + log.warning("redis active-state deactivate failed for %s: %s", subdomain, e) + log.warning( "QUOTA EXCEEDED: subdomain=%s used=%d quota=%d -> deactivated", subdomain, new_used, quota, @@ -145,16 +156,49 @@ def record_delta(client: httpx.Client, subdomain: str, delta: int) -> None: enforce_quota(client, subdomain, row, new_used) + # Keep the edge active-state key fresh so the forward_auth gate has a hot, + # authoritative value (covers reactivation and TTL expiry of an active key). + quota = int(row.get("quota_bytes") or 0) + active_now = bool(row.get("is_active")) and (quota <= 0 or new_used < quota) + try: + rds.set(f"tunnel:active:{subdomain}", "1" if active_now else "0", ex=ACTIVE_TTL) + except Exception as e: + log.warning("redis active-state refresh failed for %s: %s", subdomain, e) + + +_schema_checked = False + + +def assert_api_schema(proxies: list[dict[str, Any]]) -> None: + """Fail loud if the frps dashboard API stops returning the expected + camelCase traffic fields (e.g. after an frps upgrade), so the quota + accounting bug cannot silently return.""" + global _schema_checked + if _schema_checked or not proxies: + return + sample = proxies[0] + if "todayTrafficIn" not in sample: + log.error( + "frps API schema mismatch: 'todayTrafficIn' missing from " + "proxy keys=%s; quota accounting is DISABLED until the field " + "names are fixed", + sorted(sample.keys()), + ) + else: + log.info("frps API schema OK: todayTrafficIn present") + _schema_checked = True + def poll_once(client: httpx.Client) -> None: proxies = fetch_proxies(client) log.info("poll: %d proxies", len(proxies)) + assert_api_schema(proxies) for p in proxies: name = p.get("name") or "" if not name: continue - traffic_in = int(p.get("today_traffic_in") or 0) - traffic_out = int(p.get("today_traffic_out") or 0) + traffic_in = int(p.get("todayTrafficIn") or 0) + traffic_out = int(p.get("todayTrafficOut") or 0) total = traffic_in + traffic_out prev = previous_total(name) delta = total - prev diff --git a/caddy/Caddyfile b/caddy/Caddyfile index 77c683f..b66683f 100644 --- a/caddy/Caddyfile +++ b/caddy/Caddyfile @@ -20,11 +20,104 @@ Referrer-Policy "no-referrer" Permissions-Policy "geolocation=(), microphone=(), camera=()" Cross-Origin-Opener-Policy "same-origin" + # SECURITY (R6/W6): ENFORCING Content-Security-Policy for the prod Next.js + # dashboard (app.linumiq.net) and the apex redirect. 'unsafe-inline' is + # required for Next.js App-Router inline flight-data