@tafrahq/wt-tunnel-server
v0.7.0
Published
Runs the wt-tunnel server: Caddy, frps and a sign-in service (Google, GitHub, Microsoft, Cloudflare Access) under one supervisor, in the foreground or detached. Downloads pinned, checksum-verified Caddy and frps when you have none.
Maintainers
Readme
wt-tunnel-server
The server half of server mode. wt-tunnel-server start runs three things under one supervisor process:
- Caddy on 443 (and 80, which redirects to https). Dev hosts get certificates on demand from the deployment's own root CA.
- frps on 7000. Tunnel clients connect here with the tunnel token over TLS. Its HTTP vhost listens on 127.0.0.1:8080 only.
- The auth service on 127.0.0.1:4000, inside the supervisor. It runs the sign-in (Google, GitHub, Microsoft or Cloudflare Access) through Better Auth, with no database, and strips the session cookie before a request reaches an app.
The supervisor writes the Caddyfile and frps.toml from the config into a private temporary directory, starts Caddy and frps, and prefixes their output with [caddy] or [frps]. Once every port accepts connections it prints ready. If Caddy or frps exits, the supervisor stops the other and exits with code 1, so a platform restarts it. SIGTERM or SIGINT stops both and exits 0.
The same CLI runs directly on a host and in the published Docker image, which installs it from npm.
Run the image
Each release publishes the image to GitHub Container Registry as ghcr.io/tafrahq/wt-tunnel-server:<version>, for linux/amd64. The image is public, so pulling it needs no token. It runs on any host that gives it raw TCP on ports 443, 80 and 7000:
docker run --rm -p 443:443 -p 80:80 -p 7000:7000 \
-v "$PWD/.server:/etc/wt-tunnel-server:ro" ghcr.io/tafrahq/wt-tunnel-server:<version> \
--config /etc/wt-tunnel-serverThe image runs wt-tunnel-server start with the Caddy and frps it ships, so arguments after the image name go to start. Everything in the container runs as the unprivileged node user, uid 1000, so it must be able to read the mounted files: ca.key and the config are 0600, so the host user that owns them needs uid 1000, or the files a mode that lets the container read them. The Caddy binary carries cap_net_bind_service, so it can bind 443 and 80 without root.
The host also needs these DNS records:
*.<domain>for each dev domain, the tunnel host that clients connect to, and the sign-in host (authHost), all pointing at the host directly. Caddy and frps end TLS themselves, so no proxy may sit in front of them.
Caddy gets the sign-in host a public certificate from Let's Encrypt at each start, so ports 80 and 443 must be reachable from the internet. Let's Encrypt issues at most five certificates for one name a week; past that, and on a host the internet cannot reach, Caddy falls back to its internal CA, which browsers do not trust. Caddy keeps its certificates in a temporary directory that each start creates anew.
For the recommended setup on Fly.io, use wt-tunnel. It creates the server folder, then the app, its IPs, a secret per file in the folder, the Machine and the records.
Run without Docker
npm install --global @tafrahq/wt-tunnel-server
cd my-wt-tunnel/.server
wt-tunnel-server check
wt-tunnel-server start --detachIt needs Node.js 22 or later, on Linux or macOS.
Commands
| Command | What it does | Exit code |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| start | Runs the server in the foreground, with its output on stdout, until Caddy or frps exits or it gets SIGTERM or SIGINT. | 0 after SIGTERM or SIGINT, 1 if Caddy or frps exited or start failed |
| start --detach | Checks the config and finds the binaries, then starts the server in the background. It returns once Caddy, frps and the auth service listen, and prints where the log is. If the server exits during startup, it prints the log's last lines instead. It refuses to start while a server from the same state directory runs. | 0 when the server is up, 1 otherwise |
| stop | Sends SIGTERM to the detached server and waits until it, Caddy and frps have exited. Anything still running after 15 seconds gets SIGKILL. | 0, also when nothing was running |
| status | Prints running: pid <pid>, since <time>, log <path> or stopped. | 0 when running, 3 when stopped |
| check | Checks the config without starting anything. | 0 when valid, 1 otherwise |
start and check read the config from --config <file>, or --config <dir> for <dir>/wt-tunnel-server.json. Without it, they read the JSON in WT_TUNNEL_SERVER_CONFIG, and without that, wt-tunnel-server.json in the current directory, the file wt-tunnel deploy writes into the server folder.
A detached server keeps server.pid and server.log in its state directory: --state-dir <dir>, else WT_TUNNEL_SERVER_STATE_DIR, else $XDG_STATE_HOME/wt-tunnel-server, else ~/.local/state/wt-tunnel-server. Pass the same --state-dir to start, stop and status. The log grows across runs; each start appends a header line.
Caddy and frps
start uses binaries you installed yourself when you pass them:
wt-tunnel-server start --config .server --caddy /usr/local/bin/caddy --frps /usr/local/bin/frpsWithout --caddy or --frps, it uses caddy or frps from PATH. If there is none, it downloads the release it pins (Caddy 2.11.7, frp 0.71.1) for linux or darwin on amd64 or arm64, checks the tarball's SHA-256 against the pinned value, and keeps the binary under ~/.cache/wt-tunnel-server (~/Library/Caches/wt-tunnel-server on macOS, or $XDG_CACHE_HOME/wt-tunnel-server). A tarball that does not match is never unpacked.
Ports
Caddy binds 443 and 80. Outside Docker, only root may bind ports below 1024 by default, so as a normal user Caddy exits, and start names your options:
- Let the Caddy binary bind them, on Linux:
sudo setcap cap_net_bind_service=+ep <path to caddy>.startprints the path it used. A downloaded Caddy keeps the capability until a new version replaces it. - Run
wt-tunnel-serveras root. - Move the ports with
--https-portand--http-port, for example behind a load balancer that forwards 443 to 8443.
--tunnel-port moves frps from 7000. Clients then need the same port as server.port in their wt-tunnel.json. The internal ports (4000 for the auth service, 8080 for the frps vhost, both on 127.0.0.1) are fixed and must be free.
Configure
docs/server-mode.md lists the config's fields. ca.cert and ca.key name the root CA's PEM files, relative to the config file's directory. With the config in WT_TUNNEL_SERVER_CONFIG, they are relative to the current directory. wt-tunnel deploy writes all three files into one folder, .server/.
wt-tunnel ca create <domain>... creates a root: an EC P-256 root, valid for 10 years and name-constrained to the listed domains and every name under them. Keep ca.key secret.
Each device that opens dev URLs must trust the root once. The sign-in service serves it on the sign-in host, the one name with a publicly trusted certificate:
| Path | What it serves |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| https://<authHost>/ca | A page with the root's subject, validity, SHA-256 fingerprint and permitted names, a download button, and trust steps for macOS, iPhone and iPad, Android, Windows, Linux and Firefox. The visitor's platform, read from the User-Agent, comes first. |
| https://<authHost>/ca.crt | The root certificate as PEM, application/x-x509-ca-cert, downloaded as wt-tunnel-ca-<label>.crt. The label is the one in the root's name, wt-tunnel root (<label>), which wt-tunnel sets to the tunnel host. A root named otherwise gives the first domain. |
| https://<authHost>/ca.mobileconfig | An unsigned Apple configuration profile, application/x-apple-aspen-config, with one root certificate payload. Its UUIDs come from the root's fingerprint, so a second download is the same profile. |
The three paths need no sign-in, and responses may be cached for five minutes. They serve the certificate parsed from ca.cert and re-encoded, so nothing else in that field, and never ca.key, reaches a browser. On dev hosts, /ca is an ordinary path behind sign-in; only /_wt/* is reserved there. check and start refuse a ca.cert that holds no readable certificate.
Each sign-in provider's redirect URI is https://<authHost>/api/auth/callback/<provider>, such as .../callback/github. docs/server-mode.md lists the providers and how to set each up.
Build the image
The image installs a published @tafrahq/wt-tunnel-server (0.7.0 or later) from npm with npm install --global. Caddy comes from the official caddy:2.11.7 image and frps from the checksum-verified frp release. The build reads no files from its context, so it does not need the repository's source. Pass the version to install:
docker build --build-arg WT_TUNNEL_SERVER_VERSION=<version> \
--platform linux/amd64 -f apps/server/Dockerfile -t wt-tunnel-server apps/serverThe build fails if you leave out WT_TUNNEL_SERVER_VERSION. The release workflow builds and pushes the published image the same way.
Develop
pnpm check at the repository root formats, lints, type-checks and tests this package with the rest of the monorepo. pnpm turbo run build --filter @tafrahq/wt-tunnel-server builds apps/server/dist. tsdown compiles the workspace-only packages into it: @tafrahq/wt-tunnel-contract, which holds the config schema that this server and wt-tunnel both decode, and @tafrahq/wt-tunnel-binaries, which pins and installs Caddy and frps. The published package depends on neither.
