From 6d4c4ebc34118ed20be4418205bae16901e27176 Mon Sep 17 00:00:00 2001 From: jdtw Date: Sat, 1 Aug 2026 09:52:40 -0700 Subject: [PATCH 1/2] Add a tailnet-only Fly deployment for the web frontend The frontend signs with a key that can add, edit and delete any link and performs no authentication of its own, so anyone who can reach it has full control. Today it runs on a VPS bound to all interfaces, which makes its safety a property of that host's firewall rather than of the deployment. This runs it on Fly with no public ingress at all. fly.toml declares no services, so no public address is allocated and no dedicated IP is needed. tailscaled runs in userspace-networking mode, which leaves the container with no externally reachable interface: Tailscale Serve is the only route in, and it accepts connections only from the tailnet. The image cannot be distroless like the server's, since tailscaled runs alongside the client and the entrypoint needs a shell. State is kept in memory rather than on disk, which pairs with an ephemeral auth key so nodes deregister when the machine stops instead of accumulating on every restart. The signing key arrives as a base64 secret and is written to tmpfs, so it never lands in the image or on a volume. The machine cannot scale to zero: Fly wakes stopped machines from its own proxy, and tailnet traffic never traverses it. Co-Authored-By: Claude Opus 5 --- frontend/Dockerfile | 23 +++++++++++++++ frontend/README.md | 61 ++++++++++++++++++++++++++++++++++++++ frontend/entrypoint.sh | 66 ++++++++++++++++++++++++++++++++++++++++++ frontend/fly.toml | 32 ++++++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100755 frontend/entrypoint.sh create mode 100644 frontend/fly.toml 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..e6d1ac5 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,61 @@ +# 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. Two things +enforce that here: + +- `fly.toml` declares no services, so Fly allocates no public ingress. The + app should have no dedicated IP; `fly ips list` should be empty. +- `tailscaled` runs in userspace-networking mode, so the container has no + network interface reachable from outside. Tailscale Serve is the only + path in, and it only accepts connections from the tailnet. + +## Setup + +Create an **ephemeral, reusable** auth key in the Tailscale admin console +(Settings -> Keys). Ephemeral matters: the container keeps no Tailscale +state on disk, so each start registers a new node, and ephemeral nodes are +removed automatically when they go offline instead of accumulating. + +``` +$ fly apps create links-frontend +$ fly secrets set -a links-frontend \ + TS_AUTHKEY="tskey-auth-..." \ + LINKS_PRIVATE_KEY_B64="$(base64 -i ~/.config/links/client-priv.pb)" +$ fly deploy -c frontend/fly.toml +``` + +Then confirm no public ingress was allocated: + +``` +$ fly ips list -a links-frontend # expect no rows +``` + +The frontend is reachable from the tailnet at `https://links..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 + +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. Adding a +bind-address flag to the client would close that gap. diff --git a/frontend/entrypoint.sh b/frontend/entrypoint.sh new file mode 100755 index 0000000..5ff3448 --- /dev/null +++ b/frontend/entrypoint.sh @@ -0,0 +1,66 @@ +#!/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. state=mem: +# keeps no node state on disk, which pairs with an ephemeral auth key: the +# node disappears from the tailnet when this machine stops rather than +# accumulating stale entries on every restart. +/usr/local/bin/tailscaled \ + --tun=userspace-networking \ + --state=mem: \ + --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 + +i=0 +until /usr/local/bin/tailscale --socket="${SOCK}" status >/dev/null 2>&1; do + i=$((i + 1)) + if [ "${i}" -gt 30 ]; then + echo "entrypoint: tailscaled did not become ready" >&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..b364819 --- /dev/null +++ b/frontend/fly.toml @@ -0,0 +1,32 @@ +# 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 Serve inside the +# container, which means no dedicated IP is needed and none should be +# allocated. +# +# Deploy from the repository root: +# fly deploy -c frontend/fly.toml +app = "links-frontend" +primary_region = "sjc" +kill_signal = "SIGINT" +kill_timeout = "5s" + +[build] + dockerfile = "frontend/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" From 29abd4353183d611265c468799487bbcbd4d32f7 Mon Sep 17 00:00:00 2001 From: jdtw Date: Sat, 1 Aug 2026 12:36:53 -0700 Subject: [PATCH 2/2] Fix the frontend deployment against a real tailnet Three problems that only a real deploy surfaced. The dockerfile path in fly.toml is resolved relative to fly.toml itself, not to the build context, so "frontend/Dockerfile" became frontend/frontend/Dockerfile. The build context still has to be the repository root for the Go sources, so it is now passed explicitly: `fly deploy . -c frontend/fly.toml`. `tailscale status` is not a readiness probe. It exits non-zero while the node is stopped or unauthenticated, which is precisely the state tailscaled is in before login, so the wait loop could never succeed. It timed out, set -e exited, and Fly restart-looped the machine. It now waits for the control socket. --state=mem: is incompatible with `serve --https`. Serve provisions a Let's Encrypt certificate and caches it under the state directory, so every TLS handshake failed with "no TailscaleVarRoot". It now uses a state directory on the machine's ephemeral root filesystem. Also correct the README: Serve is not the only route in. In userspace-networking mode tailscaled forwards inbound tailnet connections to local listeners, so the client is reachable directly on its port as well. Both paths are confined to the tailnet, but Serve is not a security boundary and the docs should not imply it is. Co-Authored-By: Claude Opus 5 --- frontend/README.md | 58 ++++++++++++++++++++++++++++++------------ frontend/entrypoint.sh | 23 ++++++++++++----- frontend/fly.toml | 14 ++++++---- 3 files changed, 67 insertions(+), 28 deletions(-) diff --git a/frontend/README.md b/frontend/README.md index e6d1ac5..5e7e70c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -4,30 +4,44 @@ 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. Two things -enforce that here: +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. -- `fly.toml` declares no services, so Fly allocates no public ingress. The - app should have no dedicated IP; `fly ips list` should be empty. -- `tailscaled` runs in userspace-networking mode, so the container has no - network interface reachable from outside. Tailscale Serve is the only - path in, and it only accepts connections from the tailnet. +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). Ephemeral matters: the container keeps no Tailscale -state on disk, so each start registers a new node, and ephemeral nodes are -removed automatically when they go offline instead of accumulating. +(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 set -a links-frontend \ - TS_AUTHKEY="tskey-auth-..." \ - LINKS_PRIVATE_KEY_B64="$(base64 -i ~/.config/links/client-priv.pb)" -$ fly deploy -c frontend/fly.toml +$ fly secrets import -a links-frontend < /run/links/priv.pb export LINKS_PRIVATE_KEY=/run/links/priv.pb -# userspace-networking avoids needing a TUN device or NET_ADMIN. state=mem: -# keeps no node state on disk, which pairs with an ephemeral auth key: the -# node disappears from the tailnet when this machine stops rather than -# accumulating stale entries on every restart. +# 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 \ - --state=mem: \ + --statedir=/var/lib/tailscale \ --socket="${SOCK}" & TAILSCALED_PID=$! @@ -43,11 +48,15 @@ TAILSCALED_PID=$! # 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 /usr/local/bin/tailscale --socket="${SOCK}" status >/dev/null 2>&1; do +until [ -S "${SOCK}" ]; do i=$((i + 1)) if [ "${i}" -gt 30 ]; then - echo "entrypoint: tailscaled did not become ready" >&2 + echo "entrypoint: tailscaled socket never appeared" >&2 exit 1 fi sleep 1 diff --git a/frontend/fly.toml b/frontend/fly.toml index b364819..c3b3bdb 100644 --- a/frontend/fly.toml +++ b/frontend/fly.toml @@ -2,19 +2,23 @@ # # 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 Serve inside the +# public ingress. Reachability comes solely from Tailscale inside the # container, which means no dedicated IP is needed and none should be -# allocated. +# allocated. `fly ips list` should stay empty. # -# Deploy from the repository root: -# fly deploy -c frontend/fly.toml +# 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 = "frontend/Dockerfile" + dockerfile = "Dockerfile" [env] PORT = "9099"