268 lines
11 KiB
Caddyfile
268 lines
11 KiB
Caddyfile
{
|
|
email office@linumiq.com
|
|
on_demand_tls {
|
|
# SECURITY (R2/F3): closed allow-list authorizer. The edge function returns
|
|
# 200 only for reserved hosts (apex/app/api) and subdomains registered in
|
|
# the tunnels table; 403 otherwise. This prevents unbounded on-demand
|
|
# certificate issuance for arbitrary hostnames.
|
|
ask http://supabase-edge-functions:9000/check-subdomain
|
|
}
|
|
}
|
|
|
|
# SECURITY (R4/F10/W5): baseline response-hardening headers applied to the
|
|
# LinumIQ-controlled surfaces (apex/app/api). HSTS forces HTTPS for a year and
|
|
# is safe for first-party hostnames we fully control.
|
|
(security_headers) {
|
|
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"
|
|
# 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
|
|
-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
|
|
linumiq.net {
|
|
tls {
|
|
on_demand
|
|
}
|
|
import security_headers
|
|
redir https://app.linumiq.net{uri} permanent
|
|
}
|
|
|
|
# Reserved hostname: Next.js dashboard (upstream not yet running in Wave A)
|
|
app.linumiq.net {
|
|
tls {
|
|
on_demand
|
|
}
|
|
import security_headers
|
|
import rate_limit_api
|
|
reverse_proxy web-prod:3000
|
|
}
|
|
|
|
# Reserved hostname: Supabase API (Kong)
|
|
api.linumiq.net {
|
|
tls {
|
|
on_demand
|
|
}
|
|
import security_headers_api
|
|
import block_internal_functions
|
|
import rate_limit_api
|
|
reverse_proxy kong-prod:8000
|
|
}
|
|
|
|
# Wildcard tunnel subdomains -> frps vhost HTTP. Per-name HTTP-01 issued on first hit.
|
|
# NOTE: only HSTS is injected here; Home Assistant sets its own security headers
|
|
# (X-Frame-Options, etc.) and we must not override its CSP / framing behaviour.
|
|
*.linumiq.net {
|
|
tls {
|
|
on_demand
|
|
}
|
|
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
|
|
|
# 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
|
|
}
|
|
}
|
|
|
|
# ============================================================================
|
|
# DEV environment (served by this same shared Caddy instance).
|
|
# Dev upstreams live on the external "dev_edge" network; Caddy is dual-homed on
|
|
# both "edge" (prod) and "dev_edge" (dev). On-demand TLS for these hosts is
|
|
# authorized by the global ask endpoint, which recognises the dev hostnames.
|
|
# ============================================================================
|
|
|
|
# Dev dashboard (Next.js, dev build)
|
|
app-dev.linumiq.net {
|
|
tls {
|
|
on_demand
|
|
}
|
|
import security_headers_app_dev
|
|
import rate_limit_api
|
|
reverse_proxy web-dev:3000
|
|
}
|
|
|
|
# Dev Supabase API (dev Kong)
|
|
api-dev.linumiq.net {
|
|
tls {
|
|
on_demand
|
|
}
|
|
import security_headers_api
|
|
import block_internal_functions
|
|
import rate_limit_api
|
|
reverse_proxy kong-dev:8000
|
|
}
|
|
|
|
# Dev wildcard tunnel subdomains -> dev frps vhost HTTP. More specific than
|
|
# *.linumiq.net, so dev tunnels match here. Only HSTS injected (HA sets its own
|
|
# framing/CSP headers).
|
|
*.dev.linumiq.net {
|
|
tls {
|
|
on_demand
|
|
}
|
|
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
|
|
|
# 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
|
|
}
|
|
}
|