Skip to content

Commit 4656c9f

Browse files
authored
gateway: advertise dnsvip CIDRs as Tailscale subnet routes (#654)
1 parent e8d3297 commit 4656c9f

4 files changed

Lines changed: 78 additions & 10 deletions

File tree

cmd/clawpatrol/dnsvip/dnsvip.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ func (a *Allocator) InternalVIPs() (netip.Addr, netip.Addr) {
114114
return a.vipForID(internalID)
115115
}
116116

117+
// CIDRs returns the (v4, v6) prefixes this allocator hands VIPs out
118+
// of. The gateway advertises them as Tailscale subnet routes so
119+
// exit-node-routed clients can reach VIPs at all (see
120+
// advertiseExitRoutes in cmd/clawpatrol).
121+
func (a *Allocator) CIDRs() (netip.Prefix, netip.Prefix) {
122+
return a.cidr4, a.cidr6
123+
}
124+
117125
type entry struct {
118126
ID uint32
119127
Hostname string

cmd/clawpatrol/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3149,6 +3149,14 @@ func runGateway(args []string) {
31493149
if err != nil {
31503150
log.Fatalf("listen: %v", err)
31513151
}
3152+
if tsnetServer != nil {
3153+
// Advertise exit routes plus the dnsvip CIDRs as subnet routes;
3154+
// without the latter, exit-node clients can't reach any v4 VIP
3155+
// (the local inbound filter shrinks a /0 advertisement to
3156+
// public space only). See advertiseExitRoutes.
3157+
vip4, vip6 := g.dnsvip.CIDRs()
3158+
go advertiseExitRoutes(tsnetServer, vip4, vip6)
3159+
}
31523160
if ln != nil {
31533161
log.Printf("gateway listening on %s, %d endpoints across %d profiles",
31543162
ln.Addr(), len(policy.Endpoints), len(policy.Profiles))

cmd/clawpatrol/tailscale.go

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,9 @@ func openListener(cfg *config.Gateway, stateDir string) (*tsnet.Server, net.List
153153
return nil, nil, err
154154
}
155155
_ = bringUp.Close()
156-
// Advertise exit routes so whole-machine and per-process tsnet
157-
// clients can use this node as a Tailscale exit node.
158-
go advertiseExitRoutes(s)
156+
// Route advertisement (exit routes + VIP subnet routes) happens in
157+
// runGateway via advertiseExitRoutes once the dnsvip allocator's
158+
// CIDRs are known.
159159
return s, ln, nil
160160
}
161161

@@ -238,7 +238,28 @@ func tsnetCertDomain(s *tsnet.Server) string {
238238
// node (advertises 0.0.0.0/0 and ::/0). Whole-machine clients on the
239239
// same tailnet can then route all traffic through this gateway; exit
240240
// flows are intercepted via RegisterFallbackTCPHandler in runGateway.
241-
func advertiseExitRoutes(s *tsnet.Server) {
241+
//
242+
// The dnsvip CIDRs are advertised alongside as plain subnet routes.
243+
// The exit-node /0 advertisements alone do NOT make the v4 VIPs
244+
// reachable: tailscaled derives the inbound packet filter's accept
245+
// set (localNets) locally from AdvertiseRoutes, and a /0 route is
246+
// deliberately shrunk to "the internet" (guest-wifi semantics) by
247+
// subtracting removeFromDefaultRoute — which contains 10.0.0.0/8 and
248+
// therefore the v4 VIP range. Inbound exit-node flows to a v4 VIP
249+
// were dropped by the filter's "destination not allowed" check before
250+
// any clawpatrol handler ran, for every client kind (tsnet
251+
// `clawpatrol run` and whole-machine alike). The v6 list strips only
252+
// link-local/multicast/fd7a:115c:a1e0::/48, so fd78:: VIPs always
253+
// passed — which is why v6-capable clients masked the bug. See
254+
// ipn/ipnlocal updateFilterLocked + shrinkDefaultRoute.
255+
//
256+
// Advertising the VIP CIDRs as non-default routes puts them in
257+
// localNets verbatim, so VIP-bound flows reach
258+
// RegisterFallbackTCPHandler / the UDP catch-all like any other
259+
// intercepted traffic. This is purely node-local: it does not require
260+
// the routes to be approved in the tailnet (clients route VIPs via
261+
// the exit-node /0, not via subnet routes). (#653)
262+
func advertiseExitRoutes(s *tsnet.Server, vipCIDRs ...netip.Prefix) {
242263
lc, err := s.LocalClient()
243264
if err != nil {
244265
log.Printf("tsnet: LocalClient for exit routes: %v", err)
@@ -248,13 +269,18 @@ func advertiseExitRoutes(s *tsnet.Server) {
248269
netip.MustParsePrefix("0.0.0.0/0"),
249270
netip.MustParsePrefix("::/0"),
250271
}
272+
for _, p := range vipCIDRs {
273+
if p.IsValid() {
274+
routes = append(routes, p)
275+
}
276+
}
251277
if _, err := lc.EditPrefs(context.Background(), &ipn.MaskedPrefs{
252278
AdvertiseRoutesSet: true,
253279
Prefs: ipn.Prefs{AdvertiseRoutes: routes},
254280
}); err != nil {
255281
log.Printf("tsnet: advertise exit routes: %v", err)
256282
} else {
257-
log.Printf("tsnet: advertised exit routes (0.0.0.0/0, ::/0)")
283+
log.Printf("tsnet: advertised exit routes (%s)", routes)
258284
}
259285
}
260286

doc/tailscale.md

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,22 +89,48 @@ the tailnet ACL must **auto-approve the gateway as an exit node
8989
for the client tag**. Without it, the pref is accepted locally
9090
but every outbound dial silently times out.
9191

92+
Besides the two `/0` exit routes, the gateway advertises the dnsvip
93+
VIP ranges (`10.78.0.0/16` / `fd78::/64` — the per-endpoint virtual
94+
IPs that `clawpatrol.internal`, SSH hosts, ClickHouse native, etc.
95+
resolve to) as plain subnet routes. This is what makes the v4 VIPs
96+
reachable at all: tailscaled derives its inbound packet filter's
97+
accept set locally from the advertised routes, and a bare `/0`
98+
advertisement is deliberately shrunk to public address space
99+
("guest wifi" semantics — `10.0.0.0/8` and friends are stripped), so
100+
without the explicit VIP-range advertisement the gateway itself
101+
silently drops exit-node flows to v4 VIPs. The effect is node-local;
102+
the VIP routes do **not** need to be approved in the admin console
103+
for VIP traffic to work (clients reach VIPs through the exit-node
104+
`/0`, not through subnet routing). Approving or auto-approving them
105+
merely keeps the gateway's machine entry free of "pending route
106+
approval" noise.
107+
92108
Add to your tailnet ACL JSON:
93109

94110
```jsonc
95111
{
96112
"autoApprovers": {
97-
"exitNode": ["tag:client"] // must match tailscale.tags below
113+
"exitNode": ["tag:client"], // must match tailscale.tags below
114+
"routes": {
115+
// dnsvip VIP ranges (optional tidiness, see above); approver
116+
// is the GATEWAY node's tag (the tag on the authkey the
117+
// gateway itself joined with).
118+
"10.78.0.0/16": ["tag:gateway"],
119+
"fd78::/64": ["tag:gateway"]
120+
}
98121
},
99122
"tagOwners": {
100-
"tag:client": ["autogroup:admin"]
123+
"tag:client": ["autogroup:admin"],
124+
"tag:gateway": ["autogroup:admin"]
101125
}
102126
}
103127
```
104128

105-
The gateway already advertises `0.0.0.0/0` + `::/0` via
106-
`advertiseExitRoutes`, so no operator action is needed in the
107-
Tailscale admin console — the ACL above is the whole prereq.
129+
If your ACL is not the permissive default (`accept *:*`), the rules
130+
must also allow client tags to send to the VIP ranges, e.g.
131+
`{"action": "accept", "src": ["tag:client"], "dst":
132+
["10.78.0.0/16:*", "fd78::/64:*"]}``autogroup:internet` does not
133+
include them.
108134

109135
## Operator setup
110136

0 commit comments

Comments
 (0)