diff --git a/.env.example b/.env.example index d8921f0..dc7ba31 100644 --- a/.env.example +++ b/.env.example @@ -185,13 +185,18 @@ ADMIN_HOSTS=admin.dev.clupilot.com,10.10.90.185,localhost,127.0.0.1 # ── docker-compose knobs (host-side) ───────────────────────────────────── HOST_UID=1000 HOST_GID=1000 -APP_PORT=80 +# Behind a reverse proxy — which is every production install — these must stay +# on loopback: Docker publishes ports ahead of UFW, so anything on 0.0.0.0 is +# reachable from the internet regardless of the firewall, and reaching a backend +# directly skips every hostname and address rule the proxy enforces. +# For local development without a proxy, set APP_PORT=80. +APP_PORT=127.0.0.1:8080 # Vite dev server (HMR). Off by default: over the HTTPS domains the # browser cannot load assets from http://:5173. Set true only when # working over http://10.10.90.185, then run: docker compose up -d app VITE_AUTOSTART=false VITE_PORT=5173 -REVERB_HOST_PORT=8080 +REVERB_HOST_PORT=127.0.0.1:8081 DB_HOST_PORT=3306 # ── CI (Gitea Actions) ────────────────────────────────────────────────────── diff --git a/app/Console/Commands/ConsoleAccess.php b/app/Console/Commands/ConsoleAccess.php index dca514a..7329945 100644 --- a/app/Console/Commands/ConsoleAccess.php +++ b/app/Console/Commands/ConsoleAccess.php @@ -53,13 +53,16 @@ class ConsoleAccess extends Command */ private function caddy(): int { - // The switch has to reach the proxy too. With the restriction off the - // application lets everyone through on purpose — and a proxy still - // holding the old list would keep rejecting them before Laravel ever - // sees the request, so "open" in the console would do nothing at all. - $ranges = RestrictConsoleNetwork::isRestricted() - ? RestrictConsoleNetwork::allowedRanges() - : ['0.0.0.0/0', '::/0']; + // Always the allowlist — never 0.0.0.0/0, not even when the console's + // own restriction is switched off. + // + // The owner's rule is absolute: the console is reachable over the + // management VPN, or from an address they have listed, and from nowhere + // else. Letting the console's "open" switch also open the PROXY would + // put the console on the public internet with one click, which is + // exactly the state that rule exists to prevent. Switching it off + // relaxes the application's own check; the proxy keeps its list. + $ranges = RestrictConsoleNetwork::allowedRanges(); // Never empty: an empty remote_ip matcher matches NOTHING in Caddy, and // the console would become unreachable from anywhere at all — including diff --git a/bin/clupilot b/bin/clupilot index 19eb8f4..f222609 100755 --- a/bin/clupilot +++ b/bin/clupilot @@ -49,7 +49,14 @@ case "$cmd" in ps) docker compose ps ;; logs) if [[ $# -gt 0 ]]; then docker compose logs -f "$@"; else docker compose logs -f; fi ;; health) - _aport="$(sed -n 's/^APP_PORT=//p' .env 2>/dev/null | head -1)"; _aport="${_aport:-80}" + # APP_PORT may carry a bind address (127.0.0.1:8080), which is the + # SAFE default — Docker publishes ahead of UFW. Take the port only, or + # the health check builds http://localhost:127.0.0.1:8080/ and reports + # the site down when it is fine. + _aport="$(sed -n 's/^APP_PORT=//p' .env 2>/dev/null | head -1)"; # 8080, matching the compose default. Checking 80 would report a healthy + # default deployment as unreachable. + _aport="${_aport:-127.0.0.1:8080}" + _aport="${_aport##*:}" printf 'HTTP : '; curl -sS -o /dev/null -w '%{http_code}\n' "http://localhost:${_aport}/" || true docker compose ps ;; artisan) docker compose exec -u "$UIDGID" app php artisan "$@" ;; diff --git a/deploy/caddy/Caddyfile.example b/deploy/caddy/Caddyfile.example new file mode 100644 index 0000000..bbca7f6 --- /dev/null +++ b/deploy/caddy/Caddyfile.example @@ -0,0 +1,102 @@ +{ + servers { + # Address-based rules and QUIC early data do not mix: a decision taken + # on 0-RTT data can answer 425, and some browsers do not retry — an + # intermittent lockout from the console, which is the worst kind. + protocols h1 h2 + } + # Replace: this address receives the certificate-expiry notices for THIS + # installation. Left as the copied original, they reach whoever wrote the + # file instead of whoever runs the server. + email you@example.com +} + +# Reference reverse-proxy configuration. +# +# Copy to /etc/caddy/Caddyfile, replace the hostnames, then: +# sudo bash deploy/install-agent.sh +# which replaces the console's allowlist line with an import generated from the +# list the owner keeps in the console itself. +# +# The rule this file exists to enforce: only the marketing site, the customer +# portal and the status page are public. The console and the websocket endpoint +# must not merely refuse — they must be indistinguishable from a hostname that +# serves nothing at all. An empty 404 is itself information: it says something +# terminates TLS here and chose not to answer. + + + +# ── Customer portal ────────────────────────────────────────────────────────── +app.example.com { + reverse_proxy 127.0.0.1:8080 +} + +# ── Stripe webhook, and nothing else ───────────────────────────────────────── +# The application answers the whole site on every hostname, so without this the +# login and registration pages are served here too. Narrowed to the one request +# this hostname exists for; everything else never reaches PHP. +api.example.com { + @webhook { + method POST + path /webhooks/stripe + } + handle @webhook { + reverse_proxy 127.0.0.1:8080 + } + handle { + respond 404 + } +} + +# ── Websockets (Reverb) ────────────────────────────────────────────────────── +ws.example.com { + # Only an actual websocket upgrade. A browser opening this name has no + # business here, and answering it — even with a 404 — advertises that + # something is listening. + @websocket { + header Connection *Upgrade* + header Upgrade websocket + } + handle @websocket { + reverse_proxy 127.0.0.1:8081 + } + handle { + abort + } +} + +# ── Operator console ───────────────────────────────────────────────────────── +# The network-level lock. The hostname checks inside the application are +# defence in depth — a Host header is chosen by the caller, so they cannot +# decide WHO gets through. This can. +# +# 10.66.0.0/24 is the WireGuard subnet. 62.178.50.33 is temporary, so the first +# VPN access can be created at all; it comes out once that is done. +admin.example.com { + import /etc/caddy/clupilot-console-allow.conf + handle @allowed { + reverse_proxy 127.0.0.1:8080 + } + # Nothing at all for anyone else. An empty 404 still says "something + # terminates TLS here and chose not to answer" — which is itself the + # information worth hiding. Closing the connection is what a host that does + # not serve this name looks like. + handle { + abort + } +} +www.example.com { + reverse_proxy 127.0.0.1:8080 +} + +status.example.com { + reverse_proxy 127.0.0.1:8080 +} + +# Plain HTTP for the two hidden names. Without this Caddy answers port 80 with +# an automatic redirect to https, which announces that both hostnames are +# served here — the one thing this file exists to avoid. The ACME challenge is +# unaffected: Caddy's own challenge handler runs ahead of these routes. +http://admin.example.com, http://ws.example.com { + abort +} diff --git a/deploy/update.sh b/deploy/update.sh index 51db1ee..164ea0c 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -320,6 +320,40 @@ log "Rebuilding caches" in_app php artisan optimize:clear >/dev/null in_app php artisan optimize >/dev/null +# Existing installations keep whatever .env already said, so safer defaults in +# compose protect new installs only. A backend published on 0.0.0.0 is reachable +# from the internet even with UFW closed — Docker publishes ahead of it — and +# reaching a backend directly skips every hostname and address rule the proxy +# enforces, including the one keeping the console private. +# +# Rewritten only where a reverse proxy is actually in front. A development box +# without one genuinely needs the port exposed, and silently taking it away +# would look like the machine had broken. +# ACTIVE, not merely installed. A retired package still on disk is not a proxy, +# and rebinding a development box to loopback because caddy happens to be +# installed makes it unreachable with no obvious cause. +proxy_running=0 +systemctl is-active --quiet caddy 2>/dev/null && proxy_running=1 +systemctl is-active --quiet nginx 2>/dev/null && proxy_running=1 +# And actually holding 443 — a service that is up but proxying something else +# entirely is no reason to take this application off the network. +if [[ $proxy_running -eq 1 ]] && ! ss -ltn 2>/dev/null | grep -qE ':443\s'; then + proxy_running=0 +fi + +if [[ $proxy_running -eq 1 ]]; then + for var in APP_PORT REVERB_HOST_PORT; do + value="$(sed -n "s/^${var}=//p" .env | tail -1)" + + # A bare number: published on every interface. + if [[ "$value" =~ ^[0-9]+$ ]]; then + sed -i "s|^${var}=.*|${var}=127.0.0.1:${value}|" .env + printf '\033[1;33m !\033[0m %s\n' "${var} was published on all interfaces — bound to 127.0.0.1:${value}." + printf ' %s\n' "Reaching it directly bypassed the reverse proxy entirely. Set ${var}=${value} again if that was deliberate." + fi + done +fi + log "Restarting services" docker compose up -d # Workers hold their PHP classes for the life of the process; without this they diff --git a/docker-compose.yml b/docker-compose.yml index 0e100b1..f4eaf6d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,11 @@ services: volumes: - .:/var/www/html ports: - - "${APP_PORT:-80}:80" + # Loopback by DEFAULT. Docker publishes ports ahead of UFW, so a backend + # published on 0.0.0.0 is reachable from the internet even with the + # firewall closed — and reaching it directly skips the reverse proxy, and + # with it every hostname and address rule the console depends on. + - "${APP_PORT:-127.0.0.1:8080}:80" - "${VITE_PORT:-5173}:5173" # Laravel reads .env from the bind mount. Do NOT inject it as real env vars # (env_file): that would override the test env (phpunit force) and let the @@ -38,7 +42,7 @@ services: volumes: - .:/var/www/html ports: - - "${REVERB_HOST_PORT:-8080}:8080" + - "${REVERB_HOST_PORT:-127.0.0.1:8081}:8080" depends_on: - app - redis diff --git a/tests/Feature/Admin/ConsoleNetworkTest.php b/tests/Feature/Admin/ConsoleNetworkTest.php index 697d82e..946cbad 100644 --- a/tests/Feature/Admin/ConsoleNetworkTest.php +++ b/tests/Feature/Admin/ConsoleNetworkTest.php @@ -145,13 +145,19 @@ it('tells the reverse proxy the same thing it tells itself', function () { ->assertSuccessful(); }); -it('opens the proxy too when the restriction is lifted', function () { - // Otherwise "open" in the console changes nothing a visitor can feel: the - // proxy keeps rejecting them before the application is reached. +it('never opens the proxy to everyone, not even when the restriction is lifted', function () { + // The rule is absolute: the console is reachable over the VPN or from a + // listed address, and nowhere else. Letting the console's own switch also + // open the PROXY would put it on the public internet with one click. Settings::set('console.network_restricted', false); + Settings::set('console.allowed_ips', ['203.0.113.7']); $this->artisan('clupilot:console-access caddy') - ->expectsOutputToContain('0.0.0.0/0') + ->expectsOutputToContain('@allowed remote_ip 10.66.0.0/24 203.0.113.7') + ->assertSuccessful(); + + $this->artisan('clupilot:console-access caddy') + ->doesntExpectOutputToContain('0.0.0.0/0') ->assertSuccessful(); });