mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-13 03:52:02 +09:00
c483bebba8
addNextDNSMetadata dereferenced node.Hostinfo().OS() without the .Valid() guard its siblings (RequestTags, TailNode) apply, so building the DNS config for a node with nil Hostinfo (a legacy NULL host_info row) panicked whenever a NextDNS resolver was configured. Guard the OS() dereference.
377 lines
10 KiB
Go
377 lines
10 KiB
Go
package mapper
|
|
|
|
import (
|
|
"fmt"
|
|
"net/netip"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/go-cmp/cmp/cmpopts"
|
|
"github.com/juanfont/headscale/hscontrol/db"
|
|
"github.com/juanfont/headscale/hscontrol/state"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/juanfont/headscale/hscontrol/types/change"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/dnstype"
|
|
)
|
|
|
|
var iap = func(ipStr string) *netip.Addr {
|
|
ip := netip.MustParseAddr(ipStr)
|
|
return &ip
|
|
}
|
|
|
|
func TestDNSConfigMapResponse(t *testing.T) {
|
|
tests := []struct {
|
|
magicDNS bool
|
|
want *tailcfg.DNSConfig
|
|
}{
|
|
{
|
|
magicDNS: true,
|
|
want: &tailcfg.DNSConfig{
|
|
Routes: map[string][]*dnstype.Resolver{},
|
|
Domains: []string{
|
|
"foobar.headscale.net",
|
|
},
|
|
Proxied: true,
|
|
},
|
|
},
|
|
{
|
|
magicDNS: false,
|
|
want: &tailcfg.DNSConfig{
|
|
Domains: []string{"foobar.headscale.net"},
|
|
Proxied: false,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(fmt.Sprintf("with-magicdns-%v", tt.magicDNS), func(t *testing.T) {
|
|
mach := func(hostname, username string, userid uint) *types.Node {
|
|
return &types.Node{
|
|
Hostname: hostname,
|
|
UserID: new(userid),
|
|
User: &types.User{
|
|
Name: username,
|
|
},
|
|
}
|
|
}
|
|
|
|
baseDomain := "foobar.headscale.net"
|
|
|
|
dnsConfigOrig := tailcfg.DNSConfig{
|
|
Routes: make(map[string][]*dnstype.Resolver),
|
|
Domains: []string{baseDomain},
|
|
Proxied: tt.magicDNS,
|
|
}
|
|
|
|
nodeInShared1 := mach("test_get_shared_nodes_1", "shared1", 1)
|
|
|
|
got := generateDNSConfig(
|
|
&types.Config{
|
|
TailcfgDNSConfig: &dnsConfigOrig,
|
|
},
|
|
nodeInShared1.View(),
|
|
nil,
|
|
)
|
|
|
|
if diff := cmp.Diff(tt.want, got, cmpopts.EquateEmpty()); diff != "" {
|
|
t.Errorf("expandAlias() unexpected result (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNextDNSCapMapRendering(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
mkConfig := func(addrs ...string) *types.Config {
|
|
resolvers := make([]*dnstype.Resolver, len(addrs))
|
|
for i, a := range addrs {
|
|
resolvers[i] = &dnstype.Resolver{Addr: a}
|
|
}
|
|
|
|
return &types.Config{
|
|
TailcfgDNSConfig: &tailcfg.DNSConfig{
|
|
Resolvers: resolvers,
|
|
},
|
|
}
|
|
}
|
|
|
|
mkNode := func() types.NodeView {
|
|
return (&types.Node{
|
|
ID: 1,
|
|
Hostname: "node1",
|
|
IPv4: iap("100.64.0.1"),
|
|
Hostinfo: &tailcfg.Hostinfo{OS: "linux"},
|
|
}).View()
|
|
}
|
|
|
|
// resolverAddr extracts the first resolver's address with a
|
|
// bounds check. Without it, a regression that drops the
|
|
// resolver list would nil-panic instead of failing cleanly.
|
|
resolverAddr := func(t *testing.T, got *tailcfg.DNSConfig) string {
|
|
t.Helper()
|
|
|
|
if got == nil {
|
|
t.Fatalf("generateDNSConfig returned nil")
|
|
}
|
|
|
|
if len(got.Resolvers) == 0 {
|
|
t.Fatalf("generateDNSConfig returned no Resolvers")
|
|
}
|
|
|
|
return got.Resolvers[0].Addr
|
|
}
|
|
|
|
t.Run("no_capmap_metadata_appended", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := generateDNSConfig(
|
|
mkConfig("https://dns.nextdns.io/abc"),
|
|
mkNode(),
|
|
nil,
|
|
)
|
|
|
|
want := "https://dns.nextdns.io/abc?device_ip=100.64.0.1&device_model=linux&device_name=node1"
|
|
if addr := resolverAddr(t, got); addr != want {
|
|
t.Errorf("addr = %q, want %q", addr, want)
|
|
}
|
|
})
|
|
|
|
t.Run("profile_overrides_global", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
capMap := tailcfg.NodeCapMap{
|
|
"nextdns:override": []tailcfg.RawMessage{},
|
|
}
|
|
|
|
got := generateDNSConfig(
|
|
mkConfig("https://dns.nextdns.io/global"),
|
|
mkNode(),
|
|
capMap,
|
|
)
|
|
|
|
want := "https://dns.nextdns.io/override?device_ip=100.64.0.1&device_model=linux&device_name=node1"
|
|
if addr := resolverAddr(t, got); addr != want {
|
|
t.Errorf("addr = %q, want %q", addr, want)
|
|
}
|
|
})
|
|
|
|
t.Run("no_device_info_skips_metadata", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
capMap := tailcfg.NodeCapMap{
|
|
"nextdns:abc": []tailcfg.RawMessage{},
|
|
"nextdns:no-device-info": []tailcfg.RawMessage{},
|
|
}
|
|
|
|
got := generateDNSConfig(
|
|
mkConfig("https://dns.nextdns.io/global"),
|
|
mkNode(),
|
|
capMap,
|
|
)
|
|
|
|
want := "https://dns.nextdns.io/abc"
|
|
if addr := resolverAddr(t, got); addr != want {
|
|
t.Errorf("addr = %q, want %q", addr, want)
|
|
}
|
|
})
|
|
|
|
t.Run("non_nextdns_resolver_untouched", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
capMap := tailcfg.NodeCapMap{
|
|
"nextdns:abc": []tailcfg.RawMessage{},
|
|
}
|
|
|
|
got := generateDNSConfig(
|
|
mkConfig("https://dns.example.org/dns-query"),
|
|
mkNode(),
|
|
capMap,
|
|
)
|
|
|
|
want := "https://dns.example.org/dns-query"
|
|
if addr := resolverAddr(t, got); addr != want {
|
|
t.Errorf("non-nextdns resolver was rewritten: %q", addr)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestBuildFromChangeFiltersPeerPatchesByVisibility proves that incremental
|
|
// peer-change patches (online/offline, endpoint, key-expiry) are restricted to
|
|
// the recipient's ACL-visible peer set, the same way buildTailPeers filters
|
|
// full peer objects via policy.ReduceNodes. Without it, a node receives the
|
|
// existence, presence, and addresses of peers its policy forbids accessing.
|
|
func TestBuildFromChangeFiltersPeerPatchesByVisibility(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
|
|
p4 := netip.MustParsePrefix("100.64.0.0/10")
|
|
p6 := netip.MustParsePrefix("fd7a:115c:a1e0::/48")
|
|
|
|
cfg := &types.Config{
|
|
Database: types.DatabaseConfig{
|
|
Type: types.DatabaseSqlite,
|
|
Sqlite: types.SqliteConfig{Path: tmp + "/h.db"},
|
|
},
|
|
PrefixV4: &p4,
|
|
PrefixV6: &p6,
|
|
IPAllocation: types.IPAllocationStrategySequential,
|
|
BaseDomain: "headscale.test",
|
|
Policy: types.PolicyConfig{Mode: types.PolicyModeDB},
|
|
DERP: types.DERPConfig{
|
|
DERPMap: &tailcfg.DERPMap{
|
|
Regions: map[int]*tailcfg.DERPRegion{999: {RegionID: 999}},
|
|
},
|
|
},
|
|
Tuning: types.Tuning{
|
|
NodeStoreBatchSize: state.TestBatchSize,
|
|
NodeStoreBatchTimeout: state.TestBatchTimeout,
|
|
},
|
|
}
|
|
|
|
database, err := db.NewHeadscaleDatabase(cfg)
|
|
require.NoError(t, err)
|
|
|
|
user1 := database.CreateUserForTest("u1")
|
|
user2 := database.CreateUserForTest("u2")
|
|
n1 := database.CreateRegisteredNodeForTest(user1, "n1")
|
|
n1b := database.CreateRegisteredNodeForTest(user1, "n1b")
|
|
n2 := database.CreateRegisteredNodeForTest(user2, "n2")
|
|
require.NoError(t, database.Close())
|
|
|
|
s, err := state.NewState(cfg)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
|
|
// Each user may reach only its own devices, so n1 cannot access n2.
|
|
policy := `{"acls":[
|
|
{"action":"accept","src":["u1@"],"dst":["u1@:*"]},
|
|
{"action":"accept","src":["u2@"],"dst":["u2@:*"]}
|
|
]}`
|
|
_, err = s.SetPolicy([]byte(policy))
|
|
require.NoError(t, err)
|
|
|
|
m := &mapper{state: s, cfg: cfg}
|
|
|
|
// n2 (user2) comes online; n1 (user1) must NOT receive its patch.
|
|
leakChange := change.NodeOnline(n2.ID)
|
|
resp, err := m.buildFromChange(n1.ID, tailcfg.CurrentCapabilityVersion, &leakChange)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp)
|
|
|
|
for _, p := range resp.PeersChangedPatch {
|
|
assert.NotEqual(t, n2.ID.NodeID(), p.NodeID,
|
|
"n1 must not receive an online patch for n2, which its policy forbids accessing")
|
|
}
|
|
|
|
// Control: n1b (same user) coming online IS visible to n1.
|
|
okChange := change.NodeOnline(n1b.ID)
|
|
resp2, err := m.buildFromChange(n1.ID, tailcfg.CurrentCapabilityVersion, &okChange)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp2)
|
|
|
|
var gotVisible bool
|
|
for _, p := range resp2.PeersChangedPatch {
|
|
if p.NodeID == n1b.ID.NodeID() {
|
|
gotVisible = true
|
|
}
|
|
}
|
|
assert.True(t, gotVisible,
|
|
"n1 must receive the online patch for visible same-user peer n1b")
|
|
}
|
|
|
|
// TestBuildFromChangeFiltersUserProfilesByVisibility proves the incremental
|
|
// PeersChanged path restricts UserProfiles to the recipient's ACL-visible
|
|
// peers, like the full-map path (whose ListPeers returns the
|
|
// BuildPeerMap-filtered set). Without it, a changed node broadcast to all
|
|
// nodes leaks its owner's identity (login name, display name, avatar) to
|
|
// recipients whose policy forbids accessing that node.
|
|
func TestBuildFromChangeFiltersUserProfilesByVisibility(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
|
|
p4 := netip.MustParsePrefix("100.64.0.0/10")
|
|
p6 := netip.MustParsePrefix("fd7a:115c:a1e0::/48")
|
|
|
|
cfg := &types.Config{
|
|
Database: types.DatabaseConfig{
|
|
Type: types.DatabaseSqlite,
|
|
Sqlite: types.SqliteConfig{Path: tmp + "/h.db"},
|
|
},
|
|
PrefixV4: &p4,
|
|
PrefixV6: &p6,
|
|
IPAllocation: types.IPAllocationStrategySequential,
|
|
BaseDomain: "headscale.test",
|
|
Policy: types.PolicyConfig{Mode: types.PolicyModeDB},
|
|
DERP: types.DERPConfig{
|
|
DERPMap: &tailcfg.DERPMap{
|
|
Regions: map[int]*tailcfg.DERPRegion{999: {RegionID: 999}},
|
|
},
|
|
},
|
|
Tuning: types.Tuning{
|
|
NodeStoreBatchSize: state.TestBatchSize,
|
|
NodeStoreBatchTimeout: state.TestBatchTimeout,
|
|
},
|
|
}
|
|
|
|
database, err := db.NewHeadscaleDatabase(cfg)
|
|
require.NoError(t, err)
|
|
|
|
user1 := database.CreateUserForTest("u1")
|
|
user2 := database.CreateUserForTest("u2")
|
|
n1 := database.CreateRegisteredNodeForTest(user1, "n1")
|
|
n2 := database.CreateRegisteredNodeForTest(user2, "n2")
|
|
require.NoError(t, database.Close())
|
|
|
|
s, err := state.NewState(cfg)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
|
|
// Each user may reach only its own devices, so n1 cannot access n2.
|
|
policy := `{"acls":[
|
|
{"action":"accept","src":["u1@"],"dst":["u1@:*"]},
|
|
{"action":"accept","src":["u2@"],"dst":["u2@:*"]}
|
|
]}`
|
|
_, err = s.SetPolicy([]byte(policy))
|
|
require.NoError(t, err)
|
|
|
|
m := &mapper{state: s, cfg: cfg}
|
|
|
|
// n2 (user2) is added and broadcast. n1 (user1) cannot access it, so n1
|
|
// must NOT receive user2's profile.
|
|
c := change.NodeAdded(n2.ID)
|
|
resp, err := m.buildFromChange(n1.ID, tailcfg.CurrentCapabilityVersion, &c)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp)
|
|
|
|
for _, up := range resp.UserProfiles {
|
|
assert.NotEqual(t, tailcfg.UserID(user2.ID), up.ID,
|
|
"n1 must not receive user2's profile; n2 is not ACL-visible to n1")
|
|
}
|
|
}
|
|
|
|
// TestGenerateDNSConfigNilHostinfoNoPanic proves generateDNSConfig does not
|
|
// panic when a node's Hostinfo is nil (e.g. a legacy DB row with a NULL
|
|
// host_info column). addNextDNSMetadata dereferenced node.Hostinfo().OS()
|
|
// without the .Valid() guard its siblings (RequestTags, TailNode) apply, so
|
|
// building such a node's map crashed the server whenever a NextDNS resolver
|
|
// was configured.
|
|
func TestGenerateDNSConfigNilHostinfoNoPanic(t *testing.T) {
|
|
node := (&types.Node{
|
|
Hostname: "legacy-node",
|
|
IPv4: iap("100.64.0.1"),
|
|
// Hostinfo intentionally nil, as a legacy NULL host_info row loads.
|
|
}).View()
|
|
|
|
cfg := &types.Config{
|
|
TailcfgDNSConfig: &tailcfg.DNSConfig{
|
|
Resolvers: []*dnstype.Resolver{{Addr: "https://dns.nextdns.io/abc"}},
|
|
},
|
|
}
|
|
|
|
require.NotPanics(t, func() {
|
|
generateDNSConfig(cfg, node, nil)
|
|
}, "generateDNSConfig must not panic when a node has nil Hostinfo")
|
|
}
|