Tailscale ACL in Practice: Letting Production Servers Be Accessed Without Initiating Outbound Connections

Machine-translated (anthropic, claude-haiku-5-5) from the Chinese original. Read the original in 中文

Contents

In the AI era, a safer ops practice: let production servers be reachable but not initiate outbound connections, and keep them from reaching your laptop, so that if one is compromised, you don't leak more information.

Requirements

I have several production servers in my tailnet, all tagged tag:prod-server. The goal is simple: my devices can reach them, but they cannot initiate connections to any device in the tailnet. That way, even if one production machine is compromised, the attacker can't use Tailscale to move laterally to my laptop or other machines.

The configuration itself is only a few lines, but I ran into three pitfalls: the allow-all rule in the default policy, Tailscale SSH's separate permissions, and the exit node stopping working. I'll record them in order.

First, understand this: default deny, but the default policy allows everything

Tailscale's rule engine defaults to deny: any traffic not allowed by a rule is dropped. That's why there is only accept, no deny.

However, the policy that a new tailnet comes with includes an allow-all rule:

json
{ "src": ["*"], "dst": ["*"], "ip": ["*"] }

So out of the box, all devices can reach each other. That's the effect of this rule, not the engine's default. And * as a source includes tagged devices, so as long as this rule exists, everyone can reach prod-server.

One more point: connections are stateful. When someone connects to prod-server, its replies are allowed through automatically, so you don't need to write any outbound rule for it. Therefore, the way to make it "only accessible" is: don't include any rule with tag:prod-server as the source in the policy.

Network-layer configuration

After deleting the allow-all rule, explicitly add back the access relationships you need:

jsonc
"grants": [
  // 每个用户自己的设备之间互通
  { "src": ["autogroup:member"], "dst": ["autogroup:self"],     "ip": ["*"] },
  // 成员可以访问 prod-server(可按需收紧到 tcp:22、tcp:443)
  { "src": ["autogroup:member"], "dst": ["tag:prod-server"],   "ip": ["*"] },
]

The key is to use autogroup:member as the source rather than *. Tagged devices no longer belong to any user and are not counted as members, so they aren't included. Note that autogroup:self only covers devices you own: in a multi-user tailnet, A can't reach B's devices, and none of the tagged devices are included either.

Pitfall one: SSH rejected

After the change, ssh sg-server returned an error:

text
tailscale: tailnet policy does not permit you to SSH to this node

The network was reachable; what was blocking me was Tailscale SSH. Once tailscale up --ssh is enabled on a server, port 22 is taken over by Tailscale, and besides grants, it also requires the ssh section of the policy to allow this login. The default ssh rule's destination is autogroup:self, and since sg-server is now tagged and no longer owned by me, the login was rejected.

Adding one rule fixes it:

jsonc
"ssh": [
  { "action": "check", "src": ["autogroup:member"], "dst": ["autogroup:self"],
    "users": ["autogroup:nonroot", "root"] },
  { "action": "check", "src": ["autogroup:admin"],  "dst": ["tag:prod-server"],
    "users": ["root", "autogroup:nonroot"] },
]

Using autogroup:admin as the source for production machines allows only the administrator to log in. check periodically requires the browser to re-authenticate, which is more secure than accept.

Pitfall two: the exit node stopped working

One of the prod-servers was also an exit node, and it stopped working after the change. The cause wasn't the tag; it was deleting the allow-all rule. Only devices that can reach autogroup:internet can use an exit node, and the original * implicitly included it.

Add a grant:

jsonc
{ "src": ["autogroup:member"], "dst": ["autogroup:internet"], "ip": ["*"] }

This only allows my devices to reach the internet through the exit node, and gives prod-server no outbound permission at all. If it still doesn't work, check two more things:

  • Use sudo tailscale set --advertise-exit-node to confirm the exit node advertisement is still present. tailscale up must be given all parameters; writing only --advertise-tags may drop it.
  • In the admin console, confirm that "Use as exit node" is still approved, or add "autoApprovers": { "exitNode": ["tag:prod-server"] } to the policy.

That said, running an exit node on a production machine routes personal traffic through it, so if conditions allow, a separate machine is cleaner.

Should prod-servers talk to each other?

With the configuration above, prod-servers can't reach each other or SSH into each other. If the business needs it (for example, an application connecting to a database), you can open only the necessary ports:

jsonc
// grants
{ "src": ["tag:prod-server"], "dst": ["tag:prod-server"], "ip": ["tcp:5432"] }

// ssh:机器之间没人做浏览器认证,用 accept
{ "action": "accept", "src": ["tag:prod-server"], "dst": ["tag:prod-server"], "users": ["deploy"] }

Even then, prod-servers still can't reach personal devices, and Tailscale SSH itself forbids SSH from a tag source to devices owned by a user. The trade-off is that a compromised machine can move laterally to similar machines, so don't open this unless there's a real need.

A lazy shortcut and the final configuration

If the tailnet has only you, you can simplify grants to a single rule:

jsonc
{ "src": ["autogroup:member"], "dst": ["*"], "ip": ["*"] }

dst: * includes all devices, subnet routes, and autogroup:internet, so exit nodes come back too. The source is still member, so prod-server still can't initiate outbound connections. The cost is broader permissions: users can reach each other, and any tagged device or subnet added later is reachable by default. When multiple people use the tailnet, I still recommend writing them separately so that every permission is explicit.

Finally, lock the constraints down with tests, so that if the policy is accidentally modified later, saving will fail immediately:

jsonc
"tests": [
  { "src": "tag:prod-server", "deny": ["autogroup:member:22", "tag:prod-server:22"] },
],
"sshTests": [
  { "src": "[email protected]", "dst": ["tag:prod-server"], "accept": ["root"] },
],

In one sentence: remove the allow-all rule, use only autogroup:member as the source, and add one rule each for SSH and the exit node.

References

Comments