michcia's notes

declarative tailnet sharing? we asked 2 gay girls and here's what they said

Some weeks ago I was browsing Tailscale settings and I noticed that there's a cool sounding feature in there I haven't seen before: declarative node sharing between tailnets.

For reasons that might feel confusing at first, it seemed like a very important feature to me. On one hand, I don't run a lot of homelab servers. My network is nothing like the "Greater Seattle Polycule infrastructure" hosting "god's own Plex server"1. But on the other hand, my tailnet spans two home networks, mine and my parents'2, and I have servers in both – it could be nice to separate that into another Tailscale org, so that my parents can't trivially peek into my own network. And then, some of the servers I would like to share with Tea and maybe later some other people. I even have a little split-DNS setup for an "intranet" domain, which fails whenever power goes out at my parents', because CoreDNS tries to run before tailscale0 is ready, and I'm too lazy to fix that.

If I'm given too much free time, I will definitely end up reinventing BeyondCorp from first principles, but in the meantime, Tailscale has been enough. What I would like to fix the most is that 1) effectively, my parents' laptop is bound to my personal Tailscale account, and 2) I've only link-shared one server with Tea, which stops me from moving services around between servers.

So a week ago, when I got past the waitlist for declarative sharing, me and Tea jumped onto it to try it out. And, largely, it works. That said, we spent a few hours troubleshooting things that were not obvious to us as basic users, especially ACLs.

tldr how the feature works

Let's stare at the JSON policy examples given by the docs. This is a simpler example, where nodes are only shared in one direction. For the tailnet that is sharing nodes to another, you have to do something like this:

{
  "externalTailnets": {
    "<receiving-tailnet-alias>": {
      "externalID": "<receiving-tailnet-id>",
      "allowIncomingConnections": true,
    }
  },
  "grants": [
    {
      "src": ["group://<receiving-tailnet-alias>/<receiving-group-name>"],
      "dst": ["<servers-being-shared>"],
      "ip": ["*"]
    }
  ],
}

and for the tailnet that is receiving the nodes, you have

{
  "externalTailnets": {
    "<sharing-tailnet-alias>": {
      "externalID": "<sharing-tailnet-id>",
      "allowIncomingConnections": false,
      "allowExternalReferencesTo": ["group:<receiving-group-name>"],
    }
  },

  "groups": {
    "group:<receiving-group-name>": ["<user-1>"]
  }
}

and so the big picture is that, simultaneously, receiving tailnet must be exposing who's in the group that should get access, and sharing tailnet must be exposing some nodes to said group. The easiest way to share a bunch of servers that comes into mind is by creating a tag:server to assign to them.

It's pretty simple to turn it into a symmetric sharing-receiving situation, where each side has a group, and each side has a bunch of nodes being shared.

but tags shake up the semantics in symmetric sharing

The biggest issue we ran into was that just tagging the servers broke existing node sharing between us.

Before tags, I had (more or less) server-m, laptop-m, desktop-m (and multiple others), and Tea had server-t and desktop-t. All my nodes were registered with my Tailscale identity, and her nodes with hers. server-m was link-shared with Tea, and server-t was link-shared with me.

To share server nodes between our tailnets, we put ACL tags on them. This was the biggest misunderstanding. Tagging a node disconnects it from a user identity and it becomes a shared resource owned by the tailnet itself.

Before nodes were tagged, each of server-m, laptop-m and desktop-m were my personal nodes, and could access server-t. Similarly, server-t and desktop-t could access server-m.

After tagging, laptop-m and desktop-m could access server-t, and desktop-t could access server-m, but the rest of the connections got cut off. The servers couldn't talk to each other anymore, because they weren't getting our personal permission anymore, neither from link-sharing nor from declarative sharing.

The solution for us was quite simple, once we realized where the problem was: to allowExternalReferencesTo both group:user-group-name-here and tag:server, and grant access to tag:server to both the group and the tag from the opposite tailnet. Assuming both sides want to use the same names, the ACLs would look something like this:

{
  "externalTailnets": {
    "<opposite-tailnet-alias>": {
      "externalID": "<opposite-tailnet-id>",
      "allowIncomingConnections": true,
      "allowExternalReferencesTo": ["group:silly-humans", "tag:server"],
    }
  },
  "groups": {
    "group:silly-humans": ["<user-1>"]
  },
  "tagOwners": {
	"tag:server": [],
  },
  "grants": [
    {
      "src": ["group://<opposite-tailnet-alias>/silly-humans", "tag://<opposite-tailnet-alias>/server"],
      "dst": ["tag:server"],
      "ip": ["*"]
    }
  ],
}

how did you notice this

By running tailscale status on each affected device. I realized that Tea's server was showing up in there only on my laptop and not on my server, despite me previously being able to connect to the NFS shares there.

There is also another funny aspect of the declarative sharing: that I haven't seen the nodes shared with it in full-computer UI outputs (like the tailscale status), but they seem to show up on the bottom of the Android app, under "tagged devices".


The feature while in alpha is capped at only 3 tailnets you can pair with, which probably limits its usefulness to Actual Polycules for now. For the rest of us, who don't need complex ACLs, I hope this post can be useful to anyone who might have this problem as well.

  1. V. Scott, J. Hoffstetter, "Tran Girlismo" podcast, no idea what episode though, definitely no later than June 30th 2026↩

  2. The question of who gets to be 192.168.0.0/24 was resolved over a decade ago by their previous D-Link router which defaulted to 192.168.2.0/24.↩