mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-25 17:54:54 +09:00
policy/v2: suggest approved exit nodes by default
Matches SaaS; Apple clients hide the exit-node list without a suggestion. Fixes #3415
This commit is contained in:
committed by
Kristoffer Dalby
parent
06fa3075da
commit
c604874dba
@@ -29,35 +29,27 @@ import (
|
||||
// most peers.
|
||||
//
|
||||
// Caps the client reads from the peer view rather than the self view
|
||||
// (suggest-exit-node, dns-subdomain-resolve — see
|
||||
// ipn/ipnlocal/local.go:7534 and node_backend.go:745) are emitted only
|
||||
// when the peer satisfies the cap's emission condition. This function
|
||||
// encodes those conditions; the mapper calls it from
|
||||
// ([tailcfg.NodeAttrSuggestExitNode], read by
|
||||
// [tailscale.com/ipn/ipnlocal.LocalBackend.SuggestExitNode], and
|
||||
// [tailcfg.NodeAttrDNSSubdomainResolve]) are emitted only when the
|
||||
// peer satisfies the cap's emission condition. This function encodes
|
||||
// those conditions; the mapper calls it from
|
||||
// [mapper.MapResponseBuilder.buildTailPeers] and the compat test calls
|
||||
// it to compute the expected per-peer wire shape.
|
||||
func PeerCapMap(peer types.NodeView, peerSelfCaps tailcfg.NodeCapMap) tailcfg.NodeCapMap {
|
||||
if len(peerSelfCaps) == 0 {
|
||||
// suggest-exit-node — surfaced on Peer.CapMap when the peer
|
||||
// advertises exit routes AND those routes are approved, with or
|
||||
// without a nodeAttrs grant: SaaS stamps it by default, and Apple
|
||||
// clients hide the exit-node list without a suggestion. A policy
|
||||
// value, if any, wins. Approval gating prevents the suggestion from
|
||||
// following an advertised-but-not-yet-trusted node.
|
||||
if !peer.IsExitNode() {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out tailcfg.NodeCapMap
|
||||
|
||||
// suggest-exit-node — surfaced on Peer.CapMap when the peer
|
||||
// advertises exit routes AND those routes are approved. Client
|
||||
// reads at ipn/ipnlocal/local.go:7534. Approval gating prevents
|
||||
// the suggestion from following an advertised-but-not-yet-trusted
|
||||
// node.
|
||||
if peer.IsExitNode() {
|
||||
if v, ok := peerSelfCaps[nodecap.SuggestExitNode]; ok {
|
||||
if out == nil {
|
||||
out = tailcfg.NodeCapMap{}
|
||||
}
|
||||
|
||||
out[nodecap.SuggestExitNode] = v
|
||||
}
|
||||
return tailcfg.NodeCapMap{
|
||||
nodecap.SuggestExitNode: peerSelfCaps[nodecap.SuggestExitNode],
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// unmodelledTailnetStateCaps lists [tailcfg.NodeCapability] values
|
||||
|
||||
@@ -396,3 +396,70 @@ func TestNodeAttrsSuggestExitNodeOnPeerCapMap(t *testing.T) {
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
// TestSuggestExitNodeDefaultOnPeerCapMap covers the SaaS default: an
|
||||
// approved exit node carries suggest-exit-node on its peer view with
|
||||
// no nodeAttrs grant, and loses it when approval is withdrawn. Apple
|
||||
// clients hide the exit-node list without it (issue #3415).
|
||||
func TestSuggestExitNodeDefaultOnPeerCapMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := servertest.NewServer(t)
|
||||
user := srv.CreateUser(t, "sed-user")
|
||||
|
||||
exit := servertest.NewClient(t, srv, "sed-exit", servertest.WithUser(user))
|
||||
viewer := servertest.NewClient(t, srv, "sed-viewer", servertest.WithUser(user))
|
||||
|
||||
exit.WaitForPeers(t, 1, 10*time.Second)
|
||||
viewer.WaitForPeers(t, 1, 10*time.Second)
|
||||
|
||||
exitRoutes := []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
}
|
||||
|
||||
exit.Direct().SetHostinfo(&tailcfg.Hostinfo{
|
||||
BackendLogID: "servertest-sed-exit",
|
||||
Hostname: "sed-exit",
|
||||
RoutableIPs: exitRoutes,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
require.NoError(t, exit.Direct().SendUpdate(ctx))
|
||||
cancel()
|
||||
|
||||
peerHasCap := func(want bool) func(*netmap.NetworkMap) bool {
|
||||
return func(nm *netmap.NetworkMap) bool {
|
||||
if nm == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, peer := range nm.Peers {
|
||||
if peer.ComputedName() == "sed-exit" {
|
||||
return peer.CapMap().Contains(nodecap.SuggestExitNode) == want
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
exitID := findNodeID(t, srv, "sed-exit")
|
||||
_, ch, err := srv.State().SetApprovedRoutes(exitID, exitRoutes)
|
||||
require.NoError(t, err)
|
||||
srv.App.Change(ch)
|
||||
|
||||
viewer.WaitForCondition(t, "peer suggest-exit-node without nodeAttrs",
|
||||
10*time.Second, peerHasCap(true))
|
||||
|
||||
// SaaS never stamps it on the exit node's own view.
|
||||
require.False(t, hasCap(exit.Netmap(), nodecap.SuggestExitNode),
|
||||
"suggest-exit-node must not appear on SelfNode by default")
|
||||
|
||||
_, ch, err = srv.State().SetApprovedRoutes(exitID, nil)
|
||||
require.NoError(t, err)
|
||||
srv.App.Change(ch)
|
||||
|
||||
viewer.WaitForCondition(t, "peer suggest-exit-node gone after unapprove",
|
||||
10*time.Second, peerHasCap(false))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user