Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions frontend/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
87 changes: 87 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
@@ -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.<tailnet>.ts.net` (via Serve, with a real
certificate) and directly at `http://<tailnet-ip>: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 <<EOF
TS_AUTHKEY=tskey-auth-...
LINKS_PRIVATE_KEY_B64=$(base64 -i ~/.config/links/client-priv.pb)
EOF
$ fly deploy . -c frontend/fly.toml
```

`fly secrets import` reads from stdin, so neither value appears in the
process table the way `fly secrets set` would.

Pass the repository root explicitly to `fly deploy`: it is the build
context, and the Dockerfile needs the Go sources. The `dockerfile` key in
`fly.toml` is resolved relative to `fly.toml` itself, not to that context.

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.<tailnet>.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.
75 changes: 75 additions & 0 deletions frontend/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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://<hostname>.<tailnet>.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}"
36 changes: 36 additions & 0 deletions frontend/fly.toml
Original file line number Diff line number Diff line change
@@ -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"
Loading