mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-04 04:34:07 +09:00
mapper: give a tagged node its own user in its own entry
As SaaS. Clients take a peer with their own user ID for a device of the same owner, so every tagged peer was a file target and could read debug endpoints. Updates #2462, #2970
This commit is contained in:
1 parent
490305417c
commit
93aef86908
3 files changed
+63
-27
No files matched your search
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
|
||||
@@ -102,6 +103,10 @@ func (b *MapResponseBuilder) WithSelfNode() *MapResponseBuilder {
|
||||
return b
|
||||
}
|
||||
|
||||
if nv.IsTagged() {
|
||||
tailnode.User = taggedSelfUserID(nv.ID())
|
||||
}
|
||||
|
||||
// PrimaryRoutes is what the router serves: the routes it is primary
|
||||
// for, under the rule its peers get them by.
|
||||
tailnode.PrimaryRoutes = slices.DeleteFunc(
|
||||
@@ -204,9 +209,31 @@ func (b *MapResponseBuilder) WithUserProfiles(peers views.Slice[types.NodeView])
|
||||
|
||||
b.resp.UserProfiles = generateUserProfiles(node, peers)
|
||||
|
||||
if node.IsTagged() {
|
||||
fqdn, err := node.GetFQDN(b.mapper.cfg.BaseDomain)
|
||||
if err != nil {
|
||||
b.addError(err)
|
||||
return b
|
||||
}
|
||||
|
||||
b.resp.UserProfiles = append(b.resp.UserProfiles, tailcfg.UserProfile{
|
||||
ID: taggedSelfUserID(node.ID()),
|
||||
LoginName: strings.TrimSuffix(fqdn, "."),
|
||||
DisplayName: node.GivenName(),
|
||||
})
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// taggedSelfUserID is the user a tagged node sees itself as: its own, as on
|
||||
// SaaS, past every real user ID. Clients take a peer with their own user ID for
|
||||
// a device of the same owner, so with the shared tagged-devices ID every tagged
|
||||
// peer looked like one: a file target, and a reader of its debug endpoints.
|
||||
func taggedSelfUserID(id types.NodeID) tailcfg.UserID {
|
||||
return tailcfg.UserID(types.TaggedDevicesUserID) + 1 + tailcfg.UserID(id) //nolint:gosec // node IDs fit in int64
|
||||
}
|
||||
|
||||
// WithPacketFilters adds packet filter rules based on policy.
|
||||
//
|
||||
// [State.FilterForNode] returns rules already reduced to only those relevant for this node.
|
||||
|
||||
@@ -141,9 +141,7 @@ var aclMapCompatTests = []string{
|
||||
// viaCompatSkipReasons lists checks where headscale differs from SaaS, keyed
|
||||
// <test_id>/<node>/<check>, or by check alone where every capture differs the
|
||||
// same way. A listed check that matches fails, so a fix must delete its entry.
|
||||
var viaCompatSkipReasons = map[string]string{
|
||||
"tagged-self": "TAGGED_SELF_PROFILE: SaaS gives a tagged node a profile of its own, headscale the shared tagged-devices one",
|
||||
}
|
||||
var viaCompatSkipReasons = map[string]string{}
|
||||
|
||||
// TestRoutesMapCompat replays routesMapCompatTests through
|
||||
// [runViaMapCompat].
|
||||
@@ -953,22 +951,31 @@ func compareUserProfiles(
|
||||
check := "peer"
|
||||
if name == "" {
|
||||
check = "self"
|
||||
if want.SelfNode.Tags().Len() > 0 {
|
||||
check = "tagged-self"
|
||||
}
|
||||
}
|
||||
|
||||
if reason, listed := viaCompatSkipReasons[check]; listed {
|
||||
if gotProfiles[name] == wantProfile {
|
||||
t.Errorf("node %q: %s profile matches SaaS; delete viaCompatSkipReasons[%q] (%s)",
|
||||
name, check, check, reason)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
assert.Equalf(t, wantProfile, gotProfiles[name], "node %q: %s profile mismatch", name, check)
|
||||
}
|
||||
|
||||
// Clients take a peer with their own user ID for a device of the same owner.
|
||||
sameOwner := func(nm *netmap.NetworkMap) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
|
||||
for _, peer := range nm.Peers {
|
||||
name := extractHostname(peer.Name())
|
||||
if _, isOurs := clients[name]; isOurs {
|
||||
out[name] = peer.User() == nm.SelfNode.User()
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
gotSame := sameOwner(got)
|
||||
for name, wantSame := range sameOwner(want) {
|
||||
if same, ok := gotSame[name]; ok {
|
||||
assert.Equalf(t, wantSame, same, "peer %q: same owner as the viewer", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nodeProfiles maps "" (self) and each peer in clients to its user profile.
|
||||
@@ -987,6 +994,9 @@ func nodeProfiles(
|
||||
login = name
|
||||
}
|
||||
|
||||
// A tagged node's login is its name, under each side's own domain.
|
||||
login, _, _ = strings.Cut(login, ".")
|
||||
|
||||
return login + " / " + p.DisplayName()
|
||||
}
|
||||
|
||||
|
||||
@@ -694,9 +694,10 @@ func TestPreAuthKeyCorrectUserLoggedInCommand(t *testing.T) {
|
||||
status, err := client.Status()
|
||||
assert.NoError(ct, err)
|
||||
assert.Equal(ct, "Running", status.BackendState, "Expected node to be logged in, backend state: %s", status.BackendState)
|
||||
// With tags-as-identity model, tagged nodes show as [types.TaggedDevices] user (2147455555)
|
||||
// The PreAuthKey was created with tags, so the node is tagged
|
||||
assert.Equal(ct, "userid:2147455555", status.Self.UserID.String(), "Expected node to be logged in as tagged-devices user")
|
||||
// The PreAuthKey was created with tags, so the node is tagged, and a
|
||||
// tagged node sees itself as a user of its own, named after it, as on SaaS.
|
||||
assert.Equal(ct, strings.TrimSuffix(status.Self.DNSName, "."), status.User[status.Self.UserID].LoginName,
|
||||
"Expected the tagged node to be a user of its own")
|
||||
}, integrationutil.StatusReadyTimeout, 2*time.Second)
|
||||
|
||||
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
||||
@@ -810,8 +811,9 @@ func TestTaggedNodesCLIOutput(t *testing.T) {
|
||||
status, err := client.Status()
|
||||
assert.NoError(ct, err)
|
||||
assert.Equal(ct, "Running", status.BackendState, "Expected node to be logged in, backend state: %s", status.BackendState)
|
||||
// With tags-as-identity model, tagged nodes show as [types.TaggedDevices] user (2147455555)
|
||||
assert.Equal(ct, "userid:2147455555", status.Self.UserID.String(), "Expected node to be logged in as tagged-devices user")
|
||||
// A tagged node sees itself as a user of its own, named after it, as on SaaS.
|
||||
assert.Equal(ct, strings.TrimSuffix(status.Self.DNSName, "."), status.User[status.Self.UserID].LoginName,
|
||||
"Expected the tagged node to be a user of its own")
|
||||
}, integrationutil.StatusReadyTimeout, 2*time.Second)
|
||||
|
||||
// A tagged key converts the machine's user-owned node in place.
|
||||
@@ -826,19 +828,16 @@ func TestTaggedNodesCLIOutput(t *testing.T) {
|
||||
}
|
||||
}, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second)
|
||||
|
||||
// Test: tailscale status output should show "tagged-devices" not "userid:2147455555"
|
||||
// This is the fix for issue #2970 - the Tailscale client should display user-friendly names
|
||||
// Test: tailscale status names a tagged node, not "userid:<n>". This is the
|
||||
// fix for issue #2970; the node is its own user, named after it, as on SaaS.
|
||||
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
|
||||
stdout, stderr, err := client.Execute([]string{"tailscale", "status"})
|
||||
assert.NoError(ct, err, "tailscale status command should succeed, stderr: %s", stderr)
|
||||
|
||||
t.Logf("Tailscale status output:\n%s", stdout)
|
||||
|
||||
// The output should contain "tagged-devices" for tagged nodes
|
||||
assert.Contains(ct, stdout, "tagged-devices", "Tailscale status should show 'tagged-devices' for tagged nodes")
|
||||
|
||||
// The output should NOT show the raw numeric userid to the user
|
||||
assert.NotContains(ct, stdout, "userid:2147455555", "Tailscale status should not show numeric userid for tagged nodes")
|
||||
// The output should NOT show a raw numeric userid to the user
|
||||
assert.NotContains(ct, stdout, "userid:", "Tailscale status should not show numeric userid for tagged nodes")
|
||||
}, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user