diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..25eaa52 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,23 @@ +# The web frontend, published only to a tailnet. +# +# Unlike the server image this cannot be distroless: the container runs +# tailscaled alongside the client, so it needs a shell for the entrypoint. +FROM golang:1.25-alpine AS builder +WORKDIR /app +COPY . . +RUN CGO_ENABLED=0 go build -o /app/client ./cmd/client + +FROM tailscale/tailscale:stable AS tailscale + +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates iptables ip6tables + +COPY --from=tailscale /usr/local/bin/tailscaled /usr/local/bin/tailscaled +COPY --from=tailscale /usr/local/bin/tailscale /usr/local/bin/tailscale +COPY --from=builder /app/client /app/client +COPY frontend/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +# No EXPOSE and no [[services]] in fly.toml: nothing is published to the +# public internet. The only way in is over the tailnet. +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..5e7e70c --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,87 @@ +# Tailnet-only web frontend + +Runs `client --server` on Fly, published to a tailnet and nowhere else. + +The frontend signs requests with a key that can add, edit and delete any +link, and it performs no authentication of its own -- anyone who can reach +it has full control. It must therefore never be exposed publicly. What +enforces that here is that the app has no public ingress at all: `fly.toml` +declares no services, so Fly allocates no public address and the app needs +no dedicated IP. `fly ips list` should be empty, and that is worth checking +after any change to the config. + +Reachability comes entirely from Tailscale. Note that `tailscale serve` is +not the only way in: in userspace-networking mode tailscaled also forwards +inbound tailnet connections to local listeners, so the client is reachable +both at `https://links..ts.net` (via Serve, with a real +certificate) and directly at `http://:9099`. Both are confined +to the tailnet, but the second bypasses Serve, so do not treat Serve as a +security boundary -- the boundary is the tailnet. + +## Setup + +Create an **ephemeral, reusable** auth key in the Tailscale admin console +(Settings -> Keys). Reusable because every deploy replaces the machine's +filesystem and therefore its Tailscale state, so the key is used again on +each deploy. Ephemeral so the node from the previous deploy is cleaned up +automatically instead of leaving `links-1`, `links-2` behind. + +``` +$ fly apps create links-frontend +$ fly secrets import -a links-frontend <.ts.net`. + +## Configuration + +| Variable | Where | Purpose | +| --- | --- | --- | +| `TS_AUTHKEY` | secret | Tailscale auth key, ephemeral and reusable | +| `LINKS_PRIVATE_KEY_B64` | secret | base64 of the client signing key | +| `LINKS_ADDR` | `fly.toml` | the links server to talk to | +| `TS_HOSTNAME` | `fly.toml` | tailnet hostname, defaults to `links` | +| `PORT` | `fly.toml` | local port the client listens on | + +The signing key is written to `/run/links/priv.pb` at startup, on tmpfs, so +it never lands in the image or on a volume. + +## Notes + +`tailscaled` needs a real state directory, not `--state=mem:`. Serve +provisions a Let's Encrypt certificate and caches it under the state dir, so +in-memory state fails every TLS handshake with `no TailscaleVarRoot`. The +state lives on the machine's ephemeral root filesystem, which is fine: it +survives restarts and is discarded when the machine is replaced. + +`tailscale status` is not a usable readiness probe for the daemon. It exits +non-zero while the node is stopped or unauthenticated -- exactly the state +tailscaled is in before logging in -- so the entrypoint waits for the +control socket instead. + +The machine cannot scale to zero. Fly wakes stopped machines from its own +proxy, and tailnet traffic never passes through it, so a stopped machine is +simply unreachable. + +`client --server` binds all interfaces, not just loopback. With no Fly +services declared there is no public route to it, but it is reachable over +Fly's private 6PN network from other apps in the same organization, and over +the tailnet directly as noted above. Adding a bind-address flag to the +client would narrow this to loopback and make Serve the only route in. diff --git a/frontend/entrypoint.sh b/frontend/entrypoint.sh new file mode 100755 index 0000000..3b45190 --- /dev/null +++ b/frontend/entrypoint.sh @@ -0,0 +1,75 @@ +#!/bin/sh +# Brings up tailscaled, publishes the frontend to the tailnet, and runs the +# client. Nothing here listens on a public address: the container declares no +# Fly services, and tailscaled runs in userspace-networking mode so the only +# route in is Tailscale Serve. +set -eu + +PORT="${PORT:-9099}" +TS_HOSTNAME="${TS_HOSTNAME:-links}" +SOCK=/run/tailscaled.sock + +if [ -z "${TS_AUTHKEY:-}" ]; then + echo "entrypoint: TS_AUTHKEY must be set" >&2 + exit 1 +fi +if [ -z "${LINKS_PRIVATE_KEY_B64:-}" ]; then + echo "entrypoint: LINKS_PRIVATE_KEY_B64 must be set" >&2 + exit 1 +fi +if [ -z "${LINKS_ADDR:-}" ]; then + echo "entrypoint: LINKS_ADDR must be set" >&2 + exit 1 +fi + +# The client takes a path to the signing key, so materialize it from the +# secret. /run is a tmpfs, so it never reaches the image or a volume. +mkdir -p /run/links +umask 077 +echo "${LINKS_PRIVATE_KEY_B64}" | base64 -d > /run/links/priv.pb +export LINKS_PRIVATE_KEY=/run/links/priv.pb + +# userspace-networking avoids needing a TUN device or NET_ADMIN. +# +# The state directory is required, not optional: `serve --https` provisions a +# Let's Encrypt certificate and caches it under the state dir, so running with +# --state=mem: fails every TLS handshake with "no TailscaleVarRoot". The +# machine has no volume, so this lives on the ephemeral root filesystem and is +# discarded when the machine is replaced -- which is why the auth key must be +# reusable, and ephemeral so the old node is cleaned up. +mkdir -p /var/lib/tailscale +/usr/local/bin/tailscaled \ + --tun=userspace-networking \ + --statedir=/var/lib/tailscale \ + --socket="${SOCK}" & +TAILSCALED_PID=$! + +# Shut tailscaled down with the container so the ephemeral node deregisters +# promptly instead of lingering until it times out. +trap 'kill "${TAILSCALED_PID}" 2>/dev/null || true' INT TERM EXIT + +# Wait for the daemon's control socket. Note that `tailscale status` is NOT a +# usable readiness probe here: it exits non-zero while the node is stopped or +# unauthenticated, which is exactly the state tailscaled is in before we log +# in, so polling it would never succeed. +i=0 +until [ -S "${SOCK}" ]; do + i=$((i + 1)) + if [ "${i}" -gt 30 ]; then + echo "entrypoint: tailscaled socket never appeared" >&2 + exit 1 + fi + sleep 1 +done + +/usr/local/bin/tailscale --socket="${SOCK}" up \ + --authkey="${TS_AUTHKEY}" \ + --hostname="${TS_HOSTNAME}" + +# Publish https://..ts.net to the local client. Serve is +# what makes the frontend reachable at all in userspace-networking mode, and +# it is reachable only from within the tailnet. +/usr/local/bin/tailscale --socket="${SOCK}" serve --bg --https=443 "http://127.0.0.1:${PORT}" + +echo "entrypoint: serving the links frontend to the tailnet as ${TS_HOSTNAME}" +exec /app/client --server "${PORT}" diff --git a/frontend/fly.toml b/frontend/fly.toml new file mode 100644 index 0000000..c3b3bdb --- /dev/null +++ b/frontend/fly.toml @@ -0,0 +1,36 @@ +# Fly config for the links web frontend. +# +# Deliberately declares no [[services]] and no [[http_service]]: the frontend +# grants unauthenticated add/view access to every link, so it must never have +# public ingress. Reachability comes solely from Tailscale inside the +# container, which means no dedicated IP is needed and none should be +# allocated. `fly ips list` should stay empty. +# +# Deploy from the repository root, passing it explicitly as the build context +# so the Dockerfile can reach the Go sources: +# fly deploy . -c frontend/fly.toml +# +# Note that `dockerfile` below is resolved relative to THIS file, not to the +# build context. +app = "links-frontend" +primary_region = "sjc" +kill_signal = "SIGINT" +kill_timeout = "5s" + +[build] + dockerfile = "Dockerfile" + +[env] + PORT = "9099" + TS_HOSTNAME = "links" + LINKS_ADDR = "https://jdtw.us" + +[experimental] + auto_rollback = true + +# One always-on machine. Autostop is not usable here: Fly wakes stopped +# machines from its own proxy, and tailnet traffic never passes through it, +# so a stopped machine would simply be unreachable. +[[vm]] + size = "shared-cpu-1x" + memory = "256mb"