mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-17 05:52:17 +09:00
9b6bfd05bc
Use topology approved_routes instead of approving all advertised routes. Nodes with unapproved exit routes were incorrectly getting exit routes approved, causing AllowedIPs mismatches. Updates #3157
660 lines
18 KiB
Go
660 lines
18 KiB
Go
package servertest_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/juanfont/headscale/hscontrol/servertest"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tailscale/hujson"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/netmap"
|
|
)
|
|
|
|
// goldenFile represents a golden capture from Tailscale SaaS with full
|
|
// netmap data per node.
|
|
type goldenFile struct {
|
|
TestID string `json:"test_id"`
|
|
Error bool `json:"error"`
|
|
Input struct {
|
|
FullPolicy json.RawMessage `json:"full_policy"`
|
|
} `json:"input"`
|
|
Topology struct {
|
|
Nodes map[string]goldenNode `json:"nodes"`
|
|
} `json:"topology"`
|
|
Captures map[string]goldenCapture `json:"captures"`
|
|
}
|
|
|
|
type goldenNode struct {
|
|
Hostname string `json:"hostname"`
|
|
Tags []string `json:"tags"`
|
|
IPv4 string `json:"ipv4"`
|
|
IPv6 string `json:"ipv6"`
|
|
AdvertisedRoutes []string `json:"routable_ips"`
|
|
ApprovedRoutes []string `json:"approved_routes"`
|
|
}
|
|
|
|
type goldenCapture struct {
|
|
PacketFilterRules json.RawMessage `json:"packet_filter_rules"`
|
|
Netmap *goldenNetmap `json:"netmap"`
|
|
Whois map[string]goldenWhois `json:"whois"`
|
|
}
|
|
|
|
type goldenNetmap struct {
|
|
Peers []goldenPeer `json:"Peers"`
|
|
PacketFilterRules json.RawMessage `json:"PacketFilterRules"`
|
|
}
|
|
|
|
type goldenPeer struct {
|
|
Name string `json:"Name"`
|
|
AllowedIPs []string `json:"AllowedIPs"`
|
|
PrimaryRoutes []string `json:"PrimaryRoutes"`
|
|
Tags []string `json:"Tags"`
|
|
}
|
|
|
|
type goldenWhois struct {
|
|
PeerName string `json:"peer_name"`
|
|
Response *json.RawMessage `json:"response"`
|
|
}
|
|
|
|
// viaCompatTests lists golden captures that exercise via grant steering.
|
|
var viaCompatTests = []struct {
|
|
id string
|
|
desc string
|
|
}{
|
|
{"via-grant-v29", "crossed subnet steering: group-a via router-a, group-b via router-b"},
|
|
{"via-grant-v30", "crossed mixed: subnet via router-a/b, exit via exit-b/a"},
|
|
{"via-grant-v31", "peer connectivity + via exit A/B steering"},
|
|
{"via-grant-v36", "full complex: peer connectivity + crossed subnet + crossed exit"},
|
|
// TODO: V33 and V35 are not yet compatible due to:
|
|
// - V33: PrimaryRoutes election differs (node ID ordering between SaaS and headscale)
|
|
// - V35: Extra peer visibility (autoApprovers create different routing topology)
|
|
// The inferNodeRoutes Tier 3 fix enables route inference for these files,
|
|
// but the above issues need resolution first.
|
|
}
|
|
|
|
// TestViaGrantMapCompat loads golden captures from Tailscale SaaS and
|
|
// compares headscale's MapResponse structure against the captured netmap.
|
|
//
|
|
// The comparison is IP-independent: it validates peer visibility, route
|
|
// prefixes in AllowedIPs, and PrimaryRoutes — not literal Tailscale IP
|
|
// addresses which differ between Tailscale SaaS and headscale allocation.
|
|
//
|
|
// CROSS-DEPENDENCY WARNING:
|
|
// This test reads golden files from ../policy/v2/testdata/grant_results/
|
|
// (specifically via-grant-v29, v30, v31, v36). These files are shared
|
|
// with TestGrantsCompat in the policy/v2 package. Any changes to the
|
|
// file format, field structure, or naming must be coordinated with
|
|
// BOTH tests.
|
|
//
|
|
// Fields consumed by this test (but NOT by TestGrantsCompat):
|
|
// - captures[name].netmap (Peers, AllowedIPs, PrimaryRoutes, PacketFilterRules)
|
|
// - topology.nodes[name].tags (used for servertest node creation)
|
|
//
|
|
// Fields consumed by TestGrantsCompat (but NOT by this test):
|
|
// - captures[name].packet_filter_rules (golden filter rule comparison)
|
|
// - input.api_response_code/body (error case handling)
|
|
func TestViaGrantMapCompat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range viaCompatTests {
|
|
t.Run(tc.id, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(
|
|
"..", "policy", "v2", "testdata", "grant_results", tc.id+".hujson",
|
|
)
|
|
data, err := os.ReadFile(path)
|
|
require.NoError(t, err, "failed to read golden file %s", path)
|
|
|
|
ast, err := hujson.Parse(data)
|
|
require.NoError(t, err, "failed to parse HuJSON in %s", path)
|
|
ast.Standardize()
|
|
|
|
var gf goldenFile
|
|
require.NoError(t, json.Unmarshal(ast.Pack(), &gf))
|
|
|
|
if gf.Error {
|
|
t.Skipf("test %s is an error case", tc.id)
|
|
return
|
|
}
|
|
|
|
runViaMapCompat(t, gf)
|
|
})
|
|
}
|
|
}
|
|
|
|
// taggedNodes are the nodes we create in the servertest. Names match
|
|
// the anonymized pokémon identifiers tscap writes into golden files —
|
|
// see github.com/kradalby/tscap/anonymize for the full substitution
|
|
// table (big-router→caterpie, exit-a→pidgey, etc).
|
|
var taggedNodes = []string{
|
|
"caterpie", // big-router
|
|
"pidgey", // exit-a
|
|
"pidgeotto", // exit-b
|
|
"charmander", // exit-node
|
|
"rattata", // group-a-client
|
|
"raticate", // group-b-client
|
|
"spearow", // router-a
|
|
"fearow", // router-b
|
|
"squirtle", // subnet-router
|
|
"weedle", // tagged-client
|
|
"beedrill", // tagged-server
|
|
"kakuna", // tagged-prod
|
|
"blastoise", // multi-exit-router
|
|
}
|
|
|
|
func runViaMapCompat(t *testing.T, gf goldenFile) {
|
|
t.Helper()
|
|
|
|
srv := servertest.NewServer(t)
|
|
tagUser := srv.CreateUser(t, "tag-user")
|
|
|
|
policyJSON := convertViaPolicy(gf.Input.FullPolicy)
|
|
|
|
changed, err := srv.State().SetPolicy(policyJSON)
|
|
require.NoError(t, err, "failed to set policy")
|
|
|
|
if changed {
|
|
changes, err := srv.State().ReloadPolicy()
|
|
require.NoError(t, err)
|
|
srv.App.Change(changes...)
|
|
}
|
|
|
|
// Create tagged clients matching the golden topology.
|
|
clients := map[string]*servertest.TestClient{}
|
|
|
|
for _, name := range taggedNodes {
|
|
topoNode, exists := gf.Topology.Nodes[name]
|
|
if !exists || len(topoNode.Tags) == 0 {
|
|
continue
|
|
}
|
|
|
|
if _, inCaptures := gf.Captures[name]; !inCaptures {
|
|
continue
|
|
}
|
|
|
|
clients[name] = servertest.NewClient(t, srv, name,
|
|
servertest.WithUser(tagUser),
|
|
servertest.WithTags(topoNode.Tags...),
|
|
)
|
|
}
|
|
|
|
require.NotEmpty(t, clients, "no relevant nodes created")
|
|
|
|
// Determine which routes each node should advertise. If the golden
|
|
// topology has explicit advertised_routes, use those. Otherwise infer
|
|
// from the policy's autoApprovers.routes: if a node's tags match an
|
|
// approver tag for a route prefix, the node should advertise it.
|
|
nodeRoutes := inferNodeRoutes(gf)
|
|
|
|
// Build approved routes from topology. The topology's approved_routes
|
|
// field records what SaaS actually approved (which may be a subset of
|
|
// routable_ips). Using this instead of approving all advertised routes
|
|
// ensures exit routes are only approved when SaaS approved them.
|
|
nodeApproved := map[string][]netip.Prefix{}
|
|
|
|
for name, node := range gf.Topology.Nodes {
|
|
for _, r := range node.ApprovedRoutes {
|
|
nodeApproved[name] = append(
|
|
nodeApproved[name], netip.MustParsePrefix(r),
|
|
)
|
|
}
|
|
}
|
|
|
|
// Advertise and approve routes FIRST. Via grants depend on routes
|
|
// being advertised for compileViaGrant to produce filter rules.
|
|
for name, c := range clients {
|
|
routes := nodeRoutes[name]
|
|
if len(routes) == 0 {
|
|
continue
|
|
}
|
|
|
|
c.Direct().SetHostinfo(&tailcfg.Hostinfo{
|
|
BackendLogID: "servertest-" + name,
|
|
Hostname: name,
|
|
RoutableIPs: routes,
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
_ = c.Direct().SendUpdate(ctx)
|
|
|
|
cancel()
|
|
|
|
nodeID := findNodeID(t, srv, name)
|
|
_, routeChange, err := srv.State().SetApprovedRoutes(
|
|
nodeID, nodeApproved[name],
|
|
)
|
|
require.NoError(t, err)
|
|
srv.App.Change(routeChange)
|
|
}
|
|
|
|
// Wait for peers based on golden netmap expected counts.
|
|
for viewerName, c := range clients {
|
|
capture := gf.Captures[viewerName]
|
|
if capture.Netmap == nil {
|
|
continue
|
|
}
|
|
|
|
expected := 0
|
|
|
|
for _, peer := range capture.Netmap.Peers {
|
|
peerName := extractHostname(peer.Name)
|
|
if _, isOurs := clients[peerName]; isOurs {
|
|
expected++
|
|
}
|
|
}
|
|
|
|
if expected > 0 {
|
|
c.WaitForPeers(t, expected, 30*time.Second)
|
|
}
|
|
}
|
|
|
|
// Ensure all nodes have received at least one MapResponse,
|
|
// including nodes with 0 expected peers that skipped WaitForPeers.
|
|
for name, c := range clients {
|
|
c.WaitForCondition(t, name+" initial netmap", 15*time.Second,
|
|
func(nm *netmap.NetworkMap) bool {
|
|
return nm != nil
|
|
})
|
|
}
|
|
|
|
// Compare each viewer's MapResponse against the golden netmap.
|
|
for viewerName, c := range clients {
|
|
capture := gf.Captures[viewerName]
|
|
if capture.Netmap == nil {
|
|
continue
|
|
}
|
|
|
|
t.Run(viewerName, func(t *testing.T) {
|
|
nm := c.Netmap()
|
|
require.NotNil(t, nm, "netmap is nil")
|
|
|
|
compareNetmap(t, nm, capture.Netmap, clients)
|
|
})
|
|
}
|
|
}
|
|
|
|
// compareNetmap compares the headscale MapResponse against the golden
|
|
// netmap data in an IP-independent way. It validates:
|
|
// - Peer visibility (which peers are present, by hostname)
|
|
// - Route prefixes in AllowedIPs (non-Tailscale-IP entries like 10.44.0.0/16)
|
|
// - Number of Tailscale IPs per peer (should be 2: one v4 + one v6)
|
|
// - PrimaryRoutes per peer
|
|
// - PacketFilter rule count
|
|
func compareNetmap(
|
|
t *testing.T,
|
|
got *netmap.NetworkMap,
|
|
want *goldenNetmap,
|
|
clients map[string]*servertest.TestClient,
|
|
) {
|
|
t.Helper()
|
|
|
|
// Build golden peer map (only peers in our client set).
|
|
wantPeers := map[string]goldenPeer{}
|
|
|
|
for _, p := range want.Peers {
|
|
name := extractHostname(p.Name)
|
|
if _, isOurs := clients[name]; isOurs {
|
|
wantPeers[name] = p
|
|
}
|
|
}
|
|
|
|
// Build headscale peer map.
|
|
gotPeers := map[string]peerSummary{}
|
|
|
|
for _, peer := range got.Peers {
|
|
name := ""
|
|
|
|
if peer.Hostinfo().Valid() {
|
|
name = peer.Hostinfo().Hostname()
|
|
}
|
|
|
|
if name == "" {
|
|
for n := range clients {
|
|
if strings.Contains(peer.Name(), n+".") {
|
|
name = n
|
|
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if name == "" {
|
|
continue
|
|
}
|
|
|
|
// Separate AllowedIPs into Tailscale IPs (node addresses)
|
|
// and route prefixes (subnets, exit routes).
|
|
var tsIPs []netip.Prefix
|
|
|
|
var routePrefixes []string
|
|
|
|
for i := range peer.AllowedIPs().Len() {
|
|
prefix := peer.AllowedIPs().At(i)
|
|
if isTailscaleIP(prefix) {
|
|
tsIPs = append(tsIPs, prefix)
|
|
} else {
|
|
routePrefixes = append(routePrefixes, prefix.String())
|
|
}
|
|
}
|
|
|
|
slices.Sort(routePrefixes)
|
|
|
|
var proutes []string
|
|
for i := range peer.PrimaryRoutes().Len() {
|
|
proutes = append(proutes, peer.PrimaryRoutes().At(i).String())
|
|
}
|
|
|
|
slices.Sort(proutes)
|
|
|
|
gotPeers[name] = peerSummary{
|
|
TailscaleIPs: tsIPs,
|
|
RoutePrefixes: routePrefixes,
|
|
PrimaryRoutes: proutes,
|
|
}
|
|
}
|
|
|
|
// Compare peer visibility: golden peers must be present.
|
|
for name, wantPeer := range wantPeers {
|
|
gotPeer, visible := gotPeers[name]
|
|
if !visible {
|
|
wantRoutes := extractRoutePrefixes(wantPeer.AllowedIPs)
|
|
t.Errorf("peer %s: visible in Tailscale SaaS (routes=%v), missing in headscale",
|
|
name, wantRoutes)
|
|
|
|
continue
|
|
}
|
|
|
|
// Compare route prefixes in AllowedIPs (IP-independent).
|
|
wantRoutes := extractRoutePrefixes(wantPeer.AllowedIPs)
|
|
slices.Sort(wantRoutes)
|
|
|
|
assert.Equalf(t, wantRoutes, gotPeer.RoutePrefixes,
|
|
"peer %s: route prefixes in AllowedIPs mismatch", name)
|
|
|
|
// Tailscale IPs: count should match, and they must belong to
|
|
// this peer (not some other node's IPs).
|
|
wantTSIPCount := countTailscaleIPs(wantPeer.AllowedIPs)
|
|
|
|
assert.Lenf(t, gotPeer.TailscaleIPs, wantTSIPCount,
|
|
"peer %s: Tailscale IP count mismatch", name)
|
|
|
|
// Verify the Tailscale IPs are actually this peer's addresses.
|
|
if peerClient, ok := clients[name]; ok {
|
|
peerNM := peerClient.Netmap()
|
|
if peerNM != nil && peerNM.SelfNode.Valid() {
|
|
peerAddrs := map[netip.Prefix]bool{}
|
|
|
|
addrs := peerNM.SelfNode.Addresses()
|
|
for i := range addrs.Len() {
|
|
peerAddrs[addrs.At(i)] = true
|
|
}
|
|
|
|
for _, tsIP := range gotPeer.TailscaleIPs {
|
|
assert.Truef(t, peerAddrs[tsIP],
|
|
"peer %s: AllowedIPs contains Tailscale IP %s which is NOT this peer's address (peer has %v)",
|
|
name, tsIP, peerAddrs)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compare PrimaryRoutes.
|
|
assert.ElementsMatchf(t, wantPeer.PrimaryRoutes, gotPeer.PrimaryRoutes,
|
|
"peer %s: PrimaryRoutes mismatch", name)
|
|
}
|
|
|
|
// Check for extra peers headscale shows that Tailscale SaaS doesn't.
|
|
for name := range gotPeers {
|
|
if _, expected := wantPeers[name]; !expected {
|
|
t.Errorf("peer %s: visible in headscale but NOT in Tailscale SaaS", name)
|
|
}
|
|
}
|
|
|
|
// Compare PacketFilter rules (IP-independent).
|
|
// Golden file SrcIPs contain literal Tailscale SaaS CGNAT addresses
|
|
// that differ from headscale-allocated IPs, so we compare structural
|
|
// properties rather than literal SrcIP values.
|
|
var wantFilterRules []tailcfg.FilterRule
|
|
if len(want.PacketFilterRules) > 0 &&
|
|
string(want.PacketFilterRules) != "null" {
|
|
_ = json.Unmarshal(want.PacketFilterRules, &wantFilterRules)
|
|
}
|
|
|
|
if !assert.Lenf(t, got.PacketFilter, len(wantFilterRules),
|
|
"PacketFilter rule count mismatch") {
|
|
return
|
|
}
|
|
|
|
// Compare destination prefixes per rule — subnet CIDRs like
|
|
// 10.44.0.0/16 are stable between Tailscale SaaS and headscale.
|
|
// Source IPs differ entirely (different CGNAT allocation), so
|
|
// we only validate that sources exist (non-empty), not their
|
|
// exact count or values.
|
|
for i := range wantFilterRules {
|
|
wantRule := wantFilterRules[i]
|
|
gotMatch := got.PacketFilter[i]
|
|
|
|
// Verify both rules have sources (don't compare exact
|
|
// count — IP allocation differs between SaaS and headscale).
|
|
if len(wantRule.SrcIPs) > 0 {
|
|
assert.NotEmptyf(t, gotMatch.Srcs,
|
|
"PacketFilter[%d]: golden has %d SrcIPs but headscale has none",
|
|
i, len(wantRule.SrcIPs))
|
|
}
|
|
|
|
// Destination prefixes: extract non-Tailscale-IP CIDRs
|
|
// from both golden and headscale rules and compare.
|
|
var wantDstPrefixes []string
|
|
|
|
for _, dp := range wantRule.DstPorts {
|
|
pfx, err := netip.ParsePrefix(dp.IP)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if !isTailscaleIP(pfx) {
|
|
wantDstPrefixes = append(wantDstPrefixes, pfx.String())
|
|
}
|
|
}
|
|
|
|
var gotDstPrefixes []string
|
|
|
|
for _, dst := range gotMatch.Dsts {
|
|
pfx := dst.Net
|
|
if !isTailscaleIP(pfx) {
|
|
gotDstPrefixes = append(gotDstPrefixes, pfx.String())
|
|
}
|
|
}
|
|
|
|
slices.Sort(wantDstPrefixes)
|
|
slices.Sort(gotDstPrefixes)
|
|
|
|
assert.Equalf(t, wantDstPrefixes, gotDstPrefixes,
|
|
"PacketFilter[%d]: non-Tailscale destination prefixes mismatch", i)
|
|
}
|
|
}
|
|
|
|
type peerSummary struct {
|
|
TailscaleIPs []netip.Prefix // Tailscale address entries from AllowedIPs
|
|
RoutePrefixes []string // non-Tailscale-IP AllowedIPs (sorted)
|
|
PrimaryRoutes []string // sorted
|
|
}
|
|
|
|
// isTailscaleIP returns true if the prefix is a single-host Tailscale
|
|
// address (/32 for IPv4 in CGNAT range, /128 for IPv6 in Tailscale ULA).
|
|
func isTailscaleIP(prefix netip.Prefix) bool {
|
|
addr := prefix.Addr()
|
|
|
|
if addr.Is4() && prefix.Bits() == 32 {
|
|
// CGNAT range 100.64.0.0/10
|
|
return addr.As4()[0] == 100 && (addr.As4()[1]&0xC0) == 64
|
|
}
|
|
|
|
if addr.Is6() && prefix.Bits() == 128 {
|
|
// Tailscale ULA fd7a:115c:a1e0::/48
|
|
b := addr.As16()
|
|
|
|
return b[0] == 0xfd && b[1] == 0x7a && b[2] == 0x11 && b[3] == 0x5c //nolint:gosec // As16 returns [16]byte, indexing [0..3] is safe
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// extractRoutePrefixes returns the non-Tailscale-IP entries from an
|
|
// AllowedIPs list (subnet routes, exit routes, etc.).
|
|
func extractRoutePrefixes(allowedIPs []string) []string {
|
|
var routes []string
|
|
|
|
for _, aip := range allowedIPs {
|
|
prefix, err := netip.ParsePrefix(aip)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if !isTailscaleIP(prefix) {
|
|
routes = append(routes, aip)
|
|
}
|
|
}
|
|
|
|
return routes
|
|
}
|
|
|
|
// countTailscaleIPs returns the number of Tailscale IP entries in an
|
|
// AllowedIPs list.
|
|
func countTailscaleIPs(allowedIPs []string) int {
|
|
count := 0
|
|
|
|
for _, aip := range allowedIPs {
|
|
prefix, err := netip.ParsePrefix(aip)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if isTailscaleIP(prefix) {
|
|
count++
|
|
}
|
|
}
|
|
|
|
return count
|
|
}
|
|
|
|
// inferNodeRoutes determines which routes each node should advertise.
|
|
// If the golden topology has explicit advertised_routes, those are used.
|
|
// Otherwise, routes are inferred from the golden netmap data: if a node
|
|
// appears as a peer with route prefixes in AllowedIPs, it should
|
|
// advertise those routes.
|
|
func inferNodeRoutes(gf goldenFile) map[string][]netip.Prefix {
|
|
result := map[string][]netip.Prefix{}
|
|
|
|
// First use explicit advertised_routes from topology.
|
|
for name, node := range gf.Topology.Nodes {
|
|
for _, r := range node.AdvertisedRoutes {
|
|
result[name] = append(result[name], netip.MustParsePrefix(r))
|
|
}
|
|
}
|
|
|
|
// If any node already has routes, the topology is populated — use as-is.
|
|
for _, routes := range result {
|
|
if len(routes) > 0 {
|
|
return result
|
|
}
|
|
}
|
|
|
|
// Tier 2: Infer from the golden netmap — scan all captures for
|
|
// peers with route prefixes in AllowedIPs. If node X appears as
|
|
// a peer with route prefix 10.44.0.0/16, then X should advertise
|
|
// that route.
|
|
for _, capture := range gf.Captures {
|
|
if capture.Netmap == nil {
|
|
continue
|
|
}
|
|
|
|
for _, peer := range capture.Netmap.Peers {
|
|
peerName := extractHostname(peer.Name)
|
|
routes := extractRoutePrefixes(peer.AllowedIPs)
|
|
|
|
for _, r := range routes {
|
|
prefix, err := netip.ParsePrefix(r)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if !slices.Contains(result[peerName], prefix) {
|
|
result[peerName] = append(result[peerName], prefix)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tier 3: Infer from packet_filter_rules DstPorts — if a node
|
|
// receives filter rules with non-Tailscale-IP destination
|
|
// prefixes, it must be advertising those routes. This catches
|
|
// secondary HA routers whose routes don't appear in AllowedIPs
|
|
// (only the primary gets the route in AllowedIPs) but DO receive
|
|
// filter rules for those routes.
|
|
for nodeName, capture := range gf.Captures {
|
|
if len(capture.PacketFilterRules) == 0 ||
|
|
string(capture.PacketFilterRules) == "null" {
|
|
continue
|
|
}
|
|
|
|
var rules []tailcfg.FilterRule
|
|
|
|
err := json.Unmarshal(capture.PacketFilterRules, &rules)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, rule := range rules {
|
|
for _, dp := range rule.DstPorts {
|
|
prefix, err := netip.ParsePrefix(dp.IP)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if isTailscaleIP(prefix) {
|
|
continue
|
|
}
|
|
|
|
if !slices.Contains(result[nodeName], prefix) {
|
|
result[nodeName] = append(
|
|
result[nodeName], prefix,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// extractHostname extracts the hostname from a Tailscale FQDN like
|
|
// "router-a.tail78f774.ts.net.".
|
|
func extractHostname(fqdn string) string {
|
|
if before, _, ok := strings.Cut(fqdn, "."); ok {
|
|
return before
|
|
}
|
|
|
|
return fqdn
|
|
}
|
|
|
|
// convertViaPolicy converts Tailscale SaaS policy emails to headscale format.
|
|
func convertViaPolicy(raw json.RawMessage) []byte {
|
|
s := string(raw)
|
|
// Anonymized SaaS emails as written by tscap/anonymize.
|
|
s = strings.ReplaceAll(s, "odin@example.com", "tag-user@")
|
|
s = strings.ReplaceAll(s, "thor@example.org", "tag-user@")
|
|
s = strings.ReplaceAll(s, "freya@example.com", "tag-user@")
|
|
|
|
return []byte(s)
|
|
}
|