Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02b4fe1fc0 | |||
| f313701e5e |
@@ -6,6 +6,9 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
user: "1000:1000"
|
||||||
env_file: .env
|
env_file: .env
|
||||||
networks:
|
networks:
|
||||||
- edge
|
- edge
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ FRPS_PASS = os.environ["FRPS_DASHBOARD_PASS"]
|
|||||||
SUPABASE_URL = os.environ.get("SUPABASE_URL", "http://supabase-kong:8000")
|
SUPABASE_URL = os.environ.get("SUPABASE_URL", "http://supabase-kong:8000")
|
||||||
SERVICE_ROLE = os.environ["SUPABASE_SERVICE_ROLE_KEY"]
|
SERVICE_ROLE = os.environ["SUPABASE_SERVICE_ROLE_KEY"]
|
||||||
REDIS_URL = os.environ["REDIS_URL"]
|
REDIS_URL = os.environ["REDIS_URL"]
|
||||||
|
# TTL for the edge active-state cache key (tunnel:active:<sub>) 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(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -98,6 +102,13 @@ def enforce_quota(client: httpx.Client, subdomain: str, row: dict[str, Any], new
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("redis auth-cache invalidate failed for %s: %s", subdomain, 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(
|
log.warning(
|
||||||
"QUOTA EXCEEDED: subdomain=%s used=%d quota=%d -> deactivated",
|
"QUOTA EXCEEDED: subdomain=%s used=%d quota=%d -> deactivated",
|
||||||
subdomain, new_used, quota,
|
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)
|
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:
|
def poll_once(client: httpx.Client) -> None:
|
||||||
proxies = fetch_proxies(client)
|
proxies = fetch_proxies(client)
|
||||||
log.info("poll: %d proxies", len(proxies))
|
log.info("poll: %d proxies", len(proxies))
|
||||||
|
assert_api_schema(proxies)
|
||||||
for p in proxies:
|
for p in proxies:
|
||||||
name = p.get("name") or ""
|
name = p.get("name") or ""
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
traffic_in = int(p.get("today_traffic_in") or 0)
|
traffic_in = int(p.get("todayTrafficIn") or 0)
|
||||||
traffic_out = int(p.get("today_traffic_out") or 0)
|
traffic_out = int(p.get("todayTrafficOut") or 0)
|
||||||
total = traffic_in + traffic_out
|
total = traffic_in + traffic_out
|
||||||
prev = previous_total(name)
|
prev = previous_total(name)
|
||||||
delta = total - prev
|
delta = total - prev
|
||||||
|
|||||||
+175
-16
@@ -20,11 +20,104 @@
|
|||||||
Referrer-Policy "no-referrer"
|
Referrer-Policy "no-referrer"
|
||||||
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||||
Cross-Origin-Opener-Policy "same-origin"
|
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 <script> tags (no nonce)
|
||||||
|
# and React/styled inline styles; the prod bundle uses no eval so
|
||||||
|
# 'unsafe-eval' is intentionally omitted. connect-src is pinned to the
|
||||||
|
# prod Supabase API host (REST/auth/storage over https + realtime over wss).
|
||||||
|
Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; img-src 'self' data: https:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' https://api.linumiq.net wss://api.linumiq.net; worker-src 'self' blob:; object-src 'none'"
|
||||||
-Server
|
-Server
|
||||||
-X-Powered-By
|
-X-Powered-By
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# API hosts (api / api-dev) serve only JSON - no HTML/UI - so CSP is set to an
|
||||||
|
# ENFORCING, maximally-restrictive policy. The Next.js app/app-dev hosts keep
|
||||||
|
# Content-Security-Policy-Report-Only (above) until the real UI report stream
|
||||||
|
# is validated.
|
||||||
|
(security_headers_api) {
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Referrer-Policy "no-referrer"
|
||||||
|
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||||
|
Cross-Origin-Opener-Policy "same-origin"
|
||||||
|
Content-Security-Policy "default-src 'none'; frame-ancestors 'none'"
|
||||||
|
-Server
|
||||||
|
-X-Powered-By
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dev dashboard CSP: identical to (security_headers) but connect-src is pinned to
|
||||||
|
# the DEV Supabase API host (api-dev.linumiq.net) over https + wss.
|
||||||
|
(security_headers_app_dev) {
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Referrer-Policy "no-referrer"
|
||||||
|
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||||
|
Cross-Origin-Opener-Policy "same-origin"
|
||||||
|
Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; img-src 'self' data: https:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' https://api-dev.linumiq.net wss://api-dev.linumiq.net; worker-src 'self' blob:; object-src 'none'"
|
||||||
|
-Server
|
||||||
|
-X-Powered-By
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# SECURITY (R6): reject machine-only webhook functions (auth-webhook /
|
||||||
|
# stripe-webhook) and encoded-traversal evasion at the public edge.
|
||||||
|
#
|
||||||
|
# Robustness goal: Caddy and the Kong upstream normalize URIs differently
|
||||||
|
# (Kong collapses duplicate slashes and decodes %2f/%2e; Caddy's raw matcher
|
||||||
|
# does not). A path-literal, single-slash guard is therefore bypassable with
|
||||||
|
# extra slashes (e.g. //functions//v1//auth-webhook , /functions/v1//auth-webhook)
|
||||||
|
# or with percent-encoding. We close every normalization gap by matching on
|
||||||
|
# BOTH the raw, un-normalized request target ({http.request.orig_uri}, for the
|
||||||
|
# encoded tricks) AND the Caddy-decoded path ({http.request.uri.path}, which
|
||||||
|
# neutralises letter-encoding and slash collapsing). The function names are
|
||||||
|
# internal-only and are never legitimately referenced from the public edge, so
|
||||||
|
# ANY request mentioning them - at any slash count, case, or encoding - is
|
||||||
|
# rejected (403). We additionally reject any /functions/ request whose raw
|
||||||
|
# target carries encoded dot/slash/backslash sequences (%2e %2f %5c, their
|
||||||
|
# double-encoded forms %252e %252f, or a literal ..).
|
||||||
|
# get-node intentionally stays public for the Home Assistant add-on.
|
||||||
|
(block_internal_functions) {
|
||||||
|
@block_functions expression `{http.request.orig_uri}.matches("(?i)(auth-webhook|stripe-webhook)") || {http.request.uri.path}.matches("(?i)(auth-webhook|stripe-webhook)") || {http.request.orig_uri}.matches("(?i)^/+functions/+v1/+(auth-webhook|stripe-webhook)([/?]|$)") || {http.request.orig_uri}.matches("(?i)/+functions.*(%252e|%252f|%2e|%2f|%5c|\\.\\.)")`
|
||||||
|
respond @block_functions 403
|
||||||
|
}
|
||||||
|
|
||||||
|
# SECURITY (edge rate limiting): per-client-IP request throttling for the
|
||||||
|
# first-party app/api surfaces. The stock caddy:2.10.2-alpine image ships no
|
||||||
|
# rate limiter, so this config is served by a custom build that adds
|
||||||
|
# github.com/mholt/caddy-ratelimit. Two zones are evaluated per request and the
|
||||||
|
# tighter matching zone wins:
|
||||||
|
# - sensitive_per_ip: low ceiling for the machine/auth endpoints
|
||||||
|
# (/functions/* and /auth/*) that get probed (webhooks, token issuance).
|
||||||
|
# - api_per_ip: a more generous ceiling for ordinary app/api traffic.
|
||||||
|
# Keyed on {remote_host} (the real client IP - Caddy is the internet-facing
|
||||||
|
# edge). Over-limit requests receive 429 with an automatic Retry-After header.
|
||||||
|
# Deliberately NOT imported into the tunnel vhosts (*.linumiq.net /
|
||||||
|
# *.dev.linumiq.net) so Home Assistant streaming/websocket traffic is untouched.
|
||||||
|
(rate_limit_api) {
|
||||||
|
rate_limit {
|
||||||
|
zone sensitive_per_ip {
|
||||||
|
match {
|
||||||
|
path /functions/* /auth/*
|
||||||
|
}
|
||||||
|
key {remote_host}
|
||||||
|
events 8
|
||||||
|
window 1s
|
||||||
|
}
|
||||||
|
zone api_per_ip {
|
||||||
|
key {remote_host}
|
||||||
|
events 20
|
||||||
|
window 1s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Apex -> dashboard redirect
|
# Apex -> dashboard redirect
|
||||||
linumiq.net {
|
linumiq.net {
|
||||||
tls {
|
tls {
|
||||||
@@ -40,7 +133,8 @@ app.linumiq.net {
|
|||||||
on_demand
|
on_demand
|
||||||
}
|
}
|
||||||
import security_headers
|
import security_headers
|
||||||
reverse_proxy web:3000
|
import rate_limit_api
|
||||||
|
reverse_proxy web-prod:3000
|
||||||
}
|
}
|
||||||
|
|
||||||
# Reserved hostname: Supabase API (Kong)
|
# Reserved hostname: Supabase API (Kong)
|
||||||
@@ -48,14 +142,10 @@ api.linumiq.net {
|
|||||||
tls {
|
tls {
|
||||||
on_demand
|
on_demand
|
||||||
}
|
}
|
||||||
import security_headers
|
import security_headers_api
|
||||||
# SECURITY (R2): block machine-only webhook functions from the public edge.
|
import block_internal_functions
|
||||||
# auth-webhook and stripe-webhook are invoked internally over the docker
|
import rate_limit_api
|
||||||
# network (supabase-edge-functions:9000) and must never be callable from the
|
reverse_proxy kong-prod:8000
|
||||||
# internet. get-node stays public for the Home Assistant add-on.
|
|
||||||
@blocked_webhooks path /functions/v1/auth-webhook* /functions/v1/stripe-webhook*
|
|
||||||
respond @blocked_webhooks 403
|
|
||||||
reverse_proxy supabase-kong:8000
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Wildcard tunnel subdomains -> frps vhost HTTP. Per-name HTTP-01 issued on first hit.
|
# Wildcard tunnel subdomains -> frps vhost HTTP. Per-name HTTP-01 issued on first hit.
|
||||||
@@ -66,7 +156,42 @@ api.linumiq.net {
|
|||||||
on_demand
|
on_demand
|
||||||
}
|
}
|
||||||
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
reverse_proxy frps:7080
|
|
||||||
|
# SECURITY (C2): live quota / active-state enforcement at the edge. frps OSS
|
||||||
|
# reuses an established work connection and never re-consults the auth webhook
|
||||||
|
# mid-stream, so a still-connected, over-quota tunnel would otherwise keep
|
||||||
|
# serving 200 indefinitely. We gate every tunnel request through the
|
||||||
|
# tunnel-active edge function (an O(1) redis lookup of tunnel:active:<sub>),
|
||||||
|
# which returns 200 while active+under-quota and 403 once the bandwidth worker
|
||||||
|
# deactivates the tunnel. The worker flips the redis key on deactivation, so a
|
||||||
|
# live tunnel stops serving within ~1 request without the client reconnecting.
|
||||||
|
#
|
||||||
|
# FAIL OPEN: forward_auth only blocks on a definite 403. If the auth endpoint
|
||||||
|
# is unreachable/times out (dial error, 5xx) the request is allowed through by
|
||||||
|
# the handle_errors block below (re-proxied straight to frps) so a backend
|
||||||
|
# outage never takes healthy tunnels (incl. Home Assistant) offline. This hop
|
||||||
|
# is NOT added to app/api/apex (their own vhost blocks). forward_auth gates
|
||||||
|
# every normal HTTP request; the upgrade hop is exempted just below.
|
||||||
|
# Websocket/upgrade requests skip the auth subrequest: Caddy's forward_auth
|
||||||
|
# proxies the original Upgrade headers to the auth backend and tries to do
|
||||||
|
# the websocket handshake against it (502). The HA dashboard HTML/API still
|
||||||
|
# loads over plain HTTP (fully gated), and frps re-validates every
|
||||||
|
# NewWorkConn, so exempting only the upgrade hop is safe and keeps HA
|
||||||
|
# websockets working.
|
||||||
|
@httponly not header Upgrade *
|
||||||
|
forward_auth @httponly supabase-edge-functions:9000 {
|
||||||
|
uri /tunnel-active
|
||||||
|
transport http {
|
||||||
|
dial_timeout 2s
|
||||||
|
response_header_timeout 2s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reverse_proxy frps-prod:7080
|
||||||
|
|
||||||
|
handle_errors {
|
||||||
|
@authdown expression `{http.error.status_code} in [502, 503, 504]`
|
||||||
|
reverse_proxy @authdown frps-prod:7080
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -81,7 +206,8 @@ app-dev.linumiq.net {
|
|||||||
tls {
|
tls {
|
||||||
on_demand
|
on_demand
|
||||||
}
|
}
|
||||||
import security_headers
|
import security_headers_app_dev
|
||||||
|
import rate_limit_api
|
||||||
reverse_proxy web-dev:3000
|
reverse_proxy web-dev:3000
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,10 +216,10 @@ api-dev.linumiq.net {
|
|||||||
tls {
|
tls {
|
||||||
on_demand
|
on_demand
|
||||||
}
|
}
|
||||||
import security_headers
|
import security_headers_api
|
||||||
@blocked_webhooks path /functions/v1/auth-webhook* /functions/v1/stripe-webhook*
|
import block_internal_functions
|
||||||
respond @blocked_webhooks 403
|
import rate_limit_api
|
||||||
reverse_proxy supabase-dev-kong:8000
|
reverse_proxy kong-dev:8000
|
||||||
}
|
}
|
||||||
|
|
||||||
# Dev wildcard tunnel subdomains -> dev frps vhost HTTP. More specific than
|
# Dev wildcard tunnel subdomains -> dev frps vhost HTTP. More specific than
|
||||||
@@ -104,5 +230,38 @@ api-dev.linumiq.net {
|
|||||||
on_demand
|
on_demand
|
||||||
}
|
}
|
||||||
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
reverse_proxy frps-dev:7080
|
|
||||||
|
# SECURITY (C2): live quota / active-state enforcement at the edge - dev
|
||||||
|
# mirror of the *.linumiq.net gate. Gates every tunnel request through the
|
||||||
|
# dev tunnel-active edge function (O(1) redis-dev lookup of
|
||||||
|
# tunnel:active:<sub>) so a still-connected, over-quota dev tunnel is denied
|
||||||
|
# within ~1 request without the client reconnecting. FAIL OPEN: a backend
|
||||||
|
# outage re-proxies straight to frps-dev (handle_errors) so it never takes
|
||||||
|
# healthy dev tunnels offline. Websocket/upgrade requests skip the auth
|
||||||
|
# subrequest (HA websockets); frps-dev re-validates every NewWorkConn.
|
||||||
|
@httponly not header Upgrade *
|
||||||
|
forward_auth @httponly supabase-dev-edge-functions:9000 {
|
||||||
|
uri /tunnel-active
|
||||||
|
transport http {
|
||||||
|
dial_timeout 2s
|
||||||
|
response_header_timeout 2s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reverse_proxy frps-dev:7080
|
||||||
|
|
||||||
|
handle_errors {
|
||||||
|
@authdown expression `{http.error.status_code} in [502, 503, 504]`
|
||||||
|
reverse_proxy @authdown frps-dev:7080
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# SECURITY (R6): exact-vhost enforcement / catch-all. Any HTTPS request whose
|
||||||
|
# Host header does not match one of the explicit site blocks above - including
|
||||||
|
# FQDN trailing-dot evasion like "api.linumiq.net." - is rejected here with 403
|
||||||
|
# instead of falling through to Caddy's default empty 200 response. This ensures
|
||||||
|
# only known vhosts are served and closes the trailing-dot Host bypass.
|
||||||
|
:443 {
|
||||||
|
respond 403 {
|
||||||
|
close
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Custom Caddy v2.10.2 image that adds the HTTP rate-limiting module
|
||||||
|
# (github.com/mholt/caddy-ratelimit) which is absent from the stock
|
||||||
|
# caddy:2.10.2-alpine image. Multi-stage: build the binary with xcaddy, then
|
||||||
|
# drop it into the official runtime image so all stock modules/entrypoint and
|
||||||
|
# the existing Caddyfile config are preserved unchanged.
|
||||||
|
ARG CADDY_VERSION=2.10.2
|
||||||
|
|
||||||
|
FROM caddy:${CADDY_VERSION}-builder-alpine AS builder
|
||||||
|
ARG CADDY_VERSION
|
||||||
|
# Pin the rate-limit module to a known-good commit (master @ 2026-05-21, go.mod
|
||||||
|
# requires caddyserver/caddy v2.10.0 -> compatible with v2.10.2).
|
||||||
|
RUN xcaddy build "v${CADDY_VERSION}" \
|
||||||
|
--with github.com/mholt/caddy-ratelimit@16aecbbcb8ca07dc1c671e263379606ff9493c55
|
||||||
|
|
||||||
|
FROM caddy:${CADDY_VERSION}-alpine
|
||||||
|
# Replace only the caddy binary; keep the official image's entrypoint, CA certs,
|
||||||
|
# and default config layout intact.
|
||||||
|
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
services:
|
services:
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2.10.2-alpine
|
image: caddy-ratelimit:2.10.2
|
||||||
|
# Custom build: stock caddy:2.10.2 + github.com/mholt/caddy-ratelimit.
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.ratelimit
|
||||||
container_name: caddy
|
container_name: caddy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
security_opt:
|
security_opt:
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
name: bandwidth-worker-dev
|
name: bandwidth-worker-dev
|
||||||
services:
|
services:
|
||||||
bandwidth-worker:
|
bandwidth-worker-dev:
|
||||||
build: .
|
build: .
|
||||||
image: bandwidth-worker-dev:1.0.0
|
image: bandwidth-worker-dev:1.0.0
|
||||||
container_name: bandwidth-worker-dev
|
container_name: bandwidth-worker-dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
user: "1000:1000"
|
||||||
env_file: .env
|
env_file: .env
|
||||||
networks:
|
networks:
|
||||||
- dev_edge
|
- dev_edge
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ FRPS_PASS = os.environ["FRPS_DASHBOARD_PASS"]
|
|||||||
SUPABASE_URL = os.environ.get("SUPABASE_URL", "http://supabase-kong:8000")
|
SUPABASE_URL = os.environ.get("SUPABASE_URL", "http://supabase-kong:8000")
|
||||||
SERVICE_ROLE = os.environ["SUPABASE_SERVICE_ROLE_KEY"]
|
SERVICE_ROLE = os.environ["SUPABASE_SERVICE_ROLE_KEY"]
|
||||||
REDIS_URL = os.environ["REDIS_URL"]
|
REDIS_URL = os.environ["REDIS_URL"]
|
||||||
|
# TTL for the edge active-state cache key (tunnel:active:<sub>) 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(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -98,6 +102,13 @@ def enforce_quota(client: httpx.Client, subdomain: str, row: dict[str, Any], new
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("redis auth-cache invalidate failed for %s: %s", subdomain, 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(
|
log.warning(
|
||||||
"QUOTA EXCEEDED: subdomain=%s used=%d quota=%d -> deactivated",
|
"QUOTA EXCEEDED: subdomain=%s used=%d quota=%d -> deactivated",
|
||||||
subdomain, new_used, quota,
|
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)
|
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:
|
def poll_once(client: httpx.Client) -> None:
|
||||||
proxies = fetch_proxies(client)
|
proxies = fetch_proxies(client)
|
||||||
log.info("poll: %d proxies", len(proxies))
|
log.info("poll: %d proxies", len(proxies))
|
||||||
|
assert_api_schema(proxies)
|
||||||
for p in proxies:
|
for p in proxies:
|
||||||
name = p.get("name") or ""
|
name = p.get("name") or ""
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
traffic_in = int(p.get("today_traffic_in") or 0)
|
traffic_in = int(p.get("todayTrafficIn") or 0)
|
||||||
traffic_out = int(p.get("today_traffic_out") or 0)
|
traffic_out = int(p.get("todayTrafficOut") or 0)
|
||||||
total = traffic_in + traffic_out
|
total = traffic_in + traffic_out
|
||||||
prev = previous_total(name)
|
prev = previous_total(name)
|
||||||
delta = total - prev
|
delta = total - prev
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: frps-dev
|
name: frps-dev
|
||||||
services:
|
services:
|
||||||
frps:
|
frps-dev:
|
||||||
image: snowdreamtech/frps:0.65.0
|
image: snowdreamtech/frps:0.65.0
|
||||||
container_name: frps-dev
|
container_name: frps-dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -8,7 +8,7 @@ services:
|
|||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
ports:
|
ports:
|
||||||
# Dev tunnel ingress (dev frpc clients connect here). Public port 7001.
|
# Dev tunnel ingress (dev frpc clients connect here). Public port 7001.
|
||||||
- "7001:7001"
|
- "127.0.0.1:7001:7001"
|
||||||
# Dashboard/API port 7500 unpublished. bandwidth-worker-dev reaches it
|
# Dashboard/API port 7500 unpublished. bandwidth-worker-dev reaches it
|
||||||
# internally via frps-dev:7500 on the dev_edge network.
|
# internally via frps-dev:7500 on the dev_edge network.
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: redis-dev
|
name: redis-dev
|
||||||
services:
|
services:
|
||||||
redis:
|
redis-dev:
|
||||||
image: redis:7.2-alpine
|
image: redis:7.2-alpine
|
||||||
container_name: redis-dev
|
container_name: redis-dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
name: stripe-stub-dev
|
name: stripe-stub-dev
|
||||||
services:
|
services:
|
||||||
stripe-stub:
|
stripe-stub-dev:
|
||||||
build: .
|
build: .
|
||||||
image: stripe-stub-dev:1.0.0
|
image: stripe-stub-dev:1.0.0
|
||||||
container_name: stripe-stub-dev
|
container_name: stripe-stub-dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
user: "1000:1000"
|
||||||
env_file: .env
|
env_file: .env
|
||||||
networks:
|
networks:
|
||||||
- dev_edge
|
- dev_edge
|
||||||
|
|||||||
@@ -1,13 +1,39 @@
|
|||||||
# Dev override: attach kong + edge functions to the external "dev_edge" network
|
# Dev override: attach kong + edge functions to the external "dev_edge" network
|
||||||
# so the shared Caddy and the dev frps can reach them by DNS name, and set the
|
# so the shared Caddy and the dev frps can reach them by DNS name, and set the
|
||||||
# dev tunnel base domain for the on-demand-TLS authorizer + frps auth plugin.
|
# dev tunnel base domain for the on-demand-TLS authorizer + frps auth plugin.
|
||||||
|
#
|
||||||
|
# COLLISION-PROOFING (alias-residual elimination):
|
||||||
|
# * The dev service KEYS are now globally unique (kong-dev / functions-dev), so
|
||||||
|
# the auto service-name alias on the Caddy-shared "dev_edge" network is the
|
||||||
|
# UNIQUE *-dev name -- a bare logical name (kong/functions) can never resolve
|
||||||
|
# to a dev container from Caddy.
|
||||||
|
# * The bare names are re-exposed ONLY on the internal project "default" network
|
||||||
|
# (which Caddy is NOT attached to) so every in-stack consumer that still uses
|
||||||
|
# http://kong:8000 / http://functions:9000 (kong.yml, edge-fn source, studio /
|
||||||
|
# functions env, vector) keeps resolving with zero changes.
|
||||||
services:
|
services:
|
||||||
kong:
|
kong-dev:
|
||||||
networks: [default, dev_edge]
|
networks:
|
||||||
functions:
|
default:
|
||||||
networks: [default, dev_edge]
|
aliases:
|
||||||
|
- kong
|
||||||
|
dev_edge:
|
||||||
|
aliases:
|
||||||
|
- kong-dev
|
||||||
|
functions-dev:
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
aliases:
|
||||||
|
- functions
|
||||||
|
dev_edge:
|
||||||
|
aliases:
|
||||||
|
- functions-dev
|
||||||
|
ask-delegation: {}
|
||||||
environment:
|
environment:
|
||||||
TUNNEL_BASE_DOMAIN: dev.linumiq.net
|
TUNNEL_BASE_DOMAIN: dev.linumiq.net
|
||||||
networks:
|
networks:
|
||||||
dev_edge:
|
dev_edge:
|
||||||
external: true
|
external: true
|
||||||
|
# On-demand-TLS ask delegation network (shared with prod edge authorizer).
|
||||||
|
ask-delegation:
|
||||||
|
external: true
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ services:
|
|||||||
# Uncomment to use Big Query backend for analytics
|
# Uncomment to use Big Query backend for analytics
|
||||||
# NEXT_ANALYTICS_BACKEND_PROVIDER: bigquery
|
# NEXT_ANALYTICS_BACKEND_PROVIDER: bigquery
|
||||||
|
|
||||||
kong:
|
kong-dev:
|
||||||
container_name: supabase-dev-kong
|
container_name: supabase-dev-kong
|
||||||
image: kong:2.8.1
|
image: kong:2.8.1
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -62,7 +62,7 @@ services:
|
|||||||
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml
|
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml
|
||||||
# https://github.com/supabase/cli/issues/14
|
# https://github.com/supabase/cli/issues/14
|
||||||
KONG_DNS_ORDER: LAST,A,CNAME
|
KONG_DNS_ORDER: LAST,A,CNAME
|
||||||
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
|
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination
|
||||||
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
|
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
|
||||||
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
|
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
|
||||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||||
@@ -299,7 +299,7 @@ services:
|
|||||||
PG_META_DB_USER: supabase_admin
|
PG_META_DB_USER: supabase_admin
|
||||||
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
|
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
|
||||||
functions:
|
functions-dev:
|
||||||
container_name: supabase-dev-edge-functions
|
container_name: supabase-dev-edge-functions
|
||||||
image: supabase/edge-runtime:v1.58.3
|
image: supabase/edge-runtime:v1.58.3
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -316,6 +316,7 @@ services:
|
|||||||
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
|
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
|
||||||
REDIS_URL: ${REDIS_URL}
|
REDIS_URL: ${REDIS_URL}
|
||||||
STRIPE_STUB_WEBHOOK_SECRET: ${STRIPE_STUB_WEBHOOK_SECRET}
|
STRIPE_STUB_WEBHOOK_SECRET: ${STRIPE_STUB_WEBHOOK_SECRET}
|
||||||
|
AUTH_WEBHOOK_SECRET: ${AUTH_WEBHOOK_SECRET}
|
||||||
volumes:
|
volumes:
|
||||||
- ./volumes/functions:/home/deno/functions:Z
|
- ./volumes/functions:/home/deno/functions:Z
|
||||||
command:
|
command:
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ services:
|
|||||||
- ./frps.toml:/etc/frp/frps.toml:ro
|
- ./frps.toml:/etc/frp/frps.toml:ro
|
||||||
command: ["frps", "-c", "/etc/frp/frps.toml"]
|
command: ["frps", "-c", "/etc/frp/frps.toml"]
|
||||||
networks:
|
networks:
|
||||||
- edge
|
edge:
|
||||||
|
aliases:
|
||||||
|
- frps-prod
|
||||||
networks:
|
networks:
|
||||||
edge:
|
edge:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
user: "1000:1000"
|
||||||
env_file: .env
|
env_file: .env
|
||||||
networks:
|
networks:
|
||||||
- edge
|
- edge
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ services:
|
|||||||
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml
|
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml
|
||||||
# https://github.com/supabase/cli/issues/14
|
# https://github.com/supabase/cli/issues/14
|
||||||
KONG_DNS_ORDER: LAST,A,CNAME
|
KONG_DNS_ORDER: LAST,A,CNAME
|
||||||
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
|
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination
|
||||||
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
|
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
|
||||||
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
|
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
|
||||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||||
@@ -316,6 +316,7 @@ services:
|
|||||||
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
|
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
|
||||||
REDIS_URL: ${REDIS_URL}
|
REDIS_URL: ${REDIS_URL}
|
||||||
STRIPE_STUB_WEBHOOK_SECRET: ${STRIPE_STUB_WEBHOOK_SECRET}
|
STRIPE_STUB_WEBHOOK_SECRET: ${STRIPE_STUB_WEBHOOK_SECRET}
|
||||||
|
AUTH_WEBHOOK_SECRET: ${AUTH_WEBHOOK_SECRET}
|
||||||
volumes:
|
volumes:
|
||||||
- ./volumes/functions:/home/deno/functions:Z
|
- ./volumes/functions:/home/deno/functions:Z
|
||||||
command:
|
command:
|
||||||
|
|||||||
Reference in New Issue
Block a user