Identity and access

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.

Updated Houssam Hammoudi, CTOTested with Headscale 0.29.4, Tailscale 1.102.4

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

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

  1. The tool listens on loopback only. For example:
ini
# 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
  1. The host joins the tailnet with a tag, and Serve publishes the loopback port:
bash
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
  1. The policy grants one group one port, and tests the rest stays closed:
json
{
  "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"]},
  ],
}
  1. 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:

bash
tailscale serve --bg --tcp=80 tcp://127.0.0.1:8080
text
Available 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 off

From the ordinary network, using the container's own network address, neither port answers:

text
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 refused

Alice (group ops) over the tailnet:

bash
wget -qO- http://100.64.0.1/   # through alice's tailscaled
text
admin tool: ok

Bob (group dev) over the tailnet. His own Tailscale client cannot reach the tool, which is not even in his list of peers:

bash
wget -qO- http://100.64.0.1/   # through bob's tailscaled
tailscale status
text
wget: 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:

bash
headscale policy check -f policy.hujson
text
Policy is valid

Mistakes 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 the funnel node 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 deny tests 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 free

The 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