Make the private hostnames look like nothing is there, and close the way past the proxy
An empty 404 is itself information: it says something terminates TLS here and chose not to answer. The console and the websocket endpoint now close the connection instead, which is what a hostname that serves nothing looks like. The websocket endpoint answers a genuine upgrade — verified live, 101 — and nothing else; a browser opening it gets a closed connection. Reviewing that turned up two holes that mattered more than the thing being reviewed. The compose defaults published the application and Reverb on every interface. Docker publishes ports ahead of UFW, so those backends were reachable from the internet with the firewall closed — and reaching a backend directly skips the proxy, and with it every hostname and address rule keeping the console private. The defaults are loopback now, and an update rebinds an existing installation that is behind a proxy actually running and holding 443. A development box without one is left alone, because taking its port away would look like the machine had broken. And the console's own "open" switch was emitting 0.0.0.0/0 into the proxy's allowlist. One click would have put the console on the public internet, which is the exact state the owner's rule exists to prevent. Switching it off relaxes the application's check; the proxy keeps its list, always. The reference proxy config carries the arrangement: QUIC early data off, since an address-based decision taken on 0-RTT can answer 425 and some browsers do not retry — an intermittent lockout from the console is the worst kind — and explicit handling for plain HTTP, whose automatic redirect otherwise announces both hidden names to anyone who asks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/portal-design tested-20260727-0953-2bb1d7c
parent
c4649571df
commit
2bb1d7cc3b
|
|
@ -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://<ip>: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) ──────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "$@" ;;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue