Admin tools reachable only on a tailnet, the secure way
The admin panel is "internal", which in practice means it listens on 0.0.0.0 behind a firewall rule someone wrote in 2021. Nobody is sure the rule still exists, but everybody is sure the panel works from the café.
The short answer
Make the admin tool listen on 127.0.0.1 only and publish it to the tailnet with tailscale serve, so the only network path to it is the tailnet. Tag the host, grant one group the one port it serves, add policy tests that deny everyone else, keep the tool's own login, and never use Funnel for it.
On this page
What goes wrong
Admin tools (a database console, a queue dashboard, a backup UI) listen on all interfaces by default. The plan was a firewall in front. Then the host moves, gets a second interface, or a cloud firewall is edited, and the panel is on the internet.
When a tailnet arrives, teams add it next to the old path instead of instead of it. The tool is now reachable on the tailnet and on the LAN.
The tailnet policy says autogroup:member may reach the tool's host on any
port. Every laptop of every employee, including the one just phished, can now
reach it.
On Tailscale's hosted control plane, someone needs to show the panel to a vendor and turns on Funnel. It is now public, with a friendly URL.
What the docs say
Tailscale Serve lets you route traffic from other devices on your Tailscale network (known as a tailnet) to a local service running on your device.
Source: Tailscale docs, Tailscale Serve
When you use the identity headers to authenticate to a backend service, it's best practice to only have the service listen on localhost.
Source: Tailscale docs, Tailscale Serve
Funnel traffic, which is publicly available, does not include identity headers.
Source: Tailscale docs, Tailscale Serve
The docs recommend localhost in the context of identity headers. It is the right default for every admin tool, headers or not: if the tool cannot be reached except through Serve, the tailnet policy is the only door.
Headscale's features page
marks both Serve and Funnel as not supported. The plain TCP forward used below
(--tcp) is done by the client and worked against Headscale 0.29.4 in the
test. Do not count on HTTPS Serve or identity headers with Headscale.
The secure configuration
- The tool listens on loopback only. For example:
# a systemd drop-in for a tool that takes a listen address flag
[Service]
ExecStart=
ExecStart=/usr/local/bin/admin-tool --listen 127.0.0.1:8080- The host joins the tailnet with a tag, and Serve publishes the loopback port:
tailscale up --login-server=https://hs.example.com --advertise-tags=tag:admin-tools \
--auth-key=file:/run/ts-authkey
tailscale serve --bg --tcp=80 tcp://127.0.0.1:8080
# With TLS in the tool itself: tailscale serve --bg --tcp=443 tcp://127.0.0.1:8443- The policy grants one group one port, and tests the rest stays closed:
{
"groups": {
"group:ops": ["[email protected]"],
"group:dev": ["[email protected]"],
},
"tagOwners": {
"tag:admin-tools": ["group:ops"],
},
"grants": [
// only ops reach the admin tools, and only on the port they serve
{"src": ["group:ops"], "dst": ["tag:admin-tools"], "ip": ["tcp:80"]},
],
"tests": [
{"src": "[email protected]", "accept": ["tag:admin-tools:80"], "deny": ["tag:admin-tools:22"]},
{"src": "[email protected]", "deny": ["tag:admin-tools:80"]},
],
}- Keep the tool's own login (SSO with MFA). The tailnet decides who can reach it; the tool decides who can use it.
On a host where Serve is not an option (a tool that must bind an interface,
or a UDP service), bind the tool to the host's tailnet address (100.x.y.z)
and drop the port on every other interface with the host firewall.
Prove it
From secure-tests/admin-tools-reachable-tailnet/. One container runs the
"admin tool" (a tiny HTTP responder on 127.0.0.1:8080) and three Tailscale
nodes in userspace mode, as uid 1000 with no capabilities. Serve publishes the
port on the tailnet:
tailscale serve --bg --tcp=80 tcp://127.0.0.1:8080Available within your tailnet:
|-- tcp://admin-tool.tail.example.com:80
|-- tcp://100.64.0.1:80
|-- tcp://[fd7a:115c:a1e0::1]:80
|--> tcp://127.0.0.1:8080
Serve started and running in the background.
To disable the proxy, run: tailscale serve --tcp=80 offFrom the ordinary network, using the container's own network address, neither port answers:
http://<host>:8080/ wget: can't connect to remote host (172.21.0.3): Connection refused
http://<host>:80/ wget: can't connect to remote host (172.21.0.3): Connection refusedAlice (group ops) over the tailnet:
wget -qO- http://100.64.0.1/ # through alice's tailscaledadmin tool: okBob (group dev) over the tailnet. His own Tailscale client cannot reach the tool, which is not even in his list of peers:
wget -qO- http://100.64.0.1/ # through bob's tailscaled
tailscale statuswget: server returned error: HTTP/1.1 502 Bad Gateway
failed (exit 1)
100.64.0.3 bob-laptop bob@The policy and its tests pass:
headscale policy check -f policy.hujsonPolicy is validMistakes people make
Two paths to the same tool
A tool on the tailnet and on the LAN is as exposed as the LAN. Remove the old path: loopback bind, or a firewall drop on every interface except the tailnet's.
autogroup:member as the source
That is every personal device in the tailnet. Name the group that needs the tool, and the one port.
Funnel "for five minutes"
Funnel publishes to the internet. There is no five minutes. Share a screen or
add the vendor to a group with a time-limited grant instead. On Tailscale,
Funnel only works for nodes that the funnel node attribute in the policy
covers, so leave that attribute out. Headscale does not support Funnel.
Dropping the tool's own login
The tailnet limits the network. It does not know who inside ops may delete backups. Keep SSO and MFA in the tool.
Untagged hosts
A tool on a host owned by a person inherits that person's rules and dies with their account. Tag servers, and let only ops assign the tag.
No deny tests
The grant that lets ops in is easy to see. The line that lets everyone in is
added later, by someone else. A deny test catches it in review.
Checklist
- Bind every admin tool to 127.0.0.1 or to the tailnet address only.
- Publish with
tailscale serve; never with Funnel, and keep thefunnelnode attribute out of the policy. - Tag admin hosts and restrict who can assign the tag.
- Grant one group the one port each tool serves.
- Add
denytests for every group that must not reach the tools. - Keep SSO and MFA in each tool.
- Scan the host's public and LAN addresses for the tool's ports after each change.
The best firewall rule for an admin panel is the one you never needed, because the panel never listened anywhere else.
H2-CPQE
Learn it on a live range
Private access and tailnets, in Edge and Post-Quantum Networking: a real host in your browser, and every objective checked on the machine.
Start freeThe Secure Way
More on identity and access
Self-hosted identity with Zitadel, private access with Headscale, break-glass and offboarding.
All identity and access guides