From 4da06925d01727c11bafa633ade6aaf0d35d01d1 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 15 Jun 2026 08:18:59 +0000 Subject: [PATCH 001/127] types/change: add NodeKeyRotated for relogin peer patch Sends a relogin as an incremental PeerChange, not a whole-node add. --- hscontrol/types/change/change.go | 29 +++++++++++++++++++++++ hscontrol/types/change/change_test.go | 33 +++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/hscontrol/types/change/change.go b/hscontrol/types/change/change.go index 77d0f083d..d15469d9f 100644 --- a/hscontrol/types/change/change.go +++ b/hscontrol/types/change/change.go @@ -490,6 +490,35 @@ func EndpointOrDERPUpdate(id types.NodeID, patch *tailcfg.PeerChange) Change { return c } +// NodeKeyRotated returns a [Change] for a node re-logging in: its NodeKey (and +// possibly DiscoKey, key expiry, or endpoints) changed, but nothing structural +// did. Peers only need those changed fields, so it is sent as the minimal +// incremental [tailcfg.PeerChange] patch rather than re-advertising the whole +// node — the smallest update that conveys the rotation, and the least +// disruptive for peers reconciling it. +func NodeKeyRotated(node types.NodeView) Change { + nk := node.NodeKey() + dk := node.DiscoKey() + + // KeyExpiry is always set: the zero value clears any prior expiry on the + // peer (un-expire), and a non-zero value carries the new expiry. + var expiry time.Time + if e, ok := node.Expiry().GetOk(); ok { + expiry = e + } + + c := PeerPatched("node key rotated (relogin)", &tailcfg.PeerChange{ + NodeID: tailcfg.NodeID(node.ID()), //nolint:gosec // NodeID is bounded + Key: &nk, + DiscoKey: &dk, + KeyExpiry: &expiry, + Endpoints: node.Endpoints().AsSlice(), + }) + c.OriginNode = node.ID() + + return c +} + // UserAdded returns a [Change] for when a user is added or updated. // A full update is sent to refresh user profiles on all nodes. func UserAdded() Change { diff --git a/hscontrol/types/change/change_test.go b/hscontrol/types/change/change_test.go index 14fe077a5..5d8578927 100644 --- a/hscontrol/types/change/change_test.go +++ b/hscontrol/types/change/change_test.go @@ -4,11 +4,13 @@ import ( "net/netip" "reflect" "testing" + "time" "github.com/juanfont/headscale/hscontrol/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "tailscale.com/tailcfg" + "tailscale.com/types/key" ) func TestChange_FieldSync(t *testing.T) { @@ -653,3 +655,34 @@ func TestNodeOnlineOfflineForSubnetRouter(t *testing.T) { }) } } + +// TestNodeKeyRotatedEmitsPatchNotWholeNode proves a relogin is delivered to +// peers as an incremental peer patch, not a whole-node add. A whole-node add is +// non-patchifiable on the tailscale client whenever Hostinfo changed (which it +// does on relogin), forcing the broken NodeMutationAdd path that strands a +// re-keyed, momentarily-endpoint-less peer. +func TestNodeKeyRotatedEmitsPatchNotWholeNode(t *testing.T) { + expiry := time.Now().Add(24 * time.Hour).UTC() + node := types.Node{ + ID: 7, + NodeKey: key.NewNode().Public(), + DiscoKey: key.NewDisco().Public(), + Endpoints: []netip.AddrPort{netip.MustParseAddrPort("192.168.1.9:41641")}, + Expiry: &expiry, + } + view := node.View() + + c := NodeKeyRotated(view) + + assert.False(t, c.IsFull(), "relogin must be a peer patch, not a full update") + assert.Empty(t, c.PeersChanged, "relogin must not emit a whole-node PeersChanged") + require.Len(t, c.PeerPatches, 1, "relogin must emit exactly one peer patch") + + patch := c.PeerPatches[0] + assert.Equal(t, view.ID().NodeID(), patch.NodeID) + require.NotNil(t, patch.Key, "patch must carry the rotated NodeKey") + assert.Equal(t, node.NodeKey, *patch.Key) + require.NotNil(t, patch.KeyExpiry, "patch must carry KeyExpiry to (un)expire the peer") + assert.Equal(t, expiry, *patch.KeyExpiry) + assert.Equal(t, []netip.AddrPort(node.Endpoints), patch.Endpoints, "patch must carry endpoints") +} From a5ef3aff15ab559e06f1553dcbe653a7b1e2d4f4 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 15 Jun 2026 08:18:59 +0000 Subject: [PATCH 002/127] state: patch relogins and gate endpoint broadcasts Relogin sent as a peer patch with endpoints preserved; endpoint-only deltas broadcast only on useful (non-STUN) changes. --- hscontrol/state/endpoint_test.go | 85 +++++++++++++++++++++++ hscontrol/state/persist_test.go | 90 ++++++++++++++++++++++++ hscontrol/state/state.go | 114 ++++++++++++++++++++++++++----- 3 files changed, 272 insertions(+), 17 deletions(-) diff --git a/hscontrol/state/endpoint_test.go b/hscontrol/state/endpoint_test.go index b8905ab7b..21b83e5b6 100644 --- a/hscontrol/state/endpoint_test.go +++ b/hscontrol/state/endpoint_test.go @@ -111,3 +111,88 @@ func TestEndpointStorageInNodeStore(t *testing.T) { } } } + +// TestEndpointBroadcastWorthy verifies the gate that decides whether an +// endpoint-only delta is worth fanning out to peers as an incremental +// PeersChangedPatch. A delta that only adds STUN-derived endpoints (or only +// removes endpoints) is suppressed: it is churny and unlikely to be useful, +// and disco's callMeMaybe re-derives STUN paths anyway. Only deltas that +// introduce a genuinely useful (non-STUN) endpoint are broadcast-worthy. +func TestEndpointBroadcastWorthy(t *testing.T) { + local := netip.MustParseAddrPort("192.168.1.5:41641") + local2 := netip.MustParseAddrPort("192.168.1.6:41641") + stun := netip.MustParseAddrPort("203.0.113.7:41641") + stun2 := netip.MustParseAddrPort("203.0.113.8:41641") + portmap := netip.MustParseAddrPort("198.51.100.9:41641") + + tests := []struct { + name string + stored []netip.AddrPort + newEPs []netip.AddrPort + newType []tailcfg.EndpointType + want bool + }{ + { + name: "adds only a STUN endpoint - suppress", + stored: []netip.AddrPort{local}, + newEPs: []netip.AddrPort{local, stun}, + newType: []tailcfg.EndpointType{tailcfg.EndpointLocal, tailcfg.EndpointSTUN}, + want: false, + }, + { + name: "adds only STUN4LocalPort - suppress", + stored: []netip.AddrPort{local}, + newEPs: []netip.AddrPort{local, stun}, + newType: []tailcfg.EndpointType{tailcfg.EndpointLocal, tailcfg.EndpointSTUN4LocalPort}, + want: false, + }, + { + name: "adds a useful local endpoint - broadcast", + stored: []netip.AddrPort{stun}, + newEPs: []netip.AddrPort{stun, local}, + newType: []tailcfg.EndpointType{tailcfg.EndpointSTUN, tailcfg.EndpointLocal}, + want: true, + }, + { + name: "adds a useful portmapped endpoint - broadcast", + stored: []netip.AddrPort{local}, + newEPs: []netip.AddrPort{local, portmap}, + newType: []tailcfg.EndpointType{tailcfg.EndpointLocal, tailcfg.EndpointPortmapped}, + want: true, + }, + { + name: "pure shrink, no additions - suppress", + stored: []netip.AddrPort{local, local2}, + newEPs: []netip.AddrPort{local}, + newType: []tailcfg.EndpointType{tailcfg.EndpointLocal}, + want: false, + }, + { + name: "nil types (older client) adding endpoint - broadcast", + stored: []netip.AddrPort{local}, + newEPs: []netip.AddrPort{local, local2}, + want: true, + }, + { + name: "only STUN endpoints churn (replace one STUN with another) - suppress", + stored: []netip.AddrPort{local, stun}, + newEPs: []netip.AddrPort{local, stun2}, + newType: []tailcfg.EndpointType{tailcfg.EndpointLocal, tailcfg.EndpointSTUN}, + want: false, + }, + { + name: "mixed add: one STUN and one useful - broadcast", + stored: []netip.AddrPort{local}, + newEPs: []netip.AddrPort{local, stun, local2}, + newType: []tailcfg.EndpointType{tailcfg.EndpointLocal, tailcfg.EndpointSTUN, tailcfg.EndpointLocal}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := endpointBroadcastWorthy(tt.stored, tt.newEPs, tt.newType) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/hscontrol/state/persist_test.go b/hscontrol/state/persist_test.go index 0adac6d58..ab35d9a4d 100644 --- a/hscontrol/state/persist_test.go +++ b/hscontrol/state/persist_test.go @@ -325,3 +325,93 @@ func TestReauthRejectsNodeKeyClaimedByAnotherMachine(t *testing.T) { require.Error(t, err, "re-auth claiming a NodeKey bound to another machine must be rejected") } + +// TestReauthPreservesEndpointsWhenClientOmitsThem proves the re-auth/update +// path keeps a node's live WireGuard endpoints when the originating +// RegisterRequest carried none. Web/OIDC relogins report endpoints via +// MapRequest, not register, so RegData.Endpoints is empty; wiping the stored +// endpoints would advertise the re-keyed node to peers endpoint-less, which +// drives head/unstable tailscale clients into one-way disco-deafness. +func TestReauthPreservesEndpointsWhenClientOmitsThem(t *testing.T) { + dbPath := t.TempDir() + "/headscale.db" + cfg := persistTestConfig(dbPath) + + database, err := db.NewHeadscaleDatabase(cfg) + require.NoError(t, err) + + user := database.CreateUserForTest("user") + require.NoError(t, database.Close()) + + s, err := NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + machine := key.NewMachine() + endpoints := []netip.AddrPort{ + netip.MustParseAddrPort("192.168.1.5:41641"), + netip.MustParseAddrPort("10.0.0.5:41641"), + } + + // Node is registered and has reported live endpoints (as after its first + // MapRequest). + node, err := s.createAndSaveNewNode(newNodeParams{ + User: *user, + MachineKey: machine.Public(), + NodeKey: key.NewNode().Public(), + DiscoKey: key.NewDisco().Public(), + Hostname: "node", + Endpoints: endpoints, + RegisterMethod: util.RegisterMethodCLI, + }) + require.NoError(t, err) + require.Equal(t, endpoints, node.Endpoints().AsSlice(), + "precondition: node has live endpoints") + + // Node re-authenticates, rotating its NodeKey. The RegisterRequest carries + // no endpoints. + updated, err := s.applyAuthNodeUpdate(authNodeUpdateParams{ + ExistingNode: node, + RegData: &types.RegistrationData{ + MachineKey: machine.Public(), + NodeKey: key.NewNode().Public(), + Hostname: "node", + Hostinfo: &tailcfg.Hostinfo{}, + Endpoints: nil, + }, + ValidHostinfo: &tailcfg.Hostinfo{}, + Hostname: "node", + User: user, + RegisterMethod: util.RegisterMethodCLI, + }) + require.NoError(t, err) + + assert.Equal(t, endpoints, updated.Endpoints().AsSlice(), + "re-auth without reported endpoints must preserve the node's live endpoints") +} + +// TestReauthChange covers the decision both re-auth paths share: a same-user +// relogin must be an incremental peer patch (so the tailscale client takes its +// fast patch path), never a whole-node add (which strands a re-keyed, +// momentarily-endpoint-less peer disco-deaf); a policy change forces a full +// recompute; a new node is a whole-node add. +func TestReauthChange(t *testing.T) { + n := types.Node{ + ID: 7, + NodeKey: key.NewNode().Public(), + DiscoKey: key.NewDisco().Public(), + } + node := n.View() + + relogin := reauthChange(node, true, false) + assert.Len(t, relogin.PeerPatches, 1, "relogin must be a peer patch") + assert.Empty(t, relogin.PeersChanged, "relogin must not be a whole-node add") + + added := reauthChange(node, false, false) + assert.Empty(t, added.PeerPatches) + assert.Len(t, added.PeersChanged, 1, "a new node must be a whole-node add") + + pol := reauthChange(node, true, true) + assert.Empty(t, pol.PeerPatches, "a policy change must not be a peer patch") + assert.Empty(t, pol.PeersChanged) + assert.False(t, pol.IsEmpty(), "a policy change must be non-empty") +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index f7cfd686e..d7ffc83d1 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -1615,7 +1615,14 @@ func (s *State) applyAuthNodeUpdate(params authNodeUpdateParams) (types.NodeView params.ValidHostinfo, ) - node.Endpoints = regData.Endpoints + // Preserve the node's live endpoints when the register request carried + // none. Web/OIDC relogins report endpoints via MapRequest, not register, + // so RegData.Endpoints is empty; clearing the stored set would advertise + // the re-keyed node with no way for peers to reach it. The first + // MapRequest restores the live set. + if len(regData.Endpoints) > 0 { + node.Endpoints = regData.Endpoints + } // Do NOT reset IsOnline here. Online status is managed exclusively by // [State.Connect]/[State.Disconnect] in the poll session lifecycle. // Resetting it during re-registration causes a false offline blip: the @@ -2115,14 +2122,12 @@ func (s *State) HandleNodeFromAuthPath( return finalNode, change.NodeAdded(finalNode.ID()), fmt.Errorf("updating policy manager nodes: %w", err) } - var c change.Change - if !usersChange.IsEmpty() || !nodesChange.IsEmpty() { - c = change.PolicyChange() - } else { - c = change.NodeAdded(finalNode.ID()) - } + policyChanged := !usersChange.IsEmpty() || !nodesChange.IsEmpty() - return finalNode, c, nil + // nodeExistsForSameUser is true only for a same-user relogin; a tag->user + // conversion is excluded, as it changes the peer's User — a structural + // change peers must see in full, not a key-rotation patch. + return finalNode, reauthChange(finalNode, nodeExistsForSameUser, policyChanged), nil } // createNewNodeFromAuth creates a new node during auth callback. @@ -2448,14 +2453,27 @@ func (s *State) HandleNodeFromPreAuthKey( return finalNode, change.NodeAdded(finalNode.ID()), fmt.Errorf("updating policy manager nodes: %w", err) } - var c change.Change - if !usersChange.IsEmpty() || !nodesChange.IsEmpty() { - c = change.PolicyChange() - } else { - c = change.NodeAdded(finalNode.ID()) - } + policyChanged := !usersChange.IsEmpty() || !nodesChange.IsEmpty() - return finalNode, c, nil + return finalNode, reauthChange(finalNode, existsSameUser, policyChanged), nil +} + +// reauthChange returns the [change.Change] to broadcast after an authentication +// that updated or created a node. +// +// A pure relogin (isRelogin: an existing node, same user, with only its NodeKey +// rotated) is sent as a minimal incremental peer patch via [change.NodeKeyRotated] +// rather than re-advertising the whole node. A policy change forces a full +// recompute; any other (new) node is a whole-node add. +func reauthChange(node types.NodeView, isRelogin, policyChanged bool) change.Change { + switch { + case policyChanged: + return change.PolicyChange() + case isRelogin: + return change.NodeKeyRotated(node) + default: + return change.NodeAdded(node.ID()) + } } // updatePolicyManagerUsers updates the policy manager with current users. @@ -2656,8 +2674,13 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest updatedNode, ok := s.nodeStore.UpdateNode(id, func(currentNode *types.Node) { peerChange := currentNode.PeerChangeFromMapRequest(req) - // Track what specifically changed - endpointChanged = peerChange.Endpoints != nil + // Track what specifically changed. An endpoint delta is only + // broadcast-worthy when it adds a useful (non-STUN) endpoint; + // STUN-only churn and pure shrinks are suppressed to reduce peer + // churn (see endpointBroadcastWorthy). The new set is still stored + // via ApplyPeerChange below regardless of this decision. + endpointChanged = peerChange.Endpoints != nil && + endpointBroadcastWorthy(currentNode.Endpoints, req.Endpoints, req.EndpointTypes) derpChanged = peerChange.DERPRegion != 0 hostinfoChanged = !hostinfoEqual(currentNode.View(), req.Hostinfo) @@ -2841,6 +2864,63 @@ func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest return buildMapRequestChangeResponse(id, updatedNode, hostinfoChanged, endpointChanged, derpChanged) } +// endpointBroadcastWorthy reports whether an endpoint-only delta is worth +// fanning out to peers as an incremental PeersChangedPatch. A delta that only +// adds STUN-derived endpoints — or only removes endpoints — is suppressed: +// bare STUN endpoints are unlikely to be open and churn a lot (the client +// re-derives those paths over disco anyway), and a pure shrink is not worth +// telling peers about. Suppressing this churn keeps peers' views stable. +// +// The decision is intentionally conservative: it gates the broadcast only, +// not storage. The node's full endpoint set (STUN included) is still stored +// and rides along the next substantive change or full MapResponse, so no +// reachable path is permanently hidden from peers. +// +// Limitation: headscale stores bare []netip.AddrPort with no per-endpoint +// type, so we can only classify the *new* request's endpoints (via the +// parallel newTypes slice). We therefore gate on whether any newly-added +// endpoint (present in new, absent from stored) is useful (non-STUN). When +// newTypes is absent or shorter than newEPs (older clients), the unknown +// endpoints are treated as useful, preserving the pre-existing always-broadcast +// behaviour and never hiding a genuinely new endpoint. +func endpointBroadcastWorthy( + stored, newEPs []netip.AddrPort, + newTypes []tailcfg.EndpointType, +) bool { + storedSet := make(map[netip.AddrPort]struct{}, len(stored)) + for _, ep := range stored { + storedSet[ep] = struct{}{} + } + + for i, ep := range newEPs { + if _, ok := storedSet[ep]; ok { + // Already known to peers; not a newly-added endpoint. + continue + } + + // A newly-added endpoint with no type information (older client) + // is treated as useful so we never hide a genuinely new endpoint. + t := tailcfg.EndpointUnknownType + if i < len(newTypes) { + t = newTypes[i] + } + + if isUsefulEndpointType(t) { + return true + } + } + + return false +} + +// isUsefulEndpointType reports whether an endpoint type is worth eagerly +// broadcasting to peers. STUN-derived endpoints are excluded because they are +// churny and unlikely to be directly reachable; magicsock's disco handles +// establishing those paths. +func isUsefulEndpointType(t tailcfg.EndpointType) bool { + return t != tailcfg.EndpointSTUN && t != tailcfg.EndpointSTUN4LocalPort +} + // buildMapRequestChangeResponse determines the appropriate response type for a [tailcfg.MapRequest] update. // Hostinfo changes require a full update, while endpoint/DERP changes can use lightweight patches. func buildMapRequestChangeResponse( From 0961e79e16746d0e60dc447a9929070cad82c986 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 15 Jun 2026 10:52:37 +0000 Subject: [PATCH 003/127] state: re-register converted tagged nodes with reused key A node converted to tagged is re-indexed under no user, so re-registration keyed on the key's owner missed it and rejected the spent one-shot key. Match the existing tagged node by machine key. Fixes #3312 --- hscontrol/state/auth_tagged_expiry_test.go | 66 ++++++++++++++++++++++ hscontrol/state/state.go | 10 ++++ 2 files changed, 76 insertions(+) diff --git a/hscontrol/state/auth_tagged_expiry_test.go b/hscontrol/state/auth_tagged_expiry_test.go index 282a0366f..988c92457 100644 --- a/hscontrol/state/auth_tagged_expiry_test.go +++ b/hscontrol/state/auth_tagged_expiry_test.go @@ -10,6 +10,7 @@ import ( "github.com/juanfont/headscale/hscontrol/util" "github.com/stretchr/testify/require" "tailscale.com/tailcfg" + "tailscale.com/types/key" ) // TestTaggedReauthKeepsNilExpiry ensures that when an existing tagged node @@ -92,3 +93,68 @@ func TestTaggedReauthKeepsNilExpiry(t *testing.T) { require.Nil(t, finalNode.AsStruct().Expiry, "tagged node must keep nil key expiry (tagged nodes never expire)") } + +// TestTaggedReauthWithReusedUserPAK reproduces issue #3312: a containerized +// node registered with a user-owned one-shot pre-auth key, then converted to a +// tagged node (UserID cleared to NULL), is logged out when the container +// restarts and re-registers with the SAME, now-used TS_AUTHKEY. +// +// Root cause: findExistingNodeForPAK (state.go) looks the node up by the PAK's +// owning user (alice). After tagging, the node is indexed under UserID(0), so +// the same-user machine-key lookup misses, the re-registration fast-path is +// skipped, and the already-used one-shot PAK is re-validated and rejected with +// "authkey already used" — logging the node out. +// +// https://github.com/juanfont/headscale/issues/3312 +func TestTaggedReauthWithReusedUserPAK(t *testing.T) { + dbPath := t.TempDir() + "/headscale.db" + cfg := persistTestConfig(dbPath) + + s, err := NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + user := s.CreateUserForTest("authkey-user") + + policy := fmt.Sprintf(`{"tagOwners":{"tag:foo":["%s@"]}}`, user.Name) + _, err = s.SetPolicy([]byte(policy)) + require.NoError(t, err) + + // One-shot, user-owned PAK: `headscale preauthkeys create -u 1`. + pak, err := s.CreatePreAuthKey(user.TypedID(), false, false, nil, nil) + require.NoError(t, err) + + machineKey := key.NewMachine() + nodeKey := key.NewNode() + + regReq := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key}, + NodeKey: nodeKey.Public(), + Hostinfo: &tailcfg.Hostinfo{Hostname: "authkey-node"}, + Expiry: time.Now().Add(24 * time.Hour), + } + + // First registration: node joins as alice, the one-shot PAK is consumed. + first, _, err := s.HandleNodeFromPreAuthKey(regReq, machineKey.Public()) + require.NoError(t, err) + require.True(t, first.Valid()) + nodeID := first.ID() + + // `headscale nodes tag -t tag:foo`: convert to a tagged node. This clears + // both UserID and User (state.SetNodeTags), diverging the node's ownership + // from the still-user-owned PAK. + tagged, _, err := s.SetNodeTags(nodeID, []string{"tag:foo"}) + require.NoError(t, err) + require.True(t, tagged.IsTagged(), "precondition: node must be tagged") + + // Container restart: the same node re-registers with the SAME, now-used + // one-shot TS_AUTHKEY. The machine key proves identity, so this must + // succeed. It currently fails with "authkey already used". + second, _, err := s.HandleNodeFromPreAuthKey(regReq, machineKey.Public()) + require.NoError(t, err, + "re-registration with a reused user PAK on a tagged node must not be rejected") + require.True(t, second.Valid()) + require.True(t, second.IsTagged(), "node must remain tagged after re-registration") + require.Equal(t, nodeID, second.ID(), + "must update the existing node, not create a new one") +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index d7ffc83d1..ce4d896de 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -2175,6 +2175,16 @@ func (s *State) findExistingNodeForPAK( if exists { return node, true } + + // The node may have been converted to a tagged node since it first + // registered (SetNodeTags clears UserID, re-indexing it under UserID(0)). + // It is still the same machine, proven by the machine key, so recognise + // it for re-registration instead of re-validating the spent key or + // creating a duplicate node. Re-registration preserves the node's tagged + // ownership. See https://github.com/juanfont/headscale/issues/3312. + if node, exists := s.nodeStore.GetNodeByMachineKey(machineKey, 0); exists && node.IsTagged() { + return node, true + } } // Tagged nodes have nil UserID, so they are indexed under UserID(0) From e759d9fc9090ea97652348c084e39f4632dd4068 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 15 Jun 2026 10:55:45 +0000 Subject: [PATCH 004/127] auth: re-validate key when an expired node re-registers The re-registration fast-path skipped validation for a matching node key without checking expiry, so an expired node could re-auth with a spent key. Gate it on the node not being expired. Updates #3312 --- hscontrol/state/auth_tagged_expiry_test.go | 75 ++++++++++++++++++++++ hscontrol/state/state.go | 16 +++-- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/hscontrol/state/auth_tagged_expiry_test.go b/hscontrol/state/auth_tagged_expiry_test.go index 988c92457..cc1baa5e7 100644 --- a/hscontrol/state/auth_tagged_expiry_test.go +++ b/hscontrol/state/auth_tagged_expiry_test.go @@ -158,3 +158,78 @@ func TestTaggedReauthWithReusedUserPAK(t *testing.T) { require.Equal(t, nodeID, second.ID(), "must update the existing node, not create a new one") } + +// reregisterExpiredUserNodeWithSpentKey registers a user-owned node with a +// one-shot key, forces it into the expired state, and re-registers with the +// same spent key. sameNodeKey distinguishes the two re-auth shapes: +// - false: the node rotates its node key (normal tailscale client on re-auth) +// - true: the node reuses its node key +// +// In both cases an expired node is genuinely re-authenticating and must present +// a valid key; a spent one-shot key must be rejected. +func reregisterExpiredUserNodeWithSpentKey(t *testing.T, sameNodeKey bool) (types.NodeView, error) { + t.Helper() + + dbPath := t.TempDir() + "/headscale.db" + cfg := persistTestConfig(dbPath) + + s, err := NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + user := s.CreateUserForTest("expired-user") + + pak, err := s.CreatePreAuthKey(user.TypedID(), false, false, nil, nil) + require.NoError(t, err) + + machineKey := key.NewMachine() + nodeKey := key.NewNode() + + regReq := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key}, + NodeKey: nodeKey.Public(), + Hostinfo: &tailcfg.Hostinfo{Hostname: "expired-node"}, + Expiry: time.Now().Add(24 * time.Hour), + } + + first, _, err := s.HandleNodeFromPreAuthKey(regReq, machineKey.Public()) + require.NoError(t, err) + require.True(t, first.Valid()) + require.False(t, first.IsTagged(), "precondition: node must be user-owned") + + // Force the node into the expired state. + past := time.Now().Add(-1 * time.Hour) + _, ok := s.nodeStore.UpdateNode(first.ID(), func(n *types.Node) { + n.Expiry = &past + }) + require.True(t, ok) + + reReg := regReq + if !sameNodeKey { + reReg.NodeKey = key.NewNode().Public() + } + + node, _, err := s.HandleNodeFromPreAuthKey(reReg, machineKey.Public()) + + return node, err +} + +// TestExpiredUserNodeReusedOneShotKey_RotatedNodeKey: a node rotating its node +// key on re-auth is already a key rotation, so the key is re-validated. +func TestExpiredUserNodeReusedOneShotKey_RotatedNodeKey(t *testing.T) { + _, err := reregisterExpiredUserNodeWithSpentKey(t, false) + require.Error(t, err, + "expired node re-authenticating with a rotated node key must present a valid key") + require.Contains(t, err.Error(), "authkey already used") +} + +// TestExpiredUserNodeReusedOneShotKey_SameNodeKey: the security boundary must +// not depend on the client rotating its node key. An expired node re-using its +// node key must still re-validate the key, otherwise a spent one-shot key +// silently re-authorises it. +func TestExpiredUserNodeReusedOneShotKey_SameNodeKey(t *testing.T) { + _, err := reregisterExpiredUserNodeWithSpentKey(t, true) + require.Error(t, err, + "expired node re-registering with the same node key must re-validate its key") + require.Contains(t, err.Error(), "authkey already used") +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index ce4d896de..c8b1c20f8 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -2234,10 +2234,18 @@ func (s *State) HandleNodeFromPreAuthKey( isNodeKeyRotation := existsSameUser && existingNodeSameUser.Valid() && existingNodeSameUser.NodeKey() != regReq.NodeKey - if isExistingNodeReregistering && !isNodeKeyRotation { - // Existing node re-registering with same NodeKey: skip validation. - // Pre-auth keys are only needed for initial authentication. Critical for - // containers that run "tailscale up --authkey=KEY" on every restart. + // An expired node is genuinely re-authenticating, not just waking up, so it + // must present a valid key. Without this a node that re-uses its NodeKey + // after expiry would skip validation and be re-authorised with a spent or + // expired key; the boundary must not depend on the client rotating its key. + isExpired := existsSameUser && existingNodeSameUser.Valid() && + existingNodeSameUser.IsExpired() + + if isExistingNodeReregistering && !isNodeKeyRotation && !isExpired { + // Existing, still-valid node re-registering with same NodeKey: skip + // validation. Pre-auth keys are only needed for initial authentication. + // Critical for containers that run "tailscale up --authkey=KEY" on every + // restart. log.Debug(). Caller(). Uint64(zf.NodeID, existingNodeSameUser.ID().Uint64()). From a73d38bb3f3b0f9d9a1a090574c3e1774d88fc36 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 15 Jun 2026 10:58:42 +0000 Subject: [PATCH 005/127] state: reject re-registration claiming another node's key The pre-auth-key path wrote the client node key without the collision check the auth path applies, so a re-registration could claim another node's key and poison the node-key index. Reject keys bound to a different machine. Updates #3312 --- hscontrol/state/persist_test.go | 57 +++++++++++++++++++++++++++++++++ hscontrol/state/state.go | 11 +++++++ 2 files changed, 68 insertions(+) diff --git a/hscontrol/state/persist_test.go b/hscontrol/state/persist_test.go index ab35d9a4d..e29de8396 100644 --- a/hscontrol/state/persist_test.go +++ b/hscontrol/state/persist_test.go @@ -3,6 +3,7 @@ package state import ( "net/netip" "testing" + "time" "github.com/juanfont/headscale/hscontrol/db" "github.com/juanfont/headscale/hscontrol/types" @@ -415,3 +416,59 @@ func TestReauthChange(t *testing.T) { assert.Empty(t, pol.PeersChanged) assert.False(t, pol.IsEmpty(), "a policy change must be non-empty") } + +// TestPreAuthKeyReauthRejectsNodeKeyClaimedByAnotherMachine is the pre-auth-key +// analogue of TestReauthRejectsNodeKeyClaimedByAnotherMachine: re-registering +// via a pre-auth key must enforce the same 1:1 NodeKey<->MachineKey binding the +// auth path and poll-time validation enforce, so a node cannot rotate its key +// to a victim's and poison the NodeStore NodeKey index. +func TestPreAuthKeyReauthRejectsNodeKeyClaimedByAnotherMachine(t *testing.T) { + dbPath := t.TempDir() + "/headscale.db" + cfg := persistTestConfig(dbPath) + + s, err := NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + attacker := s.CreateUserForTest("attacker") + victim := s.CreateUserForTest("victim") + + victimMachine := key.NewMachine() + victimNodeKey := key.NewNode() + _, err = s.createAndSaveNewNode(newNodeParams{ + User: *victim, + MachineKey: victimMachine.Public(), + NodeKey: victimNodeKey.Public(), + DiscoKey: key.NewDisco().Public(), + Hostname: "victim", + RegisterMethod: util.RegisterMethodCLI, + }) + require.NoError(t, err) + + // Attacker registers its own node with a reusable pre-auth key. + pak, err := s.CreatePreAuthKey(attacker.TypedID(), true, false, nil, nil) + require.NoError(t, err) + + attackerMachine := key.NewMachine() + regReq := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key}, + NodeKey: key.NewNode().Public(), + Hostinfo: &tailcfg.Hostinfo{Hostname: "attacker"}, + Expiry: time.Now().Add(24 * time.Hour), + } + _, _, err = s.HandleNodeFromPreAuthKey(regReq, attackerMachine.Public()) + require.NoError(t, err) + + // Attacker re-registers its own node but supplies the victim's NodeKey. + attack := regReq + attack.NodeKey = victimNodeKey.Public() + _, _, err = s.HandleNodeFromPreAuthKey(attack, attackerMachine.Public()) + require.ErrorIs(t, err, ErrNodeKeyInUse, + "pre-auth-key re-registration claiming another machine's NodeKey must be rejected") + + // The victim still owns its NodeKey. + owner, ok := s.GetNodeByNodeKey(victimNodeKey.Public()) + require.True(t, ok) + require.Equal(t, victimMachine.Public(), owner.MachineKey(), + "victim's NodeKey index entry must be untouched") +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index c8b1c20f8..f54271846 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -2301,6 +2301,17 @@ func (s *State) HandleNodeFromPreAuthKey( Str(zf.UserName, pakUsername()). Msg("Node re-registering with existing machine key and user, updating in place") + // Re-registration rotates the NodeKey to the client-supplied value. + // Enforce the same 1:1 NodeKey<->MachineKey binding the auth path + // (applyAuthNodeUpdate) and poll-time validation enforce: a NodeKey + // already bound to a different machine must not be claimed here, or a + // re-registering node could rotate its key to a victim's and poison the + // NodeStore NodeKey index, denying the victim service. + if existing, ok := s.nodeStore.GetNodeByNodeKey(regReq.NodeKey); ok && + existing.MachineKey() != machineKey { + return types.NodeView{}, change.Change{}, ErrNodeKeyInUse + } + // Update existing node - NodeStore first, then database updatedNodeView, ok := s.nodeStore.UpdateNode(existingNodeSameUser.ID(), func(node *types.Node) { node.NodeKey = regReq.NodeKey From fd08b8fa8c179fa4c7d2310ddee847d9e39e2e2c Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 15 Jun 2026 11:07:26 +0000 Subject: [PATCH 006/127] state: make any-user machine-key lookup deterministic The lookup returned the first map match, so re-auth branch choice varied with map order once a machine key had more than one node. Prefer the tagged node, else the lowest node ID. Updates #3312 --- hscontrol/state/node_store.go | 21 +++++++---- hscontrol/state/node_store_test.go | 56 ++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/hscontrol/state/node_store.go b/hscontrol/state/node_store.go index 66a7fc5f7..b997ea2ee 100644 --- a/hscontrol/state/node_store.go +++ b/hscontrol/state/node_store.go @@ -804,12 +804,15 @@ func (s *NodeStore) GetNodeByMachineKey(machineKey key.MachinePublic, userID typ return types.NodeView{}, false } -// GetNodeByMachineKeyAnyUser returns the first node with the given machine key, +// GetNodeByMachineKeyAnyUser returns a node with the given machine key, // regardless of which user it belongs to. This is useful for scenarios like // transferring a node to a different user when re-authenticating with a // different user's auth key. -// If multiple nodes exist with the same machine key (different users), the -// first one found is returned (order is not guaranteed). +// +// When more than one node shares the machine key (e.g. a tagged node and a +// user-owned node), the choice is deterministic: the tagged node is preferred, +// otherwise the node with the lowest ID. A stable pick keeps re-auth branch +// selection (convert vs create) from depending on map iteration order. func (s *NodeStore) GetNodeByMachineKeyAnyUser(machineKey key.MachinePublic) (types.NodeView, bool) { timer := prometheus.NewTimer(nodeStoreOperationDuration.WithLabelValues("get_by_machine_key_any_user")) defer timer.ObserveDuration() @@ -817,14 +820,20 @@ func (s *NodeStore) GetNodeByMachineKeyAnyUser(machineKey key.MachinePublic) (ty nodeStoreOperations.WithLabelValues("get_by_machine_key_any_user").Inc() snapshot := s.data.Load() + + var best types.NodeView + if userMap, exists := snapshot.nodesByMachineKey[machineKey]; exists { - // Return the first node found (order not guaranteed due to map iteration) for _, node := range userMap { - return node, true + if !best.Valid() || + (node.IsTagged() && !best.IsTagged()) || + (node.IsTagged() == best.IsTagged() && node.ID() < best.ID()) { + best = node + } } } - return types.NodeView{}, false + return best, best.Valid() } // DebugString returns debug information about the [NodeStore]. diff --git a/hscontrol/state/node_store_test.go b/hscontrol/state/node_store_test.go index 745a8714b..f3c517d0f 100644 --- a/hscontrol/state/node_store_test.go +++ b/hscontrol/state/node_store_test.go @@ -1321,3 +1321,59 @@ func TestRebuildPeerMapsWithChangedPeersFunc(t *testing.T) { assert.Equal(t, 1, peers1.Len(), "ListPeers for node1 should return 1") assert.Equal(t, 1, peers2.Len(), "ListPeers for node2 should return 1") } + +// TestGetNodeByMachineKeyAnyUserDeterministic ensures the any-user machine-key +// lookup returns a stable, well-defined node when more than one node shares a +// machine key: the tagged node if present, otherwise the lowest node ID. A +// nondeterministic pick makes re-auth branch choice (convert vs create) depend +// on map iteration order. +func TestGetNodeByMachineKeyAnyUserDeterministic(t *testing.T) { + mk := key.NewMachine().Public() + + assertStablePick := func(t *testing.T, store *NodeStore, want types.NodeID) { + t.Helper() + + for range 50 { + got, ok := store.GetNodeByMachineKeyAnyUser(mk) + require.True(t, ok) + require.Equal(t, want, got.ID(), "pick must be stable and well-defined") + } + } + + t.Run("prefers lowest node ID", func(t *testing.T) { + store := NewNodeStore(nil, allowAllPeersFunc, TestBatchSize, TestBatchTimeout) + + store.Start() + defer store.Stop() + + n2 := createTestNode(2, 2, "user2", "node2") + n2.MachineKey = mk + n1 := createTestNode(1, 1, "user1", "node1") + n1.MachineKey = mk + + store.PutNode(n2) + store.PutNode(n1) + + assertStablePick(t, store, 1) + }) + + t.Run("prefers tagged node", func(t *testing.T) { + store := NewNodeStore(nil, allowAllPeersFunc, TestBatchSize, TestBatchTimeout) + + store.Start() + defer store.Stop() + + owned := createTestNode(1, 1, "user1", "node1") + owned.MachineKey = mk + tagged := createTestNode(3, 3, "user3", "node3") + tagged.MachineKey = mk + tagged.UserID = nil + tagged.User = nil + tagged.Tags = []string{"tag:foo"} + + store.PutNode(owned) + store.PutNode(tagged) + + assertStablePick(t, store, 3) + }) +} From bff216a184eebaeb82baaabf50705e683185d899 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 15 Jun 2026 11:13:34 +0000 Subject: [PATCH 007/127] state: update node in place on pre-auth-key re-registration A reusable key on a converted node, or a tagged key on a user-owned node, fell through to new-node creation, leaving two nodes per machine. Match by machine key and update or convert in place. Updates #3312 --- hscontrol/state/auth_tagged_expiry_test.go | 85 ++++++++++++++++++++++ hscontrol/state/state.go | 30 ++++++-- 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/hscontrol/state/auth_tagged_expiry_test.go b/hscontrol/state/auth_tagged_expiry_test.go index cc1baa5e7..fa1bab532 100644 --- a/hscontrol/state/auth_tagged_expiry_test.go +++ b/hscontrol/state/auth_tagged_expiry_test.go @@ -233,3 +233,88 @@ func TestExpiredUserNodeReusedOneShotKey_SameNodeKey(t *testing.T) { "expired node re-registering with the same node key must re-validate its key") require.Contains(t, err.Error(), "authkey already used") } + +// TestReusableUserPAKReauthOnTaggedNodeNoDuplicate guards against a reusable +// user pre-auth key creating a second node when it is re-presented for a node +// that has since been converted to tagged. The node must be updated in place. +func TestReusableUserPAKReauthOnTaggedNodeNoDuplicate(t *testing.T) { + dbPath := t.TempDir() + "/headscale.db" + cfg := persistTestConfig(dbPath) + + s, err := NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + user := s.CreateUserForTest("reusable-user") + + policy := fmt.Sprintf(`{"tagOwners":{"tag:foo":["%s@"]}}`, user.Name) + _, err = s.SetPolicy([]byte(policy)) + require.NoError(t, err) + + pak, err := s.CreatePreAuthKey(user.TypedID(), true, false, nil, nil) + require.NoError(t, err) + + machineKey := key.NewMachine() + regReq := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key}, + NodeKey: key.NewNode().Public(), + Hostinfo: &tailcfg.Hostinfo{Hostname: "reusable-node"}, + Expiry: time.Now().Add(24 * time.Hour), + } + + first, _, err := s.HandleNodeFromPreAuthKey(regReq, machineKey.Public()) + require.NoError(t, err) + + _, _, err = s.SetNodeTags(first.ID(), []string{"tag:foo"}) + require.NoError(t, err) + + second, _, err := s.HandleNodeFromPreAuthKey(regReq, machineKey.Public()) + require.NoError(t, err) + require.True(t, second.IsTagged()) + require.Equal(t, first.ID(), second.ID(), "must update in place, not duplicate") + require.Equal(t, 1, s.ListNodes().Len(), "machine must map to exactly one node") +} + +// TestTaggedPAKReauthConvertsUserOwnedNode ensures presenting a tagged pre-auth +// key for a machine that already has a user-owned node converts that node in +// place (same machine, new ownership) rather than creating a duplicate. +func TestTaggedPAKReauthConvertsUserOwnedNode(t *testing.T) { + dbPath := t.TempDir() + "/headscale.db" + cfg := persistTestConfig(dbPath) + + s, err := NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + user := s.CreateUserForTest("owner") + + userPak, err := s.CreatePreAuthKey(user.TypedID(), true, false, nil, nil) + require.NoError(t, err) + + machineKey := key.NewMachine() + nodeKey := key.NewNode() + regReq := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{AuthKey: userPak.Key}, + NodeKey: nodeKey.Public(), + Hostinfo: &tailcfg.Hostinfo{Hostname: "owned-node"}, + Expiry: time.Now().Add(24 * time.Hour), + } + + owned, _, err := s.HandleNodeFromPreAuthKey(regReq, machineKey.Public()) + require.NoError(t, err) + require.False(t, owned.IsTagged(), "precondition: node is user-owned") + + // A tags-only key re-registers the same machine (same node key). + taggedPak, err := s.CreatePreAuthKey(nil, true, false, nil, []string{"tag:foo"}) + require.NoError(t, err) + + convReq := regReq + convReq.Auth = &tailcfg.RegisterResponseAuth{AuthKey: taggedPak.Key} + + converted, _, err := s.HandleNodeFromPreAuthKey(convReq, machineKey.Public()) + require.NoError(t, err) + require.Equal(t, owned.ID(), converted.ID(), "must convert in place, not duplicate") + require.True(t, converted.IsTagged(), "node must become tagged") + require.Equal(t, []string{"tag:foo"}, converted.Tags().AsSlice()) + require.Equal(t, 1, s.ListNodes().Len(), "machine must map to exactly one node") +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index f54271846..f2803fdec 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -2187,10 +2187,13 @@ func (s *State) findExistingNodeForPAK( } } - // Tagged nodes have nil UserID, so they are indexed under UserID(0) - // in nodesByMachineKey. Check there for tagged PAK re-registration. + // A tagged key re-registers the same machine regardless of how that machine + // is currently owned: a tagged node (indexed under UserID(0)) is a plain + // re-registration, while a user-owned node is converted to tagged in place + // (handled by the caller). Match on the machine key alone so neither case + // creates a duplicate node. if pak.IsTagged() { - return s.nodeStore.GetNodeByMachineKey(machineKey, 0) + return s.nodeStore.GetNodeByMachineKeyAnyUser(machineKey) } return types.NodeView{}, false @@ -2241,7 +2244,13 @@ func (s *State) HandleNodeFromPreAuthKey( isExpired := existsSameUser && existingNodeSameUser.Valid() && existingNodeSameUser.IsExpired() - if isExistingNodeReregistering && !isNodeKeyRotation && !isExpired { + // A tagged key presented for a currently user-owned node converts that node + // to tagged. That is an ownership change, not a plain refresh, so it must + // present a valid key rather than ride the skip-validation fast-path. + isOwnershipConversion := existsSameUser && existingNodeSameUser.Valid() && + pak.IsTagged() && !existingNodeSameUser.IsTagged() + + if isExistingNodeReregistering && !isNodeKeyRotation && !isExpired && !isOwnershipConversion { // Existing, still-valid node re-registering with same NodeKey: skip // validation. Pre-auth keys are only needed for initial authentication. // Critical for containers that run "tailscale up --authkey=KEY" on every @@ -2327,8 +2336,17 @@ func (s *State) HandleNodeFromPreAuthKey( node.RegisterMethod = util.RegisterMethodAuthKey // Tags from PreAuthKey are only applied during initial registration. - // On re-registration the node keeps its existing tags and ownership. - // Only update AuthKey reference. + // On re-registration the node keeps its existing tags and ownership, + // except when a tagged key converts a user-owned node: that adopts + // the key's tags and drops user ownership (tagged nodes are + // user-less and never expire). Only update AuthKey reference + // otherwise. + if pak.IsTagged() && !node.IsTagged() { + node.Tags = pak.Proto().GetAclTags() + node.UserID = nil + node.User = nil + node.Expiry = nil + } node.AuthKey = pak node.AuthKeyID = &pak.ID // Do NOT reset IsOnline here. Online status is managed exclusively by From 9b8949727d6da845a9f6fb80fce887ea2da36321 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 15 Jun 2026 11:16:17 +0000 Subject: [PATCH 008/127] db: treat unknown pre-auth key as not found An unknown or deleted key returned a bare error matching neither the not-found nor pre-auth-key checks, so registration returned a server error instead of 401. Wrap gorm.ErrRecordNotFound. Updates #3312 --- hscontrol/db/preauth_keys.go | 5 ++++- hscontrol/db/preauth_keys_test.go | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/hscontrol/db/preauth_keys.go b/hscontrol/db/preauth_keys.go index 006767269..c8369f794 100644 --- a/hscontrol/db/preauth_keys.go +++ b/hscontrol/db/preauth_keys.go @@ -15,7 +15,10 @@ import ( ) var ( - ErrPreAuthKeyNotFound = errors.New("auth-key not found") + // ErrPreAuthKeyNotFound wraps gorm.ErrRecordNotFound so an unknown or + // deleted key is treated as a missing record by callers, which the + // registration handler maps to a 401 rather than a raw server error. + ErrPreAuthKeyNotFound = fmt.Errorf("auth-key not found: %w", gorm.ErrRecordNotFound) ErrPreAuthKeyExpired = errors.New("auth-key expired") ErrSingleUseAuthKeyHasBeenUsed = errors.New("auth-key has already been used") ErrUserMismatch = errors.New("user mismatch") diff --git a/hscontrol/db/preauth_keys_test.go b/hscontrol/db/preauth_keys_test.go index 25bec17e9..c152959c5 100644 --- a/hscontrol/db/preauth_keys_test.go +++ b/hscontrol/db/preauth_keys_test.go @@ -487,3 +487,16 @@ func TestUsePreAuthKeyAtomicCAS(t *testing.T) { "second UsePreAuthKey error must be a PAKError, got: %v", err) assert.Equal(t, "authkey already used", pakErr.Error()) } + +// TestGetPreAuthKeyUnknownMapsToRecordNotFound ensures an unknown (or deleted) +// pre-auth key resolves to a record-not-found error, which the registration +// handler maps to a 401 rather than a raw server error. +func TestGetPreAuthKeyUnknownMapsToRecordNotFound(t *testing.T) { + db, err := newSQLiteTestDB() + require.NoError(t, err) + + _, err = db.GetPreAuthKey("nonexistent-key") + require.Error(t, err) + require.ErrorIs(t, err, gorm.ErrRecordNotFound, + "unknown pre-auth key must map to record-not-found (handled as 401)") +} From 96d2e6ed60f2d357c8b646eb5a490dd8de1b4b2b Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 15 Jun 2026 11:20:17 +0000 Subject: [PATCH 009/127] state: roll back node store when re-registration write fails Re-registration mutated the node store before the database write and did not revert on failure, so a restart could drop the client's current node key. Snapshot the node and restore it if the write fails. Updates #3312 --- hscontrol/state/persist_test.go | 54 +++++++++++++++++++++++++++++++++ hscontrol/state/state.go | 13 ++++++++ 2 files changed, 67 insertions(+) diff --git a/hscontrol/state/persist_test.go b/hscontrol/state/persist_test.go index e29de8396..95064ea45 100644 --- a/hscontrol/state/persist_test.go +++ b/hscontrol/state/persist_test.go @@ -1,6 +1,7 @@ package state import ( + "errors" "net/netip" "testing" "time" @@ -10,6 +11,7 @@ import ( "github.com/juanfont/headscale/hscontrol/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/gorm" "tailscale.com/tailcfg" "tailscale.com/types/key" ) @@ -472,3 +474,55 @@ func TestPreAuthKeyReauthRejectsNodeKeyClaimedByAnotherMachine(t *testing.T) { require.Equal(t, victimMachine.Public(), owner.MachineKey(), "victim's NodeKey index entry must be untouched") } + +var errInjectedNodeUpdate = errors.New("injected node update failure") + +// TestPreAuthKeyReauthRevertsNodeStoreOnDBFailure ensures a failed database +// write during pre-auth-key re-registration does not leave the NodeStore +// holding a node key that was never persisted: a restart would reload the old +// row and the client's current key would no longer resolve, locking it out. +func TestPreAuthKeyReauthRevertsNodeStoreOnDBFailure(t *testing.T) { + dbPath := t.TempDir() + "/headscale.db" + cfg := persistTestConfig(dbPath) + + s, err := NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + user := s.CreateUserForTest("reauth-user") + + pak, err := s.CreatePreAuthKey(user.TypedID(), true, false, nil, nil) + require.NoError(t, err) + + machineKey := key.NewMachine() + regReq := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key}, + NodeKey: key.NewNode().Public(), + Hostinfo: &tailcfg.Hostinfo{Hostname: "reauth-node"}, + Expiry: time.Now().Add(24 * time.Hour), + } + node, _, err := s.HandleNodeFromPreAuthKey(regReq, machineKey.Public()) + require.NoError(t, err) + + origNodeKey := node.NodeKey() + + // Fail the node row update so the re-registration's database write errors + // after the NodeStore has already been mutated. + require.NoError(t, s.db.DB.Callback().Update().Before("gorm:update"). + Register("fail_node_update", func(tx *gorm.DB) { + if tx.Statement.Table == "nodes" { + _ = tx.AddError(errInjectedNodeUpdate) + } + })) + + reReg := regReq + reReg.NodeKey = key.NewNode().Public() // rotate -> NodeStore mutation, then DB write fails + _, _, err = s.HandleNodeFromPreAuthKey(reReg, machineKey.Public()) + require.NoError(t, s.db.DB.Callback().Update().Remove("fail_node_update")) + require.Error(t, err, "re-registration must fail when the database write fails") + + got, ok := s.nodeStore.GetNode(node.ID()) + require.True(t, ok) + require.Equal(t, origNodeKey, got.NodeKey(), + "NodeStore must revert to the persisted node key when the write fails") +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index f2803fdec..07f1c24ed 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -2321,6 +2321,12 @@ func (s *State) HandleNodeFromPreAuthKey( return types.NodeView{}, change.Change{}, ErrNodeKeyInUse } + // Snapshot the pre-update node so the NodeStore can be rolled back if + // the database write below fails. The view points at the immutable + // pre-update snapshot (UpdateNode swaps in a new one), so this stays + // valid after the mutation. + priorNode := existingNodeSameUser.AsStruct() + // Update existing node - NodeStore first, then database updatedNodeView, ok := s.nodeStore.UpdateNode(existingNodeSameUser.ID(), func(node *types.Node) { node.NodeKey = regReq.NodeKey @@ -2401,6 +2407,13 @@ func (s *State) HandleNodeFromPreAuthKey( return nil, nil //nolint:nilnil // intentional: transaction success }) if err != nil { + // The NodeStore was updated before the database write. Roll it back + // so it does not advertise a registration the database rejected + // (e.g. a node key that a restart would not reload). + if priorNode != nil { + s.nodeStore.PutNode(*priorNode) + } + return types.NodeView{}, change.Change{}, fmt.Errorf("writing node to database: %w", err) } From b83bf3f9936e61c55846adfa8091763d48f5d50b Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 15 Jun 2026 11:27:13 +0000 Subject: [PATCH 010/127] state: serialise registration per machine key Concurrent registrations of one machine key each saw no existing node and created their own, duplicating nodes and IPs. Hold a per-machine lock across the find-then-create section. Updates #3312 --- hscontrol/state/persist_test.go | 56 +++++++++++++++++++++++++++++++++ hscontrol/state/state.go | 29 ++++++++++++++++- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/hscontrol/state/persist_test.go b/hscontrol/state/persist_test.go index 95064ea45..0be0b4115 100644 --- a/hscontrol/state/persist_test.go +++ b/hscontrol/state/persist_test.go @@ -3,6 +3,7 @@ package state import ( "errors" "net/netip" + "sync" "testing" "time" @@ -526,3 +527,58 @@ func TestPreAuthKeyReauthRevertsNodeStoreOnDBFailure(t *testing.T) { require.Equal(t, origNodeKey, got.NodeKey(), "NodeStore must revert to the persisted node key when the write fails") } + +// TestConcurrentPreAuthKeyRegistrationSameMachineKey ensures concurrent +// registrations of the same machine key resolve to a single node. Without +// serialising the find-then-create section, each request sees "no existing +// node" and creates its own, leaving duplicate nodes and IP allocations for +// one machine. +func TestConcurrentPreAuthKeyRegistrationSameMachineKey(t *testing.T) { + dbPath := t.TempDir() + "/headscale.db" + cfg := persistTestConfig(dbPath) + + s, err := NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + user := s.CreateUserForTest("concurrent-user") + + pak, err := s.CreatePreAuthKey(user.TypedID(), true, false, nil, nil) + require.NoError(t, err) + + machineKey := key.NewMachine() + + const n = 12 + + var wg sync.WaitGroup + + start := make(chan struct{}) + errs := make(chan error, n) + + for range n { + wg.Go(func() { + regReq := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key}, + NodeKey: key.NewNode().Public(), + Hostinfo: &tailcfg.Hostinfo{Hostname: "concurrent-node"}, + Expiry: time.Now().Add(24 * time.Hour), + } + + <-start + + _, _, err := s.HandleNodeFromPreAuthKey(regReq, machineKey.Public()) + errs <- err + }) + } + + close(start) + wg.Wait() + close(errs) + + for err := range errs { + require.NoError(t, err) + } + + require.Equal(t, 1, s.ListNodes().Len(), + "concurrent registrations of one machine key must yield a single node") +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index 07f1c24ed..94acac73f 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -32,6 +32,7 @@ import ( "github.com/juanfont/headscale/hscontrol/types/change" "github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/hscontrol/util/zlog/zf" + "github.com/puzpuzpuz/xsync/v4" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "gorm.io/gorm" @@ -179,6 +180,22 @@ type State struct { // persistNodeToDB so the database row always converges on [NodeStore] // rather than being clobbered by a stale caller snapshot. persistMu sync.Mutex + + // registerLocks serialises registration per machine key so concurrent + // registrations of the same machine resolve to a single node instead of + // racing the find-then-create section and each creating their own. + // ponytail: entries are never pruned; bounded by distinct machine keys + // seen, add cleanup on node delete only if it ever matters. + registerLocks *xsync.Map[key.MachinePublic, *sync.Mutex] +} + +// lockRegistration serialises registration for a single machine key and +// returns the unlock function. +func (s *State) lockRegistration(machineKey key.MachinePublic) func() { + mu, _ := s.registerLocks.LoadOrStore(machineKey, &sync.Mutex{}) + mu.Lock() + + return mu.Unlock } // NewState creates and initializes a new [State] instance, setting up the database, @@ -272,7 +289,8 @@ func NewState(cfg *types.Config) (*State, error) { nodeStore: nodeStore, pings: newPingTracker(), - sshCheckAuth: make(map[sshCheckPair]time.Time), + sshCheckAuth: make(map[sshCheckPair]time.Time), + registerLocks: xsync.NewMap[key.MachinePublic, *sync.Mutex](), }, nil } @@ -2028,6 +2046,11 @@ func (s *State) HandleNodeFromAuthPath( // Lookup existing nodes machineKey := regData.MachineKey + + // Serialise registration for this machine so concurrent auth callbacks + // resolve to a single node rather than racing the find-then-create section. + defer s.lockRegistration(machineKey)() + existingNodeSameUser, _ := s.nodeStore.GetNodeByMachineKey(machineKey, types.UserID(user.ID)) existingNodeAnyUser, _ := s.nodeStore.GetNodeByMachineKeyAnyUser(machineKey) @@ -2204,6 +2227,10 @@ func (s *State) HandleNodeFromPreAuthKey( regReq tailcfg.RegisterRequest, machineKey key.MachinePublic, ) (types.NodeView, change.Change, error) { + // Serialise registration for this machine so concurrent restarts resolve + // to a single node rather than racing the find-then-create section. + defer s.lockRegistration(machineKey)() + pak, err := s.GetPreAuthKey(regReq.Auth.AuthKey) if err != nil { return types.NodeView{}, change.Change{}, err From a1d3e982554f8cc21bca0a80982cba005034b6c3 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 15 Jun 2026 13:10:09 +0000 Subject: [PATCH 011/127] state: allow key expiry to be set on tagged nodes Tagged nodes disable key expiry by default but can still have one set explicitly, and changing tags leaves expiry unchanged, matching Tailscale. Updates #3312 --- hscontrol/state/auth_tagged_expiry_test.go | 71 ++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/hscontrol/state/auth_tagged_expiry_test.go b/hscontrol/state/auth_tagged_expiry_test.go index fa1bab532..0544e0498 100644 --- a/hscontrol/state/auth_tagged_expiry_test.go +++ b/hscontrol/state/auth_tagged_expiry_test.go @@ -318,3 +318,74 @@ func TestTaggedPAKReauthConvertsUserOwnedNode(t *testing.T) { require.Equal(t, []string{"tag:foo"}, converted.Tags().AsSlice()) require.Equal(t, 1, s.ListNodes().Len(), "machine must map to exactly one node") } + +// TestTaggedNodeCanHaveKeyExpiry matches Tailscale: a tagged node has key +// expiry disabled by default, but it can still be set explicitly (e.g. via +// `headscale nodes expire`). +func TestTaggedNodeCanHaveKeyExpiry(t *testing.T) { + dbPath := t.TempDir() + "/headscale.db" + cfg := persistTestConfig(dbPath) + + s, err := NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + _, err = s.SetPolicy([]byte(`{"tagOwners":{"tag:foo":["tagger@"]}}`)) + require.NoError(t, err) + + pak, err := s.CreatePreAuthKey(nil, true, false, nil, []string{"tag:foo"}) + require.NoError(t, err) + + regReq := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key}, + NodeKey: key.NewNode().Public(), + Hostinfo: &tailcfg.Hostinfo{Hostname: "tagged-node"}, + } + node, _, err := s.HandleNodeFromPreAuthKey(regReq, key.NewMachine().Public()) + require.NoError(t, err) + require.True(t, node.IsTagged()) + require.Nil(t, node.AsStruct().Expiry, "key expiry is disabled by default for tagged nodes") + + expiry := time.Now().Add(24 * time.Hour) + after, _, err := s.SetNodeExpiry(node.ID(), &expiry) + require.NoError(t, err) + require.True(t, after.IsTagged(), "node stays tagged") + require.NotNil(t, after.AsStruct().Expiry, "expiry can be set on a tagged node") + require.Equal(t, expiry.Unix(), after.AsStruct().Expiry.Unix()) +} + +// TestTaggingPreservesNodeExpiry matches Tailscale: changing a node's tags does +// not alter its key expiry (expiry only changes on re-authentication). +func TestTaggingPreservesNodeExpiry(t *testing.T) { + dbPath := t.TempDir() + "/headscale.db" + cfg := persistTestConfig(dbPath) + + s, err := NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + user := s.CreateUserForTest("owner") + + _, err = s.SetPolicy(fmt.Appendf(nil, `{"tagOwners":{"tag:foo":["%s@"]}}`, user.Name)) + require.NoError(t, err) + + pak, err := s.CreatePreAuthKey(user.TypedID(), false, false, nil, nil) + require.NoError(t, err) + + expiry := time.Now().Add(24 * time.Hour) + regReq := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key}, + NodeKey: key.NewNode().Public(), + Hostinfo: &tailcfg.Hostinfo{Hostname: "owned-node"}, + Expiry: expiry, + } + node, _, err := s.HandleNodeFromPreAuthKey(regReq, key.NewMachine().Public()) + require.NoError(t, err) + require.NotNil(t, node.AsStruct().Expiry, "precondition: user node has an expiry") + + tagged, _, err := s.SetNodeTags(node.ID(), []string{"tag:foo"}) + require.NoError(t, err) + require.True(t, tagged.IsTagged()) + require.NotNil(t, tagged.AsStruct().Expiry, "tag change must not clear expiry") + require.Equal(t, expiry.Unix(), tagged.AsStruct().Expiry.Unix()) +} From 168947848549280ededd48cd2529bfeb1d6c9f5a Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 15 Jun 2026 14:43:37 +0000 Subject: [PATCH 012/127] state: return all nodes for a machine key, reject ambiguous ownership Collapse the single-pick machine-key lookups onto GetNodesByMachineKeyAllUsers so callers see every node sharing a machine key and reject the ambiguous or impossible cases (tagged and user-owned coexistence; a tagged key with several user-owned candidates) instead of mutating an arbitrarily-picked node. Updates #3312 --- hscontrol/auth_test.go | 26 ++-- hscontrol/state/auth_tagged_expiry_test.go | 89 +++++++++++ hscontrol/state/node_store.go | 56 ++----- hscontrol/state/node_store_test.go | 60 ++++---- hscontrol/state/state.go | 162 ++++++++++++++------- 5 files changed, 258 insertions(+), 135 deletions(-) diff --git a/hscontrol/auth_test.go b/hscontrol/auth_test.go index 4eb028a3b..ed294f3ca 100644 --- a/hscontrol/auth_test.go +++ b/hscontrol/auth_test.go @@ -1699,7 +1699,7 @@ func TestAuthenticationFlows(t *testing.T) { assert.False(t, resp.NodeKeyExpired) // Verify NEW node was created for user2 - node2, found := app.state.GetNodeByMachineKey(machineKey1.Public(), types.UserID(2)) + node2, found := app.state.GetNodesByMachineKeyAllUsers(machineKey1.Public())[types.UserID(2)] require.True(t, found, "new node should exist for user2") assert.Equal(t, uint(2), node2.UserID().Get(), "new node should belong to user2") @@ -1707,7 +1707,7 @@ func TestAuthenticationFlows(t *testing.T) { assert.Equal(t, "user2-context", user.Name(), "new node should show user2 username") // Verify original node still exists for user1 - node1, found := app.state.GetNodeByMachineKey(machineKey1.Public(), types.UserID(1)) + node1, found := app.state.GetNodesByMachineKeyAllUsers(machineKey1.Public())[types.UserID(1)] require.True(t, found, "original node should still exist for user1") assert.Equal(t, uint(1), node1.UserID().Get(), "original node should still belong to user1") @@ -1775,13 +1775,13 @@ func TestAuthenticationFlows(t *testing.T) { validateCompleteResponse: true, validate: func(t *testing.T, resp *tailcfg.RegisterResponse, app *Headscale) { //nolint:thelper // User1's original node should STILL exist (not transferred) - node1, found1 := app.state.GetNodeByMachineKey(machineKey1.Public(), types.UserID(1)) + node1, found1 := app.state.GetNodesByMachineKeyAllUsers(machineKey1.Public())[types.UserID(1)] require.True(t, found1, "user1's original node should still exist") assert.Equal(t, uint(1), node1.UserID().Get(), "user1's node should still belong to user1") assert.Equal(t, nodeKey1.Public(), node1.NodeKey(), "user1's node should have original node key") // User2 should have a NEW node created - node2, found2 := app.state.GetNodeByMachineKey(machineKey1.Public(), types.UserID(2)) + node2, found2 := app.state.GetNodesByMachineKeyAllUsers(machineKey1.Public())[types.UserID(2)] require.True(t, found2, "user2 should have new node created") assert.Equal(t, uint(2), node2.UserID().Get(), "user2's node should belong to user2") @@ -2914,7 +2914,7 @@ func TestPreAuthKeyLogoutAndReloginDifferentUser(t *testing.T) { for i := range 2 { node := nodes[i] // User1's original nodes should still be owned by user1 - registeredNode, found := app.state.GetNodeByMachineKey(node.machineKey.Public(), types.UserID(user1.ID)) + registeredNode, found := app.state.GetNodesByMachineKeyAllUsers(node.machineKey.Public())[types.UserID(user1.ID)] require.True(t, found, "User1's original node %s should still exist", node.hostname) require.Equal(t, user1.ID, registeredNode.UserID().Get(), "Node %s should still belong to user1", node.hostname) t.Logf("✓ User1's original node %s (ID=%d) still owned by user1", node.hostname, registeredNode.ID().Uint64()) @@ -2923,7 +2923,7 @@ func TestPreAuthKeyLogoutAndReloginDifferentUser(t *testing.T) { for i := 2; i < 4; i++ { node := nodes[i] // User2's original nodes should still be owned by user2 - registeredNode, found := app.state.GetNodeByMachineKey(node.machineKey.Public(), types.UserID(user2.ID)) + registeredNode, found := app.state.GetNodesByMachineKeyAllUsers(node.machineKey.Public())[types.UserID(user2.ID)] require.True(t, found, "User2's original node %s should still exist", node.hostname) require.Equal(t, user2.ID, registeredNode.UserID().Get(), "Node %s should still belong to user2", node.hostname) t.Logf("✓ User2's original node %s (ID=%d) still owned by user2", node.hostname, registeredNode.ID().Uint64()) @@ -2935,7 +2935,7 @@ func TestPreAuthKeyLogoutAndReloginDifferentUser(t *testing.T) { for i := 2; i < 4; i++ { node := nodes[i] // Should be able to find a node with user1 and this machine key (the new one) - newNode, found := app.state.GetNodeByMachineKey(node.machineKey.Public(), types.UserID(user1.ID)) + newNode, found := app.state.GetNodesByMachineKeyAllUsers(node.machineKey.Public())[types.UserID(user1.ID)] require.True(t, found, "Should have created new node for user1 with machine key from %s", node.hostname) require.Equal(t, user1.ID, newNode.UserID().Get(), "New node should belong to user1") t.Logf("✓ New node created for user1 with machine key from %s (ID=%d)", node.hostname, newNode.ID().Uint64()) @@ -2984,7 +2984,7 @@ func TestWebFlowReauthDifferentUser(t *testing.T) { require.True(t, resp1.MachineAuthorized, "Should be authorized via pre-auth key") // Verify node exists for user1 - user1Node, found := app.state.GetNodeByMachineKey(machineKey.Public(), types.UserID(user1.ID)) + user1Node, found := app.state.GetNodesByMachineKeyAllUsers(machineKey.Public())[types.UserID(user1.ID)] require.True(t, found, "Node should exist for user1") require.Equal(t, user1.ID, user1Node.UserID().Get(), "Node should belong to user1") user1NodeID := user1Node.ID() @@ -3000,7 +3000,7 @@ func TestWebFlowReauthDifferentUser(t *testing.T) { require.NoError(t, err) // Verify node is expired - user1Node, found = app.state.GetNodeByMachineKey(machineKey.Public(), types.UserID(user1.ID)) + user1Node, found = app.state.GetNodesByMachineKeyAllUsers(machineKey.Public())[types.UserID(user1.ID)] require.True(t, found, "Node should still exist after logout") require.True(t, user1Node.IsExpired(), "Node should be expired after logout") t.Logf("✓ User1 node expired (logged out)") @@ -3041,7 +3041,7 @@ func TestWebFlowReauthDifferentUser(t *testing.T) { t.Run("user1_original_node_still_exists", func(t *testing.T) { // User1's original node should STILL exist (not transferred to user2) - user1NodeAfter, found1 := app.state.GetNodeByMachineKey(machineKey.Public(), types.UserID(user1.ID)) + user1NodeAfter, found1 := app.state.GetNodesByMachineKeyAllUsers(machineKey.Public())[types.UserID(user1.ID)] assert.True(t, found1, "User1's original node should still exist (not transferred)") if !found1 { @@ -3056,7 +3056,7 @@ func TestWebFlowReauthDifferentUser(t *testing.T) { t.Run("user2_has_new_node_created", func(t *testing.T) { // User2 should have a NEW node created (not transfer from user1) - user2Node, found2 := app.state.GetNodeByMachineKey(machineKey.Public(), types.UserID(user2.ID)) + user2Node, found2 := app.state.GetNodesByMachineKeyAllUsers(machineKey.Public())[types.UserID(user2.ID)] assert.True(t, found2, "User2 should have a new node created") if !found2 { @@ -3080,8 +3080,8 @@ func TestWebFlowReauthDifferentUser(t *testing.T) { t.Run("both_nodes_share_machine_key", func(t *testing.T) { // Both nodes should have the same machine key (same physical device) - user1NodeFinal, found1 := app.state.GetNodeByMachineKey(machineKey.Public(), types.UserID(user1.ID)) - user2NodeFinal, found2 := app.state.GetNodeByMachineKey(machineKey.Public(), types.UserID(user2.ID)) + user1NodeFinal, found1 := app.state.GetNodesByMachineKeyAllUsers(machineKey.Public())[types.UserID(user1.ID)] + user2NodeFinal, found2 := app.state.GetNodesByMachineKeyAllUsers(machineKey.Public())[types.UserID(user2.ID)] require.True(t, found1, "User1 node should exist") require.True(t, found2, "User2 node should exist") diff --git a/hscontrol/state/auth_tagged_expiry_test.go b/hscontrol/state/auth_tagged_expiry_test.go index 0544e0498..4eef8fee7 100644 --- a/hscontrol/state/auth_tagged_expiry_test.go +++ b/hscontrol/state/auth_tagged_expiry_test.go @@ -319,6 +319,95 @@ func TestTaggedPAKReauthConvertsUserOwnedNode(t *testing.T) { require.Equal(t, 1, s.ListNodes().Len(), "machine must map to exactly one node") } +// registerTwoUsersOnOneMachine registers two user-owned nodes that share a +// machine key (the "create new, do not transfer" multi-user device state) and +// returns the State and the shared machine key. +func registerTwoUsersOnOneMachine(t *testing.T) (*State, key.MachinePublic, types.NodeID) { + t.Helper() + + dbPath := t.TempDir() + "/headscale.db" + cfg := persistTestConfig(dbPath) + + s, err := NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + u1 := s.CreateUserForTest("u1") + u2 := s.CreateUserForTest("u2") + mk := key.NewMachine() + + reg := func(pakUser *types.User) types.NodeView { + pak, err := s.CreatePreAuthKey(pakUser.TypedID(), true, false, nil, nil) + require.NoError(t, err) + n, _, err := s.HandleNodeFromPreAuthKey(tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key}, + NodeKey: key.NewNode().Public(), + Hostinfo: &tailcfg.Hostinfo{Hostname: "multi"}, + Expiry: time.Now().Add(24 * time.Hour), + }, mk.Public()) + require.NoError(t, err) + + return n + } + + n1 := reg(u1) + n2 := reg(u2) + require.NotEqual(t, n1.ID(), n2.ID(), "precondition: two distinct nodes share the machine key") + require.Equal(t, 2, s.ListNodes().Len()) + + return s, mk.Public(), n1.ID() +} + +// TestTaggedPAKReauthRejectsAmbiguousMultiUserNode: a tagged pre-auth key on a +// machine that has more than one user-owned node cannot know which to convert, +// so the registration is rejected rather than converting an arbitrary one and +// orphaning the rest. +func TestTaggedPAKReauthRejectsAmbiguousMultiUserNode(t *testing.T) { + s, mk, _ := registerTwoUsersOnOneMachine(t) + + taggedPak, err := s.CreatePreAuthKey(nil, true, false, nil, []string{"tag:foo"}) + require.NoError(t, err) + + _, _, err = s.HandleNodeFromPreAuthKey(tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{AuthKey: taggedPak.Key}, + NodeKey: key.NewNode().Public(), + Hostinfo: &tailcfg.Hostinfo{Hostname: "multi"}, + Expiry: time.Now().Add(24 * time.Hour), + }, mk) + require.ErrorIs(t, err, ErrAmbiguousNodeOwnership) + require.Equal(t, 2, s.ListNodes().Len(), "no node created or converted on rejection") +} + +// TestAuthPathRejectsTaggedAndUserCoexistence: if a machine key ends up with +// both a tagged node and a user-owned node (impossible per validateNodeOwnership, +// but reachable by tagging one node of a multi-user device via the admin path), +// an OIDC re-auth must reject rather than silently converting the tagged node +// and orphaning the user-owned one. +func TestAuthPathRejectsTaggedAndUserCoexistence(t *testing.T) { + s, mk, n1 := registerTwoUsersOnOneMachine(t) + + // Tag one of the two user-owned nodes -> {0: tagged, u2: user-owned} coexist. + _, err := s.SetPolicy([]byte(`{"tagOwners":{"tag:foo":["u1@"]}}`)) + require.NoError(t, err) + tagged, _, err := s.SetNodeTags(n1, []string{"tag:foo"}) + require.NoError(t, err) + require.True(t, tagged.IsTagged()) + + // A third user authenticates the same machine via OIDC. + u3 := s.CreateUserForTest("u3") + regData := &types.RegistrationData{ + MachineKey: mk, + NodeKey: key.NewNode().Public(), + Hostname: "multi", + Hostinfo: &tailcfg.Hostinfo{Hostname: "multi"}, + } + authID := types.MustAuthID() + s.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(regData)) + + _, _, err = s.HandleNodeFromAuthPath(authID, types.UserID(u3.ID), nil, util.RegisterMethodOIDC) + require.ErrorIs(t, err, ErrAmbiguousNodeOwnership) +} + // TestTaggedNodeCanHaveKeyExpiry matches Tailscale: a tagged node has key // expiry disabled by default, but it can still be set explicitly (e.g. via // `headscale nodes expire`). diff --git a/hscontrol/state/node_store.go b/hscontrol/state/node_store.go index b997ea2ee..e1e17a89e 100644 --- a/hscontrol/state/node_store.go +++ b/hscontrol/state/node_store.go @@ -787,53 +787,27 @@ func (s *NodeStore) GetNodeByNodeKey(nodeKey key.NodePublic) (types.NodeView, bo return nodeView, exists } -// GetNodeByMachineKey returns a node by its machine key and user ID. The bool indicates if the node exists. -func (s *NodeStore) GetNodeByMachineKey(machineKey key.MachinePublic, userID types.UserID) (types.NodeView, bool) { - timer := prometheus.NewTimer(nodeStoreOperationDuration.WithLabelValues("get_by_machine_key")) - defer timer.ObserveDuration() - - nodeStoreOperations.WithLabelValues("get_by_machine_key").Inc() - - snapshot := s.data.Load() - if userMap, exists := snapshot.nodesByMachineKey[machineKey]; exists { - if node, exists := userMap[userID]; exists { - return node, true - } - } - - return types.NodeView{}, false -} - -// GetNodeByMachineKeyAnyUser returns a node with the given machine key, -// regardless of which user it belongs to. This is useful for scenarios like -// transferring a node to a different user when re-authenticating with a -// different user's auth key. +// GetNodesByMachineKeyAllUsers returns every node sharing machineKey, keyed by +// owning UserID. Tagged nodes are indexed under UserID(0) (the tagged sentinel); +// user-owned nodes under their owning UserID. Returns an empty map if none. // -// When more than one node shares the machine key (e.g. a tagged node and a -// user-owned node), the choice is deterministic: the tagged node is preferred, -// otherwise the node with the lowest ID. A stable pick keeps re-auth branch -// selection (convert vs create) from depending on map iteration order. -func (s *NodeStore) GetNodeByMachineKeyAnyUser(machineKey key.MachinePublic) (types.NodeView, bool) { - timer := prometheus.NewTimer(nodeStoreOperationDuration.WithLabelValues("get_by_machine_key_any_user")) +// One machine key can map to several nodes (the same device registered by +// different users via the "create new, do not transfer" path). Exposing the +// whole set lets callers decide with full context — index [userID] for an exact +// match, [0] for a tagged node, or reject when the set is ambiguous — rather +// than guessing from a single arbitrary pick. +func (s *NodeStore) GetNodesByMachineKeyAllUsers(machineKey key.MachinePublic) map[types.UserID]types.NodeView { + timer := prometheus.NewTimer(nodeStoreOperationDuration.WithLabelValues("get_nodes_by_machine_key_all_users")) defer timer.ObserveDuration() - nodeStoreOperations.WithLabelValues("get_by_machine_key_any_user").Inc() + nodeStoreOperations.WithLabelValues("get_nodes_by_machine_key_all_users").Inc() - snapshot := s.data.Load() + userMap := s.data.Load().nodesByMachineKey[machineKey] - var best types.NodeView + out := make(map[types.UserID]types.NodeView, len(userMap)) + maps.Copy(out, userMap) - if userMap, exists := snapshot.nodesByMachineKey[machineKey]; exists { - for _, node := range userMap { - if !best.Valid() || - (node.IsTagged() && !best.IsTagged()) || - (node.IsTagged() == best.IsTagged() && node.ID() < best.ID()) { - best = node - } - } - } - - return best, best.Valid() + return out } // DebugString returns debug information about the [NodeStore]. diff --git a/hscontrol/state/node_store_test.go b/hscontrol/state/node_store_test.go index f3c517d0f..04d4290bb 100644 --- a/hscontrol/state/node_store_test.go +++ b/hscontrol/state/node_store_test.go @@ -1322,42 +1322,42 @@ func TestRebuildPeerMapsWithChangedPeersFunc(t *testing.T) { assert.Equal(t, 1, peers2.Len(), "ListPeers for node2 should return 1") } -// TestGetNodeByMachineKeyAnyUserDeterministic ensures the any-user machine-key -// lookup returns a stable, well-defined node when more than one node shares a -// machine key: the tagged node if present, otherwise the lowest node ID. A -// nondeterministic pick makes re-auth branch choice (convert vs create) depend -// on map iteration order. -func TestGetNodeByMachineKeyAnyUserDeterministic(t *testing.T) { +// TestGetNodesByMachineKeyAllUsers ensures the lookup returns every node sharing +// a machine key keyed by owning UserID (tagged nodes under UserID(0)), so callers +// see the full set instead of a single arbitrary pick. +func TestGetNodesByMachineKeyAllUsers(t *testing.T) { mk := key.NewMachine().Public() - assertStablePick := func(t *testing.T, store *NodeStore, want types.NodeID) { - t.Helper() - - for range 50 { - got, ok := store.GetNodeByMachineKeyAnyUser(mk) - require.True(t, ok) - require.Equal(t, want, got.ID(), "pick must be stable and well-defined") - } - } - - t.Run("prefers lowest node ID", func(t *testing.T) { + t.Run("empty when absent", func(t *testing.T) { store := NewNodeStore(nil, allowAllPeersFunc, TestBatchSize, TestBatchTimeout) store.Start() defer store.Stop() - n2 := createTestNode(2, 2, "user2", "node2") - n2.MachineKey = mk - n1 := createTestNode(1, 1, "user1", "node1") - n1.MachineKey = mk - - store.PutNode(n2) - store.PutNode(n1) - - assertStablePick(t, store, 1) + require.Empty(t, store.GetNodesByMachineKeyAllUsers(mk)) }) - t.Run("prefers tagged node", func(t *testing.T) { + t.Run("returns all user-owned nodes keyed by user", func(t *testing.T) { + store := NewNodeStore(nil, allowAllPeersFunc, TestBatchSize, TestBatchTimeout) + + store.Start() + defer store.Stop() + + n1 := createTestNode(1, 1, "user1", "node1") + n1.MachineKey = mk + n2 := createTestNode(2, 2, "user2", "node2") + n2.MachineKey = mk + + store.PutNode(n1) + store.PutNode(n2) + + all := store.GetNodesByMachineKeyAllUsers(mk) + require.Len(t, all, 2) + require.Equal(t, types.NodeID(1), all[types.UserID(1)].ID()) + require.Equal(t, types.NodeID(2), all[types.UserID(2)].ID()) + }) + + t.Run("tagged node indexed under UserID(0)", func(t *testing.T) { store := NewNodeStore(nil, allowAllPeersFunc, TestBatchSize, TestBatchTimeout) store.Start() @@ -1374,6 +1374,10 @@ func TestGetNodeByMachineKeyAnyUserDeterministic(t *testing.T) { store.PutNode(owned) store.PutNode(tagged) - assertStablePick(t, store, 3) + all := store.GetNodesByMachineKeyAllUsers(mk) + require.Len(t, all, 2) + require.Equal(t, types.NodeID(1), all[types.UserID(1)].ID()) + require.True(t, all[types.UserID(0)].IsTagged()) + require.Equal(t, types.NodeID(3), all[types.UserID(0)].ID()) }) } diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index 94acac73f..edbed2e5d 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -118,6 +118,13 @@ var ErrRegistrationExpired = errors.New("registration expired") // binding. var ErrNodeKeyInUse = errors.New("node key already in use by another machine") +// ErrAmbiguousNodeOwnership is returned when a machine key maps to a set of +// nodes from which the correct one to update or convert cannot be determined: +// multiple user-owned candidates for a tagged conversion, or a tagged node and +// a user-owned node coexisting (impossible per validateNodeOwnership). The +// registration is rejected rather than mutating an arbitrarily-picked node. +var ErrAmbiguousNodeOwnership = errors.New("machine key maps to ambiguous node ownership") + // sshCheckPair identifies a (source, destination) node pair for // SSH check auth tracking. type sshCheckPair struct { @@ -745,12 +752,11 @@ func (s *State) GetNodeByNodeKey(nodeKey key.NodePublic) (types.NodeView, bool) return s.nodeStore.GetNodeByNodeKey(nodeKey) } -// GetNodeByMachineKey retrieves a node by its machine key and user ID. -// The bool indicates if the node exists or is available (like "err not found"). -// The NodeView might be invalid, so it must be checked with .Valid(), which must be used to ensure -// it isn't an invalid node (this is more of a node error or node is broken). -func (s *State) GetNodeByMachineKey(machineKey key.MachinePublic, userID types.UserID) (types.NodeView, bool) { - return s.nodeStore.GetNodeByMachineKey(machineKey, userID) +// GetNodesByMachineKeyAllUsers returns every node sharing the machine key, +// keyed by owning UserID (tagged nodes under UserID(0)). See +// [NodeStore.GetNodesByMachineKeyAllUsers]. +func (s *State) GetNodesByMachineKeyAllUsers(machineKey key.MachinePublic) map[types.UserID]types.NodeView { + return s.nodeStore.GetNodesByMachineKeyAllUsers(machineKey) } // ResolveNode looks up a node by numeric ID, IPv4/IPv6 address, given @@ -2051,16 +2057,32 @@ func (s *State) HandleNodeFromAuthPath( // resolve to a single node rather than racing the find-then-create section. defer s.lockRegistration(machineKey)() - existingNodeSameUser, _ := s.nodeStore.GetNodeByMachineKey(machineKey, types.UserID(user.ID)) - existingNodeAnyUser, _ := s.nodeStore.GetNodeByMachineKeyAnyUser(machineKey) + all := s.nodeStore.GetNodesByMachineKeyAllUsers(machineKey) - // Named conditions - describe WHAT we found, not HOW we check it - nodeExistsForSameUser := existingNodeSameUser.Valid() - nodeExistsForAnyUser := existingNodeAnyUser.Valid() - existingNodeIsTagged := nodeExistsForAnyUser && existingNodeAnyUser.IsTagged() - existingNodeOwnedByOtherUser := nodeExistsForAnyUser && - !existingNodeIsTagged && - existingNodeAnyUser.UserID().Get() != user.ID + // Named conditions - describe WHAT we found, not HOW we check it. + existingNodeSameUser, nodeExistsForSameUser := all[types.UserID(user.ID)] + + taggedNode, hasTagged := all[0] + existingNodeIsTagged := hasTagged && taggedNode.IsTagged() + + var existingNodeOtherUser types.NodeView + + existingNodeOwnedByOtherUser := false + + for uid, n := range all { + if uid != 0 && uid != types.UserID(user.ID) && !n.IsTagged() { + existingNodeOtherUser = n + existingNodeOwnedByOtherUser = true + } + } + + // A tagged node and a user-owned node cannot legitimately share a machine + // key (validateNodeOwnership enforces tags XOR user ownership). If both are + // present the machine key is in a corrupt/ambiguous state; reject rather + // than converting an arbitrary node and orphaning the other. + if existingNodeIsTagged && (nodeExistsForSameUser || existingNodeOwnedByOtherUser) { + return types.NodeView{}, change.Change{}, ErrAmbiguousNodeOwnership + } // Create logger with common fields for all auth operations logger := log.With(). @@ -2090,7 +2112,7 @@ func (s *State) HandleNodeFromAuthPath( return types.NodeView{}, change.Change{}, err } } else if existingNodeIsTagged { - updateParams.ExistingNode = existingNodeAnyUser + updateParams.ExistingNode = taggedNode updateParams.IsConvertFromTag = true finalNode, err = s.applyAuthNodeUpdate(updateParams) @@ -2098,7 +2120,7 @@ func (s *State) HandleNodeFromAuthPath( return types.NodeView{}, change.Change{}, err } } else if existingNodeOwnedByOtherUser { - oldUser := existingNodeAnyUser.User() + oldUser := existingNodeOtherUser.User() oldUserName := "" if oldUser.Valid() { @@ -2106,14 +2128,14 @@ func (s *State) HandleNodeFromAuthPath( } logger.Info(). - Str(zf.ExistingNodeName, existingNodeAnyUser.Hostname()). - Uint64(zf.ExistingNodeID, existingNodeAnyUser.ID().Uint64()). + Str(zf.ExistingNodeName, existingNodeOtherUser.Hostname()). + Uint64(zf.ExistingNodeID, existingNodeOtherUser.ID().Uint64()). Str(zf.OldUser, oldUserName). Msg("Creating new node for different user (same machine key exists for another user)") finalNode, err = s.createNewNodeFromAuth( logger, user, regData, hostname, hostinfo, - expiry, registrationMethod, existingNodeAnyUser, + expiry, registrationMethod, existingNodeOtherUser, ) if err != nil { return types.NodeView{}, change.Change{}, err @@ -2192,11 +2214,12 @@ func (s *State) createNewNodeFromAuth( func (s *State) findExistingNodeForPAK( machineKey key.MachinePublic, pak *types.PreAuthKey, -) (types.NodeView, bool) { +) (types.NodeView, bool, error) { + all := s.nodeStore.GetNodesByMachineKeyAllUsers(machineKey) + if pak.User != nil { - node, exists := s.nodeStore.GetNodeByMachineKey(machineKey, types.UserID(pak.User.ID)) - if exists { - return node, true + if node, ok := all[types.UserID(pak.User.ID)]; ok { + return node, true, nil } // The node may have been converted to a tagged node since it first @@ -2205,21 +2228,46 @@ func (s *State) findExistingNodeForPAK( // it for re-registration instead of re-validating the spent key or // creating a duplicate node. Re-registration preserves the node's tagged // ownership. See https://github.com/juanfont/headscale/issues/3312. - if node, exists := s.nodeStore.GetNodeByMachineKey(machineKey, 0); exists && node.IsTagged() { - return node, true + if node, ok := all[0]; ok && node.IsTagged() { + return node, true, nil + } + + return types.NodeView{}, false, nil + } + + // A tagged key re-registers the same machine regardless of how it is + // currently owned. An existing tagged node is a plain re-registration. A + // single user-owned node is converted to tagged in place (handled by the + // caller). More than one user-owned node is ambiguous - we cannot know + // which to convert - so reject rather than convert an arbitrary one and + // orphan the rest. + if pak.IsTagged() { + if node, ok := all[0]; ok && node.IsTagged() { + return node, true, nil + } + + var userOwned types.NodeView + + count := 0 + + for uid, node := range all { + if uid != 0 && !node.IsTagged() { + userOwned = node + count++ + } + } + + switch count { + case 0: + return types.NodeView{}, false, nil + case 1: + return userOwned, true, nil + default: + return types.NodeView{}, false, ErrAmbiguousNodeOwnership } } - // A tagged key re-registers the same machine regardless of how that machine - // is currently owned: a tagged node (indexed under UserID(0)) is a plain - // re-registration, while a user-owned node is converted to tagged in place - // (handled by the caller). Match on the machine key alone so neither case - // creates a duplicate node. - if pak.IsTagged() { - return s.nodeStore.GetNodeByMachineKeyAnyUser(machineKey) - } - - return types.NodeView{}, false + return types.NodeView{}, false, nil } //nolint:gocyclo // sequential validation/update/create paths with security-sensitive ordering @@ -2245,7 +2293,10 @@ func (s *State) HandleNodeFromPreAuthKey( return types.TaggedDevices.Name } - existingNodeSameUser, existsSameUser := s.findExistingNodeForPAK(machineKey, pak) + existingNodeSameUser, existsSameUser, err := s.findExistingNodeForPAK(machineKey, pak) + if err != nil { + return types.NodeView{}, change.Change{}, err + } // For existing nodes, skip validation if: // 1. MachineKey matches (cryptographic proof of machine identity) @@ -2455,24 +2506,29 @@ func (s *State) HandleNodeFromPreAuthKey( finalNode = updatedNodeView } else { - // Node does not exist for this user with this machine key - // Check if node exists with this machine key for a different user - existingNodeAnyUser, existsAnyUser := s.nodeStore.GetNodeByMachineKeyAnyUser(machineKey) + // Node does not exist for this user with this machine key. + // For a user-owned key, check whether the machine key is already held + // by a node belonging to a different user (tags-only keys skip this; + // tagged nodes have no owning user). Any such node yields the same + // outcome - create a new node for the new user, do not transfer - so a + // single representative is enough. + var differentUserNode types.NodeView - // For user-owned keys, check if node exists for a different user. - // Tags-only keys (pak.User == nil) skip this check. - // Tagged nodes are also skipped since they have no owning user. - existingIsUserOwned := existsAnyUser && - existingNodeAnyUser.Valid() && - !existingNodeAnyUser.IsTagged() - belongsToDifferentUser := pak.User != nil && - existingIsUserOwned && - existingNodeAnyUser.UserID().Get() != pak.User.ID + belongsToDifferentUser := false + + if pak.User != nil { + for uid, node := range s.nodeStore.GetNodesByMachineKeyAllUsers(machineKey) { + if uid != 0 && !node.IsTagged() && uid != types.UserID(pak.User.ID) { + differentUserNode = node + belongsToDifferentUser = true + } + } + } if belongsToDifferentUser { // Node exists but belongs to a different user. // Create a new node for the new user (do not transfer). - oldUser := existingNodeAnyUser.User() + oldUser := differentUserNode.User() oldUserName := "" if oldUser.Valid() { @@ -2481,8 +2537,8 @@ func (s *State) HandleNodeFromPreAuthKey( log.Info(). Caller(). - Str(zf.ExistingNodeName, existingNodeAnyUser.Hostname()). - Uint64(zf.ExistingNodeID, existingNodeAnyUser.ID().Uint64()). + Str(zf.ExistingNodeName, differentUserNode.Hostname()). + Uint64(zf.ExistingNodeID, differentUserNode.ID().Uint64()). Str(zf.MachineKey, machineKey.ShortString()). Str(zf.OldUser, oldUserName). Str(zf.NewUser, pakUsername()). @@ -2522,7 +2578,7 @@ func (s *State) HandleNodeFromPreAuthKey( Expiry: reqExpiry, RegisterMethod: util.RegisterMethodAuthKey, PreAuthKey: pak, - ExistingNodeForNetinfo: cmp.Or(existingNodeAnyUser, types.NodeView{}), + ExistingNodeForNetinfo: cmp.Or(differentUserNode, types.NodeView{}), }) if err != nil { return types.NodeView{}, change.Change{}, fmt.Errorf("creating new node: %w", err) From 68a6d3cf17f62002be1bca436ade474cb3551440 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 15 Jun 2026 14:43:37 +0000 Subject: [PATCH 013/127] db: drop ambiguous machine-key getter, match precisely in test helper The First()-by-machine-key getter returned an undefined node when a machine key mapped to several nodes. It was used only by RegisterNodeForTest; match on (machine_key, user_id) there instead and remove the getter. Updates #3312 --- hscontrol/db/node.go | 37 ++++++++++--------------------------- 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/hscontrol/db/node.go b/hscontrol/db/node.go index fd4c8f125..192f05ade 100644 --- a/hscontrol/db/node.go +++ b/hscontrol/db/node.go @@ -143,27 +143,6 @@ func GetNodeByID(tx *gorm.DB, id types.NodeID) (*types.Node, error) { return &mach, nil } -func (hsdb *HSDatabase) GetNodeByMachineKey(machineKey key.MachinePublic) (*types.Node, error) { - return GetNodeByMachineKey(hsdb.DB, machineKey) -} - -// GetNodeByMachineKey finds a [types.Node] by its [key.MachinePublic] and returns the [types.Node] struct. -func GetNodeByMachineKey( - tx *gorm.DB, - machineKey key.MachinePublic, -) (*types.Node, error) { - mach := types.Node{} - if result := tx. - Preload("AuthKey"). - Preload("AuthKey.User"). - Preload("User"). - First(&mach, "machine_key = ?", machineKey.String()); result.Error != nil { - return nil, result.Error - } - - return &mach, nil -} - func (hsdb *HSDatabase) GetNodeByNodeKey(nodeKey key.NodePublic) (*types.Node, error) { return GetNodeByNodeKey(hsdb.DB, nodeKey) } @@ -297,12 +276,16 @@ func RegisterNodeForTest(tx *gorm.DB, node types.Node, ipv4 *netip.Addr, ipv6 *n logEvent.Msg("registering test node") - // If the a new node is registered with the same machine key, to the same user, - // update the existing node. - // If the same node is registered again, but to a new user, then that is considered - // a new node. - oldNode, _ := GetNodeByMachineKey(tx, node.MachineKey) - if oldNode != nil && oldNode.UserID == node.UserID { + // Reuse the existing node's identity only when the same machine + // re-registers for the same user; a different user is a new node. Match on + // (machine_key, user_id) precisely - a machine key can map to several nodes + // (one per user), so a machine-key-only lookup would be ambiguous. + var oldNode types.Node + + err := tx. + Where("machine_key = ? AND user_id = ?", node.MachineKey.String(), node.UserID). + First(&oldNode).Error + if err == nil { node.ID = oldNode.ID node.GivenName = oldNode.GivenName node.ApprovedRoutes = oldNode.ApprovedRoutes From b0c221f3a167505f4337522ba16958701283f69d Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 17 Jun 2026 11:06:12 +0200 Subject: [PATCH 014/127] changelog: set 0.29 date Signed-off-by: Kristoffer Dalby --- CHANGELOG.md | 6 +++++- mkdocs.yml | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4702bc005..8580865a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # CHANGELOG -## 0.29.0 (202x-xx-xx) +## 0.30.0 (202x-xx-xx) + +**Minimum supported Tailscale client version: v1.xx.0** + +## 0.29.0 (2026-06-17) **Minimum supported Tailscale client version: v1.80.0** diff --git a/mkdocs.yml b/mkdocs.yml index 694505237..18b6fc932 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -111,7 +111,7 @@ extra: - icon: fontawesome/brands/discord link: https://discord.gg/c84AZQhmpx headscale: - version: 0.28.0 + version: 0.29.0 # Extensions markdown_extensions: From 84168fb90a41aae28dba2984512ac37d8ac02c14 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 15 Jun 2026 06:17:02 +0000 Subject: [PATCH 015/127] templates: remove dead style helpers and deprecated shims --- hscontrol/templates/design.go | 420 ++++----------------------------- hscontrol/templates/general.go | 38 --- 2 files changed, 48 insertions(+), 410 deletions(-) diff --git a/hscontrol/templates/design.go b/hscontrol/templates/design.go index da9cda51f..7c9194b0c 100644 --- a/hscontrol/templates/design.go +++ b/hscontrol/templates/design.go @@ -10,41 +10,6 @@ import ( // These constants define the visual language for all Headscale HTML templates. // They ensure consistency across all pages and make it easy to maintain and update the design. -// Color System -// EXTRACTED FROM: https://headscale.net/stable/assets/stylesheets/main.342714a4.min.css -// Material for MkDocs design system - exact values from official docs. -const ( - // Text colors - from --md-default-fg-color CSS variables. - colorTextPrimary = "#000000de" //nolint:unused // rgba(0,0,0,0.87) - Body text - colorTextSecondary = "#0000008a" //nolint:unused // rgba(0,0,0,0.54) - Headings (--md-default-fg-color--light) - colorTextTertiary = "#00000052" //nolint:unused // rgba(0,0,0,0.32) - Lighter text - colorTextLightest = "#00000012" //nolint:unused // rgba(0,0,0,0.07) - Lightest text - - // Code colors - from --md-code-* CSS variables. - colorCodeFg = "#36464e" //nolint:unused // Code text color (--md-code-fg-color) - colorCodeBg = "#f5f5f5" //nolint:unused // Code background (--md-code-bg-color) - - // Border colors. - colorBorderLight = "#e5e7eb" //nolint:unused // Light borders - colorBorderMedium = "#d1d5db" //nolint:unused // Medium borders - - // Background colors. - colorBackgroundPage = "#ffffff" //nolint:unused // Page background - colorBackgroundCard = "#ffffff" //nolint:unused // Card/content background - - // Accent colors - from --md-primary/accent-fg-color. - colorPrimaryAccent = "#4051b5" //nolint:unused // Primary accent (links) - colorAccent = "#526cfe" //nolint:unused // Secondary accent - - // Success colors. - colorSuccess = "#059669" //nolint:unused // Success states - colorSuccessLight = "#d1fae5" //nolint:unused // Success backgrounds - - // Error colors. - colorError = "#dc2626" //nolint:unused // Error states (red-600) - colorErrorLight = "#fee2e2" //nolint:unused // Error backgrounds (red-100) -) - // Spacing System // Based on 4px/8px base unit for consistent rhythm. // Uses rem units for scalability with user font size preferences. @@ -60,278 +25,23 @@ const ( // Shared CSS value constants used across templates. const ( - cssBorderHS = "1px solid var(--hs-border)" //nolint:unused // Shared HS border - cssBreakWord = "break-word" //nolint:unused // Word wrapping - cssCenter = "center" //nolint:unused // Center alignment - cssOverflowWrap = "overflow-wrap" //nolint:unused // CSS property key + cssBorderHS = "1px solid var(--hs-border)" //nolint:unused // Shared HS border + cssCenter = "center" //nolint:unused // Center alignment ) // Typography System // EXTRACTED FROM: https://headscale.net/stable/assets/stylesheets/main.342714a4.min.css // Material for MkDocs typography - exact values from .md-typeset CSS. const ( - // Font families - from CSS custom properties. - fontFamilySystem = `"Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif` //nolint:unused - fontFamilyCode = `"Roboto Mono", "SF Mono", Monaco, "Cascadia Code", Consolas, "Courier New", monospace` //nolint:unused - // Font sizes - from .md-typeset CSS rules. - fontSizeBase = "0.8rem" //nolint:unused // 12.8px - Base text (.md-typeset) - fontSizeH1 = "2em" //nolint:unused // 2x base - Main headings - fontSizeH2 = "1.5625em" //nolint:unused // 1.5625x base - Section headings - fontSizeH3 = "1.25em" //nolint:unused // 1.25x base - Subsection headings - fontSizeSmall = "0.8em" //nolint:unused // 0.8x base - Small text - fontSizeCode = "0.85em" //nolint:unused // 0.85x base - Inline code + fontSizeBase = "0.8rem" //nolint:unused // 12.8px - Base text (.md-typeset) + fontSizeH3 = "1.25em" //nolint:unused // 1.25x base - Subsection headings + fontSizeSmall = "0.8em" //nolint:unused // 0.8x base - Small text // Line heights - from .md-typeset CSS rules. lineHeightBase = "1.6" //nolint:unused // Body text (.md-typeset) - lineHeightH1 = "1.3" //nolint:unused // H1 headings - lineHeightH2 = "1.4" //nolint:unused // H2 headings - lineHeightH3 = "1.5" //nolint:unused // H3 headings - lineHeightCode = "1.4" //nolint:unused // Code blocks (pre) ) -// Responsive Container Component -// Creates a centered container with responsive padding and max-width. -// Mobile-first approach: starts at 100% width with padding, constrains on larger screens. -// -//nolint:unused // Reserved for future use in Phase 4. -func responsiveContainer(children ...elem.Node) *elem.Element { - return elem.Div(attrs.Props{ - attrs.Style: styles.Props{ - styles.Width: "100%", - styles.MaxWidth: "min(800px, 90vw)", // Responsive: 90% of viewport or 800px max - styles.Margin: "0 auto", // Center horizontally - styles.Padding: "clamp(1rem, 5vw, 2.5rem)", // Fluid padding: 16px to 40px - }.ToInline(), - }, children...) -} - -// Card Component -// Reusable card for grouping related content with visual separation. -// Parameters: -// - title: Optional title for the card (empty string for no title) -// - children: Content elements to display in the card -// -//nolint:unused // Reserved for future use in Phase 4. -func card(title string, children ...elem.Node) *elem.Element { - cardContent := children - if title != "" { - cardContent = append([]elem.Node{ - elem.H3(attrs.Props{ - attrs.Style: styles.Props{ - styles.MarginTop: "0", - styles.MarginBottom: spaceM, - }.ToInline(), - }, elem.Text(title)), - }, children...) - } - - return elem.Div(attrs.Props{ - attrs.Style: styles.Props{ - styles.Background: "var(--hs-bg)", - styles.Border: cssBorderHS, - styles.BorderRadius: spaceS, - styles.Padding: "clamp(1rem, 3vw, 1.5rem)", - styles.MarginBottom: spaceL, - styles.BoxShadow: "0 1px 3px rgba(0,0,0,0.1)", - }.ToInline(), - }, cardContent...) -} - -// Code Block Component -// EXTRACTED FROM: .md-typeset pre CSS rules -// Exact styling from Material for MkDocs documentation. -// -//nolint:unused // Used across apple.go, windows.go, register_web.go templates. -func codeBlock(code string) *elem.Element { - return elem.Pre(attrs.Props{ - attrs.Style: styles.Props{ - styles.Display: "block", - styles.Padding: "0.77em 1.18em", // From .md-typeset pre - styles.Border: "none", // No border in original - styles.BorderRadius: "0.1rem", // From .md-typeset code - styles.BackgroundColor: colorCodeBg, // #f5f5f5 - styles.FontFamily: fontFamilyCode, // Roboto Mono - styles.FontSize: fontSizeCode, // 0.85em - styles.LineHeight: lineHeightCode, // 1.4 - styles.OverflowX: "auto", // Horizontal scroll - cssOverflowWrap: cssBreakWord, // Word wrapping - "word-wrap": cssBreakWord, // Legacy support - styles.WhiteSpace: "pre-wrap", // Preserve whitespace - styles.MarginTop: spaceM, // 1em - styles.MarginBottom: spaceM, // 1em - styles.Color: colorCodeFg, // #36464e - styles.BoxShadow: "none", // No shadow in original - }.ToInline(), - }, - elem.Code(nil, elem.Text(code)), - ) -} - -// Base Typeset Styles -// Returns inline styles for the main content container that matches .md-typeset. -// EXTRACTED FROM: .md-typeset CSS rule from Material for MkDocs. -// -//nolint:unused // Used in general.go for mdTypesetBody. -func baseTypesetStyles() styles.Props { - return styles.Props{ - styles.FontSize: fontSizeBase, // 0.8rem - styles.LineHeight: lineHeightBase, // 1.6 - styles.Color: colorTextPrimary, - styles.FontFamily: fontFamilySystem, - cssOverflowWrap: cssBreakWord, - styles.TextAlign: "left", - } -} - -// H1 Styles -// Returns inline styles for H1 headings that match .md-typeset h1. -// EXTRACTED FROM: .md-typeset h1 CSS rule from Material for MkDocs. -// -//nolint:unused // Used across templates for main headings. -func h1Styles() styles.Props { - return styles.Props{ - styles.Color: colorTextSecondary, // rgba(0, 0, 0, 0.54) - styles.FontSize: fontSizeH1, // 2em - styles.LineHeight: lineHeightH1, // 1.3 - styles.Margin: "0 0 1.25em", - styles.FontWeight: "300", - "letter-spacing": "-0.01em", - styles.FontFamily: fontFamilySystem, // Roboto - cssOverflowWrap: cssBreakWord, - } -} - -// H2 Styles -// Returns inline styles for H2 headings that match .md-typeset h2. -// EXTRACTED FROM: .md-typeset h2 CSS rule from Material for MkDocs. -// -//nolint:unused // Used across templates for section headings. -func h2Styles() styles.Props { - return styles.Props{ - styles.FontSize: fontSizeH2, // 1.5625em - styles.LineHeight: lineHeightH2, // 1.4 - styles.Margin: "1.6em 0 0.64em", - styles.FontWeight: "300", - "letter-spacing": "-0.01em", - styles.Color: colorTextSecondary, // rgba(0, 0, 0, 0.54) - styles.FontFamily: fontFamilySystem, // Roboto - cssOverflowWrap: cssBreakWord, - } -} - -// H3 Styles -// Returns inline styles for H3 headings that match .md-typeset h3. -// EXTRACTED FROM: .md-typeset h3 CSS rule from Material for MkDocs. -// -//nolint:unused // Used across templates for subsection headings. -func h3Styles() styles.Props { - return styles.Props{ - styles.FontSize: fontSizeH3, // 1.25em - styles.LineHeight: lineHeightH3, // 1.5 - styles.Margin: "1.6em 0 0.8em", - styles.FontWeight: "400", - "letter-spacing": "-0.01em", - styles.Color: colorTextSecondary, // rgba(0, 0, 0, 0.54) - styles.FontFamily: fontFamilySystem, // Roboto - cssOverflowWrap: cssBreakWord, - } -} - -// Paragraph Styles -// Returns inline styles for paragraphs that match .md-typeset p. -// EXTRACTED FROM: .md-typeset p CSS rule from Material for MkDocs. -// -//nolint:unused // Used for consistent paragraph spacing. -func paragraphStyles() styles.Props { - return styles.Props{ - styles.Margin: "1em 0", - styles.FontFamily: fontFamilySystem, // Roboto - styles.FontSize: fontSizeBase, // 0.8rem - inherited from .md-typeset - styles.LineHeight: lineHeightBase, // 1.6 - inherited from .md-typeset - styles.Color: colorTextPrimary, // rgba(0, 0, 0, 0.87) - cssOverflowWrap: cssBreakWord, - } -} - -// Ordered List Styles -// Returns inline styles for ordered lists that match .md-typeset ol. -// EXTRACTED FROM: .md-typeset ol CSS rule from Material for MkDocs. -// -//nolint:unused // Used for numbered instruction lists. -func orderedListStyles() styles.Props { - return styles.Props{ - styles.MarginBottom: "1em", - styles.MarginTop: "1em", - styles.PaddingLeft: "2em", - styles.FontFamily: fontFamilySystem, // Roboto - inherited from .md-typeset - styles.FontSize: fontSizeBase, // 0.8rem - inherited from .md-typeset - styles.LineHeight: lineHeightBase, // 1.6 - inherited from .md-typeset - styles.Color: colorTextPrimary, // rgba(0, 0, 0, 0.87) - inherited from .md-typeset - cssOverflowWrap: cssBreakWord, - } -} - -// Unordered List Styles -// Returns inline styles for unordered lists that match .md-typeset ul. -// EXTRACTED FROM: .md-typeset ul CSS rule from Material for MkDocs. -// -//nolint:unused // Used for bullet point lists. -func unorderedListStyles() styles.Props { - return styles.Props{ - styles.MarginBottom: "1em", - styles.MarginTop: "1em", - styles.PaddingLeft: "2em", - styles.FontFamily: fontFamilySystem, // Roboto - inherited from .md-typeset - styles.FontSize: fontSizeBase, // 0.8rem - inherited from .md-typeset - styles.LineHeight: lineHeightBase, // 1.6 - inherited from .md-typeset - styles.Color: colorTextPrimary, // rgba(0, 0, 0, 0.87) - inherited from .md-typeset - cssOverflowWrap: cssBreakWord, - } -} - -// Link Styles -// Returns inline styles for links that match .md-typeset a. -// EXTRACTED FROM: .md-typeset a CSS rule from Material for MkDocs. -// Note: Hover states cannot be implemented with inline styles. -// -//nolint:unused // Used for text links. -func linkStyles() styles.Props { - return styles.Props{ - styles.Color: colorPrimaryAccent, // #4051b5 - var(--md-primary-fg-color) - styles.TextDecoration: "none", - "word-break": cssBreakWord, - styles.FontFamily: fontFamilySystem, // Roboto - inherited from .md-typeset - } -} - -// Inline Code Styles (updated) -// Returns inline styles for inline code that matches .md-typeset code. -// EXTRACTED FROM: .md-typeset code CSS rule from Material for MkDocs. -// -//nolint:unused // Used for inline code snippets. -func inlineCodeStyles() styles.Props { - return styles.Props{ - styles.BackgroundColor: colorCodeBg, // #f5f5f5 - styles.Color: colorCodeFg, // #36464e - styles.BorderRadius: "0.1rem", - styles.FontSize: fontSizeCode, // 0.85em - styles.FontFamily: fontFamilyCode, // Roboto Mono - styles.Padding: "0 0.2941176471em", - "word-break": cssBreakWord, - } -} - -// Inline Code Component -// For inline code snippets within text. -// -//nolint:unused // Reserved for future inline code usage. -func inlineCode(code string) *elem.Element { - return elem.Code(attrs.Props{ - attrs.Style: inlineCodeStyles().ToInline(), - }, elem.Text(code)) -} - // orDivider creates a visual "or" divider between sections. // Styled with lines on either side for better visual separation. // @@ -343,16 +53,17 @@ func orDivider() *elem.Element { styles.BackgroundColor: "var(--hs-border)", }.ToInline() - return elem.Div(attrs.Props{ - attrs.Style: styles.Props{ - styles.Display: "flex", - styles.AlignItems: cssCenter, - styles.Gap: spaceM, - styles.MarginTop: space2XL, - styles.MarginBottom: space2XL, - styles.Width: "100%", - }.ToInline(), - }, + return elem.Div( + attrs.Props{ + attrs.Style: styles.Props{ + styles.Display: "flex", + styles.AlignItems: cssCenter, + styles.Gap: spaceM, + styles.MarginTop: space2XL, + styles.MarginBottom: space2XL, + styles.Width: "100%", + }.ToInline(), + }, elem.Div(attrs.Props{attrs.Style: lineStyle}), elem.Strong(attrs.Props{ attrs.Style: styles.Props{ @@ -389,7 +100,8 @@ func successBox(heading string, children ...elem.Node) *elem.Element { "aria-live": "polite", }, checkboxIcon(), - elem.Div(nil, + elem.Div( + nil, append([]elem.Node{ elem.Strong(attrs.Props{ attrs.Style: styles.Props{ @@ -434,7 +146,8 @@ func errorBox(heading string, children ...elem.Node) *elem.Element { "aria-live": "assertive", }, errorIcon(), - elem.Div(nil, + elem.Div( + nil, append([]elem.Node{ elem.Strong(attrs.Props{ attrs.Style: styles.Props{ @@ -462,22 +175,24 @@ func errorIcon() elem.Node { // //nolint:unused // Used in apple.go template. func warningBox(title, message string) *elem.Element { - return elem.Div(attrs.Props{ - attrs.Style: styles.Props{ - styles.Display: "flex", - styles.AlignItems: "flex-start", - styles.Gap: spaceM, - styles.Padding: spaceL, - styles.BackgroundColor: "var(--hs-warning-bg)", - styles.Border: "1px solid var(--hs-warning-border)", - styles.BorderRadius: spaceS, - styles.MarginTop: spaceL, - styles.MarginBottom: spaceL, - }.ToInline(), - attrs.Role: "note", - }, + return elem.Div( + attrs.Props{ + attrs.Style: styles.Props{ + styles.Display: "flex", + styles.AlignItems: "flex-start", + styles.Gap: spaceM, + styles.Padding: spaceL, + styles.BackgroundColor: "var(--hs-warning-bg)", + styles.Border: "1px solid var(--hs-warning-border)", + styles.BorderRadius: spaceS, + styles.MarginTop: spaceL, + styles.MarginBottom: spaceL, + }.ToInline(), + attrs.Role: "note", + }, elem.Raw(``), - elem.Div(nil, + elem.Div( + nil, elem.Strong(attrs.Props{ attrs.Style: styles.Props{ styles.Display: "block", @@ -528,35 +243,23 @@ func externalLink(href, text string) *elem.Element { }, elem.Text(text)) } -// Instruction Step Component -// For numbered instruction lists with consistent formatting. -// -//nolint:unused // Reserved for future use in Phase 4. -func instructionStep(_ int, text string) *elem.Element { - return elem.Li(attrs.Props{ - attrs.Style: styles.Props{ - styles.MarginBottom: spaceS, - styles.LineHeight: lineHeightBase, - }.ToInline(), - }, elem.Text(text)) -} - // detailsBox creates a collapsible
/ section. // Styled to match the card/box component family (border, radius, CSS variables). // Collapsed by default; the user clicks the summary to expand. // //nolint:unused // Used in ping.go template. func detailsBox(summary string, children ...elem.Node) *elem.Element { - return elem.Details(attrs.Props{ - attrs.Style: styles.Props{ - styles.Background: "var(--hs-bg)", - styles.Border: cssBorderHS, - styles.BorderRadius: spaceS, - styles.Padding: spaceS + " " + spaceM, - styles.MarginTop: spaceL, - styles.MarginBottom: spaceL, - }.ToInline(), - }, + return elem.Details( + attrs.Props{ + attrs.Style: styles.Props{ + styles.Background: "var(--hs-bg)", + styles.Border: cssBorderHS, + styles.BorderRadius: spaceS, + styles.Padding: spaceS + " " + spaceM, + styles.MarginTop: spaceL, + styles.MarginBottom: spaceL, + }.ToInline(), + }, elem.Summary(attrs.Props{ attrs.Style: styles.Props{ "cursor": "pointer", @@ -573,30 +276,3 @@ func detailsBox(summary string, children ...elem.Node) *elem.Element { }, children...), ) } - -// Status Message Component -// For displaying success/error/info messages with appropriate styling. -// -//nolint:unused // Reserved for future use in Phase 4. -func statusMessage(message string, isSuccess bool) *elem.Element { - bgColor := colorSuccessLight - textColor := colorSuccess - - if !isSuccess { - bgColor = colorErrorLight - textColor = colorError - } - - return elem.Div(attrs.Props{ - attrs.Style: styles.Props{ - styles.Padding: spaceM, - styles.BackgroundColor: bgColor, - styles.Color: textColor, - styles.BorderRadius: spaceS, - styles.Border: "1px solid " + textColor, - styles.MarginBottom: spaceL, - styles.FontSize: fontSizeBase, - styles.LineHeight: lineHeightBase, - }.ToInline(), - }, elem.Text(message)) -} diff --git a/hscontrol/templates/general.go b/hscontrol/templates/general.go index a9e1ac6eb..d1949ac77 100644 --- a/hscontrol/templates/general.go +++ b/hscontrol/templates/general.go @@ -86,35 +86,6 @@ func PreCode(code string) *elem.Element { return elem.Code(nil, elem.Text(code)) } -// Deprecated: use [H1], [H2], [H3] instead -func headerOne(text string) *elem.Element { - return H1(elem.Text(text)) -} - -// Deprecated: use [H1], [H2], [H3] instead -func headerTwo(text string) *elem.Element { - return H2(elem.Text(text)) -} - -// Deprecated: use [H1], [H2], [H3] instead -func headerThree(text string) *elem.Element { - return H3(elem.Text(text)) -} - -// contentContainer wraps page content with proper width. -// Content inside is left-aligned by default. -func contentContainer(children ...elem.Node) *elem.Element { - containerStyle := styles.Props{ - styles.MaxWidth: "720px", - styles.Width: "100%", - styles.Display: "flex", - styles.FlexDirection: "column", - styles.AlignItems: "flex-start", // Left-align all children - } - - return elem.Div(attrs.Props{attrs.Style: containerStyle.ToInline()}, children...) -} - // headscaleLogo returns the Headscale SVG logo for consistent branding across all pages. // The logo is styled by the .headscale-logo CSS class. func headscaleLogo() elem.Node { @@ -142,15 +113,6 @@ func pageFooter() *elem.Element { ) } -// listStyle provides consistent styling for ordered and unordered lists -// EXTRACTED FROM: .md-typeset ol, .md-typeset ul CSS rules -var listStyle = styles.Props{ - styles.LineHeight: lineHeightBase, // 1.6 - From .md-typeset - styles.MarginTop: "1em", // From CSS: margin-top: 1em - styles.MarginBottom: "1em", // From CSS: margin-bottom: 1em - styles.PaddingLeft: "clamp(1.5rem, 5vw, 2.5rem)", // Responsive indentation -} - // HtmlStructure creates a complete HTML document structure with proper meta tags // and semantic HTML5 structure. The head and body elements are passed as parameters // to allow for customization of each page. From c8bd4a0947706c74edb060ef52a95e151bbba63e Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 15 Jun 2026 06:22:22 +0000 Subject: [PATCH 016/127] types: remove dead types, constants, and methods --- hscontrol/types/api_key.go | 8 +-- hscontrol/types/common.go | 134 ------------------------------------- hscontrol/types/config.go | 34 ---------- hscontrol/types/const.go | 1 - hscontrol/types/node.go | 4 -- hscontrol/types/routes.go | 3 - hscontrol/types/version.go | 1 - 7 files changed, 2 insertions(+), 183 deletions(-) diff --git a/hscontrol/types/api_key.go b/hscontrol/types/api_key.go index f58a9f31f..552acd8b3 100644 --- a/hscontrol/types/api_key.go +++ b/hscontrol/types/api_key.go @@ -9,12 +9,8 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) -const ( - // NewAPIKeyPrefixLength is the length of the prefix for new API keys. - NewAPIKeyPrefixLength = 12 - // LegacyAPIKeyPrefixLength is the length of the prefix for legacy API keys. - LegacyAPIKeyPrefixLength = 7 -) +// NewAPIKeyPrefixLength is the length of the prefix for new API keys. +const NewAPIKeyPrefixLength = 12 // APIKey describes the datamodel for API keys used to remotely authenticate with // headscale. diff --git a/hscontrol/types/common.go b/hscontrol/types/common.go index ee94593ae..3f0802adf 100644 --- a/hscontrol/types/common.go +++ b/hscontrol/types/common.go @@ -12,7 +12,6 @@ import ( "time" "github.com/juanfont/headscale/hscontrol/util" - "tailscale.com/tailcfg" ) const ( @@ -28,139 +27,6 @@ var ( ErrInvalidAuthIDPrefix = errors.New("auth ID has invalid prefix") ) -type StateUpdateType int - -func (su StateUpdateType) String() string { - switch su { - case StateFullUpdate: - return "StateFullUpdate" - case StatePeerChanged: - return "StatePeerChanged" - case StatePeerChangedPatch: - return "StatePeerChangedPatch" - case StatePeerRemoved: - return "StatePeerRemoved" - case StateSelfUpdate: - return "StateSelfUpdate" - case StateDERPUpdated: - return "StateDERPUpdated" - } - - return "unknown state update type" -} - -const ( - StateFullUpdate StateUpdateType = iota - // StatePeerChanged is used for updates that needs - // to be calculated with all peers and all policy rules. - // This would typically be things that include tags, routes - // and similar. - StatePeerChanged - StatePeerChangedPatch - StatePeerRemoved - // StateSelfUpdate is used to indicate that the node - // has changed in control, and the client needs to be - // informed. - // The updated node is inside the ChangeNodes field - // which should have a length of one. - StateSelfUpdate - StateDERPUpdated -) - -// StateUpdate is an internal message containing information about -// a state change that has happened to the network. -// If type is [StateFullUpdate], all fields are ignored. -type StateUpdate struct { - // The type of update - Type StateUpdateType - - // ChangeNodes must be set when Type is StatePeerAdded - // and [StatePeerChanged] and contains the full node - // object for added nodes. - ChangeNodes []NodeID - - // ChangePatches must be set when Type is [StatePeerChangedPatch] - // and contains a populated [tailcfg.PeerChange] object. - ChangePatches []*tailcfg.PeerChange - - // Removed must be set when Type is [StatePeerRemoved] and - // contain a list of the nodes that has been removed from - // the network. - Removed []NodeID - - // DERPMap must be set when Type is [StateDERPUpdated] and - // contain the new DERP Map. - DERPMap *tailcfg.DERPMap - - // Additional message for tracking origin or what being - // updated, useful for ambiguous updates like [StatePeerChanged]. - Message string -} - -// Empty reports if there are any updates in the [StateUpdate]. -func (su *StateUpdate) Empty() bool { - switch su.Type { - case StatePeerChanged: - return len(su.ChangeNodes) == 0 - case StatePeerChangedPatch: - return len(su.ChangePatches) == 0 - case StatePeerRemoved: - return len(su.Removed) == 0 - case StateFullUpdate, StateSelfUpdate, StateDERPUpdated: - // These update types don't have associated data to check, - // so they are never considered empty. - return false - } - - return false -} - -func UpdateFull() StateUpdate { - return StateUpdate{ - Type: StateFullUpdate, - } -} - -func UpdateSelf(nodeID NodeID) StateUpdate { - return StateUpdate{ - Type: StateSelfUpdate, - ChangeNodes: []NodeID{nodeID}, - } -} - -func UpdatePeerChanged(nodeIDs ...NodeID) StateUpdate { - return StateUpdate{ - Type: StatePeerChanged, - ChangeNodes: nodeIDs, - } -} - -func UpdatePeerPatch(changes ...*tailcfg.PeerChange) StateUpdate { - return StateUpdate{ - Type: StatePeerChangedPatch, - ChangePatches: changes, - } -} - -func UpdatePeerRemoved(nodeIDs ...NodeID) StateUpdate { - return StateUpdate{ - Type: StatePeerRemoved, - Removed: nodeIDs, - } -} - -func UpdateExpire(nodeID NodeID, expiry time.Time) StateUpdate { - return StateUpdate{ - Type: StatePeerChangedPatch, - ChangePatches: []*tailcfg.PeerChange{ - { - NodeID: nodeID.NodeID(), - KeyExpiry: &expiry, - }, - }, - } -} - const ( authIDPrefix = "hskey-authreq-" authIDRandomLength = 24 diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 6a6d59dcb..0293918c2 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -1354,26 +1354,6 @@ type deprecator struct { fatals set.Set[string] } -// warnWithAlias will register an alias between the newKey and the oldKey, -// and log a deprecation warning if the oldKey is set. -// -//nolint:unused -func (d *deprecator) warnWithAlias(newKey, oldKey string) { - // NOTE: RegisterAlias is called with NEW KEY -> OLD KEY - viper.RegisterAlias(newKey, oldKey) - - if viper.IsSet(oldKey) { - d.warns.Add( - fmt.Sprintf( - "The %q configuration key is deprecated. Please use %q instead. %q will be removed in the future.", - oldKey, - newKey, - oldKey, - ), - ) - } -} - // fatal deprecates and adds an entry to the fatal list of options if the oldKey is set. func (d *deprecator) fatal(oldKey string) { if viper.IsSet(oldKey) { @@ -1450,20 +1430,6 @@ func (d *deprecator) warnNoAlias(newKey, oldKey string) { } } -// warn deprecates and adds an entry to the warn list of options if the oldKey is set. -// -//nolint:unused -func (d *deprecator) warn(oldKey string) { - if viper.IsSet(oldKey) { - d.warns.Add( - fmt.Sprintf( - "The %q configuration key is deprecated and has been removed. Please see the changelog for more details.", - oldKey, - ), - ) - } -} - func (d *deprecator) String() string { var b strings.Builder diff --git a/hscontrol/types/const.go b/hscontrol/types/const.go index 019c14b62..ba15df528 100644 --- a/hscontrol/types/const.go +++ b/hscontrol/types/const.go @@ -11,6 +11,5 @@ const ( JSONLogFormat = "json" TextLogFormat = "text" - KeepAliveInterval = 60 * time.Second MaxHostnameLength = 255 ) diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index cc0a51d0b..260936b42 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -78,10 +78,6 @@ type ( NodeIDs []NodeID ) -func (n NodeIDs) Len() int { return len(n) } -func (n NodeIDs) Less(i, j int) bool { return n[i] < n[j] } -func (n NodeIDs) Swap(i, j int) { n[i], n[j] = n[j], n[i] } - func (id NodeID) StableID() tailcfg.StableNodeID { return tailcfg.StableNodeID(strconv.FormatUint(uint64(id), util.Base10)) } diff --git a/hscontrol/types/routes.go b/hscontrol/types/routes.go index 5f3958f48..ac54f0043 100644 --- a/hscontrol/types/routes.go +++ b/hscontrol/types/routes.go @@ -26,6 +26,3 @@ type Route struct { // when the server is up. IsPrimary bool } - -// Deprecated: Approval of routes is denormalised onto the relevant node. -type Routes []Route diff --git a/hscontrol/types/version.go b/hscontrol/types/version.go index 62124db1d..ec07a239b 100644 --- a/hscontrol/types/version.go +++ b/hscontrol/types/version.go @@ -50,7 +50,6 @@ var GetVersionInfo = sync.OnceValue(func() *VersionInfo { OS: runtime.GOOS, Arch: runtime.GOARCH, }, - Dirty: false, } buildInfo, ok := buildInfo() From 43ba20bebb287f815d303652b2698966d46beb0c Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 15 Jun 2026 09:59:41 +0000 Subject: [PATCH 017/127] util: remove dead helpers and stale comments --- hscontrol/util/dns.go | 21 ++------------------- hscontrol/util/log.go | 12 ++++-------- hscontrol/util/string.go | 40 ---------------------------------------- 3 files changed, 6 insertions(+), 67 deletions(-) diff --git a/hscontrol/util/dns.go b/hscontrol/util/dns.go index 8c2655b81..f2ec215d7 100644 --- a/hscontrol/util/dns.go +++ b/hscontrol/util/dns.go @@ -150,24 +150,6 @@ func GenerateIPv4DNSRootDomain(ipPrefix netip.Prefix) []dnsname.FQDN { return fqdns } -// generateMagicDNSRootDomains generates a list of DNS entries to be included in [tailcfg.DNSConfig.Routes] in [tailcfg.MapResponse]. -// This list of reverse DNS entries instructs the OS on what subnets and domains the Tailscale embedded DNS -// server (listening in 100.100.100.100 udp/53) should be used for. -// -// Tailscale.com includes in the list: -// - the [types.DNSConfig.BaseDomain] of the user -// - the reverse DNS entry for IPv6 (0.e.1.a.c.5.1.1.a.7.d.f.ip6.arpa., see below more on IPv6) -// - the reverse DNS entries for the IPv4 subnets covered by the user's `IPPrefix`. -// In the public SaaS this is [64-127].100.in-addr.arpa. -// -// The main purpose of this function is then generating the list of IPv4 entries. For the 100.64.0.0/10, this -// is clear, and could be hardcoded. But we are allowing any range as `IPPrefix`, so we need to find out the -// subnets when we have 172.16.0.0/16 (i.e., [0-255].16.172.in-addr.arpa.), or any other subnet. -// -// How IN-ADDR.ARPA domains work is defined in RFC1035 (section 3.5). Tailscale.com seems to adhere to this, -// and do not make use of RFC2317 ("Classless IN-ADDR.ARPA delegation") - hence generating the entries for the next -// class block only. - // GenerateIPv6DNSRootDomain generates the IPv6 reverse DNS root domains. // From the netmask we can find out the wildcard bits (the bits that are not set in the netmask). // This allows us to then calculate the subnets included in the subsequent class block and generate the entries. @@ -192,7 +174,8 @@ func GenerateIPv6DNSRootDomain(ipPrefix netip.Prefix) []dnsname.FQDN { for i := range maskBits / nibbleLen { prefixConstantParts = append( []string{string(nibbleStr[i])}, - prefixConstantParts...) + prefixConstantParts..., + ) } makeDomain := func(variablePrefix ...string) (dnsname.FQDN, error) { diff --git a/hscontrol/util/log.go b/hscontrol/util/log.go index 03de9c344..597767a67 100644 --- a/hscontrol/util/log.go +++ b/hscontrol/util/log.go @@ -32,19 +32,15 @@ type DBLogWrapper struct { } func NewDBLogWrapper(origin *zerolog.Logger, slowThreshold time.Duration, skipErrRecordNotFound bool, parameterizedQueries bool) *DBLogWrapper { - l := &DBLogWrapper{ + return &DBLogWrapper{ Logger: origin, Level: origin.GetLevel(), SlowThreshold: slowThreshold, SkipErrRecordNotFound: skipErrRecordNotFound, ParameterizedQueries: parameterizedQueries, } - - return l } -type DBLogWrapperOption func(*DBLogWrapper) - func (l *DBLogWrapper) LogMode(gormLogger.LogLevel) gormLogger.Interface { return l } @@ -71,16 +67,16 @@ func (l *DBLogWrapper) Trace(ctx context.Context, begin time.Time, fc func() (sq } if err != nil && (!errors.Is(err, gorm.ErrRecordNotFound) || !l.SkipErrRecordNotFound) { - l.Logger.Error().Err(err).Fields(fields).Msgf("") + l.Logger.Error().Err(err).Fields(fields).Msg("") return } if l.SlowThreshold != 0 && elapsed > l.SlowThreshold { - l.Logger.Warn().Fields(fields).Msgf("") + l.Logger.Warn().Fields(fields).Msg("") return } - l.Logger.Debug().Fields(fields).Msgf("") + l.Logger.Debug().Fields(fields).Msg("") } func (l *DBLogWrapper) ParamsFilter(ctx context.Context, sql string, params ...any) (string, []any) { diff --git a/hscontrol/util/string.go b/hscontrol/util/string.go index 2bcb515f0..bed2b8681 100644 --- a/hscontrol/util/string.go +++ b/hscontrol/util/string.go @@ -3,10 +3,7 @@ package util import ( "crypto/rand" "encoding/base64" - "fmt" "strings" - - "tailscale.com/tailcfg" ) // GenerateRandomBytes returns securely generated random bytes. @@ -79,40 +76,3 @@ func MustGenerateRandomStringDNSSafe(size int) string { return hash } - -func TailNodesToString(nodes []*tailcfg.Node) string { - temp := make([]string, len(nodes)) - - for index, node := range nodes { - temp[index] = node.Name - } - - return fmt.Sprintf("[ %s ](%d)", strings.Join(temp, ", "), len(temp)) -} - -func TailMapResponseToString(resp tailcfg.MapResponse) string { - return fmt.Sprintf( - "{ Node: %s, Peers: %s }", - resp.Node.Name, - TailNodesToString(resp.Peers), - ) -} - -func TailcfgFilterRulesToString(rules []tailcfg.FilterRule) string { - var sb strings.Builder - - for index, rule := range rules { - fmt.Fprintf(&sb, ` -{ - SrcIPs: %v - DstIPs: %v -} -`, rule.SrcIPs, rule.DstPorts) - - if index < len(rules)-1 { - sb.WriteString(", ") - } - } - - return fmt.Sprintf("[ %s ](%d)", sb.String(), len(rules)) -} From f49f27adf8f2b1db3599b5318b12e733a65838c2 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 15 Jun 2026 10:10:18 +0000 Subject: [PATCH 018/127] db, sqliteconfig: remove dead and unreachable code --- hscontrol/db/db.go | 6 ------ hscontrol/db/sqliteconfig/config.go | 20 -------------------- hscontrol/db/sqliteconfig/config_test.go | 19 ------------------- hscontrol/db/users.go | 2 -- 4 files changed, 47 deletions(-) diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index 8c088c492..e963373a2 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -981,12 +981,6 @@ func runMigrations(cfg types.DatabaseConfig, dbConn *gorm.DB, migrations *gormig // Add other migration IDs here as they are identified to need FK disabled } - // Get the current foreign key status - var fkOriginallyEnabled int - if err := dbConn.Raw("PRAGMA foreign_keys").Scan(&fkOriginallyEnabled).Error; err != nil { //nolint:noinlineerr - return fmt.Errorf("checking foreign key status: %w", err) - } - // Get all migration IDs in order from the actual migration definitions // Only IDs that are in the migrationsRequiringFKDisabled map will be processed with FK disabled // any other new migrations are ran after. diff --git a/hscontrol/db/sqliteconfig/config.go b/hscontrol/db/sqliteconfig/config.go index b4c807955..711e434ac 100644 --- a/hscontrol/db/sqliteconfig/config.go +++ b/hscontrol/db/sqliteconfig/config.go @@ -101,11 +101,6 @@ func (j JournalMode) IsValid() bool { } } -// String returns the string representation. -func (j JournalMode) String() string { - return string(j) -} - // AutoVacuum represents SQLite auto_vacuum pragma values. // Auto-vacuum controls how SQLite reclaims space from deleted data. // @@ -157,11 +152,6 @@ func (a AutoVacuum) IsValid() bool { } } -// String returns the string representation. -func (a AutoVacuum) String() string { - return string(a) -} - // Synchronous represents SQLite synchronous pragma values. // Synchronous mode controls how aggressively SQLite flushes data to disk. // @@ -221,11 +211,6 @@ func (s Synchronous) IsValid() bool { } } -// String returns the string representation. -func (s Synchronous) String() string { - return string(s) -} - // TxLock represents SQLite transaction lock mode. // Transaction lock mode determines when write locks are acquired during transactions. // @@ -277,11 +262,6 @@ func (t TxLock) IsValid() bool { } } -// String returns the string representation. -func (t TxLock) String() string { - return string(t) -} - // Config holds SQLite database configuration with type-safe enums. // This configuration balances performance, durability, and operational requirements // for Headscale's SQLite database usage patterns. diff --git a/hscontrol/db/sqliteconfig/config_test.go b/hscontrol/db/sqliteconfig/config_test.go index 7829d9e92..9f233902e 100644 --- a/hscontrol/db/sqliteconfig/config_test.go +++ b/hscontrol/db/sqliteconfig/config_test.go @@ -98,25 +98,6 @@ func TestTxLock(t *testing.T) { } } -func TestTxLockString(t *testing.T) { - tests := []struct { - mode TxLock - want string - }{ - {TxLockDeferred, "deferred"}, - {TxLockImmediate, "immediate"}, - {TxLockExclusive, "exclusive"}, - } - - for _, tt := range tests { - t.Run(tt.want, func(t *testing.T) { - if got := tt.mode.String(); got != tt.want { - t.Errorf("TxLock.String() = %q, want %q", got, tt.want) - } - }) - } -} - func TestConfigValidate(t *testing.T) { tests := []struct { name string diff --git a/hscontrol/db/users.go b/hscontrol/db/users.go index 3eee704c9..271c3e3df 100644 --- a/hscontrol/db/users.go +++ b/hscontrol/db/users.go @@ -95,8 +95,6 @@ var ErrCannotChangeOIDCUser = errors.New("cannot edit OIDC user") // RenameUser renames a [types.User]. Returns error if the [types.User] does // not exist or if another [types.User] exists with the new name. func RenameUser(tx *gorm.DB, uid types.UserID, newName string) error { - var err error - oldUser, err := GetUserByID(tx, uid) if err != nil { return err From ed6596f9d7bba89455af033efcc10087a334d4bb Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 15 Jun 2026 09:53:18 +0000 Subject: [PATCH 019/127] policy, mapper, capver, hscontrol: remove dead code --- hscontrol/capver/capver.go | 38 ------------------------- hscontrol/mapper/mapper.go | 6 ---- hscontrol/policy/matcher/export_test.go | 38 +++++++++++++++++++++++++ hscontrol/policy/matcher/matcher.go | 32 --------------------- hscontrol/policy/v2/types.go | 38 ------------------------- hscontrol/tailsql.go | 4 --- 6 files changed, 38 insertions(+), 118 deletions(-) create mode 100644 hscontrol/policy/matcher/export_test.go diff --git a/hscontrol/capver/capver.go b/hscontrol/capver/capver.go index 86a653ddb..31b430447 100644 --- a/hscontrol/capver/capver.go +++ b/hscontrol/capver/capver.go @@ -3,7 +3,6 @@ package capver //go:generate go run ../../tools/capver/main.go import ( - "slices" "sort" "strings" @@ -32,13 +31,6 @@ func tailscaleVersSorted() []string { return vers } -func capVersSorted() []tailcfg.CapabilityVersion { - capVers := xmaps.Keys(capVerToTailscaleVer) - slices.Sort(capVers) - - return capVers -} - // TailscaleVersion returns the Tailscale version for the given [tailcfg.CapabilityVersion]. func TailscaleVersion(ver tailcfg.CapabilityVersion) string { return capVerToTailscaleVer[ver] @@ -66,21 +58,6 @@ func CapabilityVersion(ver string) tailcfg.CapabilityVersion { return 0 } -// TailscaleLatest returns the n latest Tailscale versions. -func TailscaleLatest(n int) []string { - if n <= 0 { - return nil - } - - tsSorted := tailscaleVersSorted() - - if n > len(tsSorted) { - return tsSorted - } - - return tsSorted[len(tsSorted)-n:] -} - // TailscaleLatestMajorMinor returns the n latest Tailscale versions (e.g. 1.80). func TailscaleLatestMajorMinor(n int, stripV bool) []string { if n <= 0 { @@ -107,18 +84,3 @@ func TailscaleLatestMajorMinor(n int, stripV bool) []string { return majorSl[len(majorSl)-n:] } - -// CapVerLatest returns the n latest [tailcfg.CapabilityVersion] values. -func CapVerLatest(n int) []tailcfg.CapabilityVersion { - if n <= 0 { - return nil - } - - s := capVersSorted() - - if n > len(s) { - return s - } - - return s[len(s)-n:] -} diff --git a/hscontrol/mapper/mapper.go b/hscontrol/mapper/mapper.go index a9a430738..af5852ce2 100644 --- a/hscontrol/mapper/mapper.go +++ b/hscontrol/mapper/mapper.go @@ -51,12 +51,6 @@ type mapper struct { created time.Time } -//nolint:unused -type patch struct { - timestamp time.Time - change *tailcfg.PeerChange -} - func newMapper( cfg *types.Config, state *state.State, diff --git a/hscontrol/policy/matcher/export_test.go b/hscontrol/policy/matcher/export_test.go new file mode 100644 index 000000000..c218d51a4 --- /dev/null +++ b/hscontrol/policy/matcher/export_test.go @@ -0,0 +1,38 @@ +package matcher + +import ( + "github.com/juanfont/headscale/hscontrol/util" + "go4.org/netipx" +) + +// MatchFromStrings builds a [Match] from raw source and destination +// strings. Unparseable entries are silently dropped (fail-open): the +// resulting [Match] is narrower than the input described, but never +// wider. Callers that need strict validation should pre-validate +// their inputs via [util.ParseIPSet]. +func MatchFromStrings(sources, destinations []string) Match { + srcs := new(netipx.IPSetBuilder) + dests := new(netipx.IPSetBuilder) + + for _, srcIP := range sources { + set, _ := util.ParseIPSet(srcIP, nil) + + srcs.AddSet(set) + } + + for _, dest := range destinations { + set, _ := util.ParseIPSet(dest, nil) + + dests.AddSet(set) + } + + srcsSet, _ := srcs.IPSet() + destsSet, _ := dests.IPSet() + + match := Match{ + srcs: srcsSet, + dests: destsSet, + } + + return match +} diff --git a/hscontrol/policy/matcher/matcher.go b/hscontrol/policy/matcher/matcher.go index fd5378341..39e7b573c 100644 --- a/hscontrol/policy/matcher/matcher.go +++ b/hscontrol/policy/matcher/matcher.go @@ -80,38 +80,6 @@ func MatchFromFilterRule(rule tailcfg.FilterRule) Match { } } -// MatchFromStrings builds a [Match] from raw source and destination -// strings. Unparseable entries are silently dropped (fail-open): the -// resulting [Match] is narrower than the input described, but never -// wider. Callers that need strict validation should pre-validate -// their inputs via [util.ParseIPSet]. -func MatchFromStrings(sources, destinations []string) Match { - srcs := new(netipx.IPSetBuilder) - dests := new(netipx.IPSetBuilder) - - for _, srcIP := range sources { - set, _ := util.ParseIPSet(srcIP, nil) - - srcs.AddSet(set) - } - - for _, dest := range destinations { - set, _ := util.ParseIPSet(dest, nil) - - dests.AddSet(set) - } - - srcsSet, _ := srcs.IPSet() - destsSet, _ := dests.IPSet() - - match := Match{ - srcs: srcsSet, - dests: destsSet, - } - - return match -} - func (m *Match) SrcsContainsIPs(ips ...netip.Addr) bool { return slices.ContainsFunc(ips, m.srcs.Contains) } diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index 30b06ca13..d38d9cb08 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -1747,44 +1747,6 @@ func (p *Protocol) String() string { return string(*p) } -// Description returns the human-readable description of the [Protocol]. -func (p *Protocol) Description() string { - switch *p { - case ProtocolNameICMP: - return "Internet Control Message Protocol" - case ProtocolNameIGMP: - return "Internet Group Management Protocol" - case ProtocolNameIPv4: - return "IPv4 encapsulation" - case ProtocolNameTCP: - return "Transmission Control Protocol" - case ProtocolNameEGP: - return "Exterior Gateway Protocol" - case ProtocolNameIGP: - return "Interior Gateway Protocol" - case ProtocolNameUDP: - return "User Datagram Protocol" - case ProtocolNameGRE: - return "Generic Routing Encapsulation" - case ProtocolNameESP: - return "Encapsulating Security Payload" - case ProtocolNameAH: - return "Authentication Header" - case ProtocolNameIPv6ICMP: - return "Internet Control Message Protocol for IPv6" - case ProtocolNameSCTP: - return "Stream Control Transmission Protocol" - case ProtocolNameFC: - return "Fibre Channel" - case ProtocolNameIPInIP: - return "IP-in-IP Encapsulation" - case ProtocolNameWildcard: - return "Wildcard (not supported - use specific protocol)" - default: - return "Unknown Protocol" - } -} - // toIANAProtocolNumbers converts a [Protocol] to its IANA protocol numbers. // Since validation happens during [Protocol.UnmarshalJSON], this method should not fail for valid [Protocol] values. func (p *Protocol) toIANAProtocolNumbers() []int { diff --git a/hscontrol/tailsql.go b/hscontrol/tailsql.go index 7f2a52ba1..25c6869a8 100644 --- a/hscontrol/tailsql.go +++ b/hscontrol/tailsql.go @@ -38,9 +38,6 @@ func runTailSQLService(ctx context.Context, logf logger.Logf, stateDir, dbPath s Hostname: opts.Hostname, Logf: logger.Discard, } - // if *doDebugLog { - // tsNode.Logf = logf - // } defer tsNode.Close() logf("Starting tailscale (hostname=%q)", opts.Hostname) @@ -87,7 +84,6 @@ func runTailSQLService(ctx context.Context, logf logger.Logf, stateDir, dbPath s http.Redirect(w, r, target, http.StatusPermanentRedirect) //nolint:gosec // G710: target prefixed by trusted base URL })) }() - // log.Printf("Redirecting HTTP to HTTPS at %q", base) // For the real service, start a separate listener. // Note: Replaces the port 80 listener. From b88a3cb7b2cf2c5c33cf05c1f5407a41fb15c387 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 15 Jun 2026 09:52:08 +0000 Subject: [PATCH 020/127] servertest, hi, integration: remove dead test helpers --- cmd/hi/docker.go | 20 ----- hscontrol/servertest/assertions.go | 119 ----------------------------- hscontrol/servertest/harness.go | 7 -- integration/scenario.go | 2 - 4 files changed, 148 deletions(-) diff --git a/cmd/hi/docker.go b/cmd/hi/docker.go index 1ed8709ce..cdfa775c0 100644 --- a/cmd/hi/docker.go +++ b/cmd/hi/docker.go @@ -769,17 +769,6 @@ func extractContainerArtifacts(ctx context.Context, cli *client.Client, containe return fmt.Errorf("extracting logs: %w", err) } - // Extract tar files for headscale containers only - if strings.HasPrefix(containerName, "hs-") { - err := extractContainerFiles(ctx, cli, containerID, containerName, logsDir, verbose) - if err != nil { - if verbose { - log.Printf("Warning: failed to extract files from %s: %v", containerName, err) - } - // Don't fail the whole extraction if files are missing - } - } - return nil } @@ -827,12 +816,3 @@ func extractContainerLogs(ctx context.Context, cli *client.Client, containerID, return nil } - -// extractContainerFiles extracts database file and directories from headscale containers. -// Note: The actual file extraction is now handled by the integration tests themselves -// via [SaveProfile], [SaveMapResponses], and [SaveDatabase] functions in hsic.go. -func extractContainerFiles(ctx context.Context, cli *client.Client, containerID, containerName, logsDir string, verbose bool) error { - // Files are now extracted directly by the integration tests - // This function is kept for potential future use or other file types - return nil -} diff --git a/hscontrol/servertest/assertions.go b/hscontrol/servertest/assertions.go index 351659cb5..54da6473d 100644 --- a/hscontrol/servertest/assertions.go +++ b/hscontrol/servertest/assertions.go @@ -1,9 +1,7 @@ package servertest import ( - "net/netip" "testing" - "time" ) // AssertMeshComplete verifies that every client in the slice sees @@ -67,72 +65,6 @@ func AssertPeerOnline(tb testing.TB, observer *TestClient, peerName string) { } } -// AssertPeerOffline checks that the observer sees peerName as offline. -func AssertPeerOffline(tb testing.TB, observer *TestClient, peerName string) { - tb.Helper() - - peer, ok := observer.PeerByName(peerName) - if !ok { - // Peer gone entirely counts as "offline" for this assertion. - return - } - - isOnline, known := peer.Online().GetOk() - if known && isOnline { - tb.Errorf("AssertPeerOffline: %s sees peer %s as online, want offline", - observer.Name, peerName) - } -} - -// AssertPeerGone checks that the observer does NOT have peerName in -// its peer list at all. -func AssertPeerGone(tb testing.TB, observer *TestClient, peerName string) { - tb.Helper() - - _, ok := observer.PeerByName(peerName) - if ok { - tb.Errorf("AssertPeerGone: %s still sees peer %s", observer.Name, peerName) - } -} - -// AssertPeerHasAllowedIPs checks that a peer has the expected -// [tailcfg.Node.AllowedIPs] prefixes. -func AssertPeerHasAllowedIPs(tb testing.TB, observer *TestClient, peerName string, want []netip.Prefix) { - tb.Helper() - - peer, ok := observer.PeerByName(peerName) - if !ok { - tb.Errorf("AssertPeerHasAllowedIPs: %s does not see peer %s", observer.Name, peerName) - - return - } - - got := make([]netip.Prefix, 0, peer.AllowedIPs().Len()) - for i := range peer.AllowedIPs().Len() { - got = append(got, peer.AllowedIPs().At(i)) - } - - if len(got) != len(want) { - tb.Errorf("AssertPeerHasAllowedIPs: %s sees %s with AllowedIPs %v, want %v", - observer.Name, peerName, got, want) - - return - } - - // Build a set for comparison. - wantSet := make(map[netip.Prefix]bool, len(want)) - for _, p := range want { - wantSet[p] = true - } - - for _, p := range got { - if !wantSet[p] { - tb.Errorf("AssertPeerHasAllowedIPs: %s sees %s with unexpected AllowedIP %v (want %v)", - observer.Name, peerName, p, want) - } - } -} - // AssertConsistentState checks that all clients agree on peer // properties: every connected client should see the same set of // peer hostnames. @@ -210,54 +142,3 @@ func AssertSelfHasAddresses(tb testing.TB, client *TestClient) { tb.Errorf("AssertSelfHasAddresses: %s self node has no addresses", client.Name) } } - -// EventuallyAssertMeshComplete retries [AssertMeshComplete] up to -// timeout, useful when waiting for state to propagate. -func EventuallyAssertMeshComplete(tb testing.TB, clients []*TestClient, timeout time.Duration) { - tb.Helper() - - expected := len(clients) - 1 - deadline := time.After(timeout) - - for { - allGood := true - - for _, c := range clients { - nm := c.Netmap() - if nm == nil || len(nm.Peers) < expected { - allGood = false - - break - } - } - - if allGood { - // Final strict check. - AssertMeshComplete(tb, clients) - - return - } - - select { - case <-deadline: - // Report the failure with details. - for _, c := range clients { - nm := c.Netmap() - - got := 0 - if nm != nil { - got = len(nm.Peers) - } - - if got != expected { - tb.Errorf("EventuallyAssertMeshComplete: %s has %d peers, want %d (timeout %v)", - c.Name, got, expected, timeout) - } - } - - return - case <-time.After(100 * time.Millisecond): - // Poll again. - } - } -} diff --git a/hscontrol/servertest/harness.go b/hscontrol/servertest/harness.go index 212c5412f..a88aa1183 100644 --- a/hscontrol/servertest/harness.go +++ b/hscontrol/servertest/harness.go @@ -145,13 +145,6 @@ func (h *TestHarness) WaitForMeshComplete(tb testing.TB, timeout time.Duration) } } -// WaitForConvergence waits until all connected clients have a -// non-nil [netmap.NetworkMap] and their peer counts have stabilised. -func (h *TestHarness) WaitForConvergence(tb testing.TB, timeout time.Duration) { - tb.Helper() - h.WaitForMeshComplete(tb, timeout) -} - // ChangePolicy sets an ACL policy on the server and propagates changes // to all connected nodes. The policy should be a valid HuJSON policy document. func (h *TestHarness) ChangePolicy(tb testing.TB, policy []byte) { diff --git a/integration/scenario.go b/integration/scenario.go index 2233cbdf8..52470fb09 100644 --- a/integration/scenario.go +++ b/integration/scenario.go @@ -406,8 +406,6 @@ func (s *Scenario) ShutdownAssertNoPanics(t *testing.T) { } if s.mockOIDC.r != nil { - s.mockOIDC.r.Close() - err := s.mockOIDC.r.Close() if err != nil { log.Printf("tearing down oidc server: %s", err) From 0b7c8aa270978c1996fe8d4a511b9e8359dcf139 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 16 Jun 2026 08:45:59 +0000 Subject: [PATCH 021/127] cli: consolidate gRPC command helpers --- cmd/headscale/cli/auth.go | 59 ++++++++++++------------ cmd/headscale/cli/nodes.go | 5 +- cmd/headscale/cli/policy.go | 78 +++++++++++++++----------------- cmd/headscale/cli/preauthkeys.go | 24 ++++++---- cmd/headscale/cli/users.go | 61 ++++++++++++------------- cmd/headscale/cli/utils.go | 17 +++++++ 6 files changed, 132 insertions(+), 112 deletions(-) diff --git a/cmd/headscale/cli/auth.go b/cmd/headscale/cli/auth.go index 8a5476dd0..85a17a3c9 100644 --- a/cmd/headscale/cli/auth.go +++ b/cmd/headscale/cli/auth.go @@ -50,44 +50,45 @@ var authRegisterCmd = &cobra.Command{ return printOutput( cmd, response.GetNode(), - fmt.Sprintf("Node %s registered", response.GetNode().GetGivenName())) + fmt.Sprintf("Node %s registered", response.GetNode().GetGivenName()), + ) }), } +// authDecisionRunE builds a RunE for an auth decision command (approve or +// reject) that reads the auth-id flag, invokes the given gRPC call, and prints +// the response. errVerb is used in the error message; okMsg is printed on +// success. +func authDecisionRunE[Resp any]( + errVerb, okMsg string, + call func(ctx context.Context, client v1.HeadscaleServiceClient, authID string) (Resp, error), +) func(*cobra.Command, []string) error { + return grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + authID, _ := cmd.Flags().GetString("auth-id") + + response, err := call(ctx, client, authID) + if err != nil { + return fmt.Errorf("%s auth request: %w", errVerb, err) + } + + return printOutput(cmd, response, okMsg) + }) +} + var authApproveCmd = &cobra.Command{ Use: "approve", Short: "Approve a pending authentication request", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - authID, _ := cmd.Flags().GetString("auth-id") - - request := &v1.AuthApproveRequest{ - AuthId: authID, - } - - response, err := client.AuthApprove(ctx, request) - if err != nil { - return fmt.Errorf("approving auth request: %w", err) - } - - return printOutput(cmd, response, "Auth request approved") - }), + RunE: authDecisionRunE("approving", "Auth request approved", + func(ctx context.Context, client v1.HeadscaleServiceClient, authID string) (*v1.AuthApproveResponse, error) { + return client.AuthApprove(ctx, &v1.AuthApproveRequest{AuthId: authID}) + }), } var authRejectCmd = &cobra.Command{ Use: "reject", Short: "Reject a pending authentication request", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - authID, _ := cmd.Flags().GetString("auth-id") - - request := &v1.AuthRejectRequest{ - AuthId: authID, - } - - response, err := client.AuthReject(ctx, request) - if err != nil { - return fmt.Errorf("rejecting auth request: %w", err) - } - - return printOutput(cmd, response, "Auth request rejected") - }), + RunE: authDecisionRunE("rejecting", "Auth request rejected", + func(ctx context.Context, client v1.HeadscaleServiceClient, authID string) (*v1.AuthRejectResponse, error) { + return client.AuthReject(ctx, &v1.AuthRejectRequest{AuthId: authID}) + }), } diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go index 5cf61a43e..4bfe1fb52 100644 --- a/cmd/headscale/cli/nodes.go +++ b/cmd/headscale/cli/nodes.go @@ -84,7 +84,8 @@ var registerNodeCmd = &cobra.Command{ return printOutput( cmd, response.GetNode(), - fmt.Sprintf("Node %s registered", response.GetNode().GetGivenName())) + fmt.Sprintf("Node %s registered", response.GetNode().GetGivenName()), + ) }), } @@ -135,7 +136,7 @@ var listNodeRoutesCmd = &cobra.Command{ } nodes = lo.Filter(nodes, func(n *v1.Node, _ int) bool { - return (n.GetSubnetRoutes() != nil && len(n.GetSubnetRoutes()) > 0) || (n.GetApprovedRoutes() != nil && len(n.GetApprovedRoutes()) > 0) || (n.GetAvailableRoutes() != nil && len(n.GetAvailableRoutes()) > 0) + return len(n.GetSubnetRoutes()) > 0 || len(n.GetApprovedRoutes()) > 0 || len(n.GetAvailableRoutes()) > 0 }) return printListOutput(cmd, nodes, func() error { diff --git a/cmd/headscale/cli/policy.go b/cmd/headscale/cli/policy.go index d6a1f573b..039fedd07 100644 --- a/cmd/headscale/cli/policy.go +++ b/cmd/headscale/cli/policy.go @@ -1,6 +1,7 @@ package cli import ( + "context" "errors" "fmt" "os" @@ -36,6 +37,16 @@ func bypassDatabase() (*db.HSDatabase, error) { return d, nil } +// openBypassDB confirms the destructive bypass action and opens the database +// directly. The caller is responsible for closing the returned handle. +func openBypassDB(cmd *cobra.Command) (*db.HSDatabase, error) { + if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") { + return nil, errAborted + } + + return bypassDatabase() +} + func init() { rootCmd.AddCommand(policyCmd) @@ -66,11 +77,7 @@ var getPolicy = &cobra.Command{ var policyData string if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass { - if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") { - return errAborted - } - - d, err := bypassDatabase() + d, err := openBypassDB(cmd) if err != nil { return err } @@ -83,19 +90,19 @@ var getPolicy = &cobra.Command{ policyData = pol.Data } else { - ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig() - if err != nil { - return fmt.Errorf("connecting to headscale: %w", err) - } - defer cancel() - defer conn.Close() + err := withGRPC(func(ctx context.Context, client v1.HeadscaleServiceClient) error { + response, err := client.GetPolicy(ctx, &v1.GetPolicyRequest{}) + if err != nil { + return fmt.Errorf("loading ACL policy: %w", err) + } - response, err := client.GetPolicy(ctx, &v1.GetPolicyRequest{}) - if err != nil { - return fmt.Errorf("loading ACL policy: %w", err) - } + policyData = response.GetPolicy() - policyData = response.GetPolicy() + return nil + }) + if err != nil { + return err + } } // This does not pass output format as we don't support yaml, json or @@ -122,11 +129,7 @@ var setPolicy = &cobra.Command{ } if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass { - if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") { - return errAborted - } - - d, err := bypassDatabase() + d, err := openBypassDB(cmd) if err != nil { return err } @@ -149,16 +152,16 @@ var setPolicy = &cobra.Command{ } else { request := &v1.SetPolicyRequest{Policy: string(policyBytes)} - ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig() - if err != nil { - return fmt.Errorf("connecting to headscale: %w", err) - } - defer cancel() - defer conn.Close() + err := withGRPC(func(ctx context.Context, client v1.HeadscaleServiceClient) error { + _, err := client.SetPolicy(ctx, request) + if err != nil { + return fmt.Errorf("setting ACL policy: %w", err) + } - _, err = client.SetPolicy(ctx, request) + return nil + }) if err != nil { - return fmt.Errorf("setting ACL policy: %w", err) + return err } } @@ -185,11 +188,7 @@ var checkPolicy = &cobra.Command{ } if bypass, _ := cmd.Flags().GetBool(bypassFlag); bypass { - if !confirmAction(cmd, "DO NOT run this command if an instance of headscale is running, are you sure headscale is not running?") { - return errAborted - } - - d, err := bypassDatabase() + d, err := openBypassDB(cmd) if err != nil { return err } @@ -224,14 +223,11 @@ var checkPolicy = &cobra.Command{ return nil } - ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig() - if err != nil { - return fmt.Errorf("connecting to headscale: %w", err) - } - defer cancel() - defer conn.Close() + err = withGRPC(func(ctx context.Context, client v1.HeadscaleServiceClient) error { + _, err := client.CheckPolicy(ctx, &v1.CheckPolicyRequest{Policy: string(policyBytes)}) - _, err = client.CheckPolicy(ctx, &v1.CheckPolicyRequest{Policy: string(policyBytes)}) + return err + }) if err != nil { return err } diff --git a/cmd/headscale/cli/preauthkeys.go b/cmd/headscale/cli/preauthkeys.go index f88df70c4..7eb8cf51b 100644 --- a/cmd/headscale/cli/preauthkeys.go +++ b/cmd/headscale/cli/preauthkeys.go @@ -128,15 +128,24 @@ var createPreAuthKeyCmd = &cobra.Command{ }), } +// preAuthKeyID reads the required --id flag for preauthkey commands. +func preAuthKeyID(cmd *cobra.Command) (uint64, error) { + id, _ := cmd.Flags().GetUint64("id") + if id == 0 { + return 0, fmt.Errorf("missing --id parameter: %w", errMissingParameter) + } + + return id, nil +} + var expirePreAuthKeyCmd = &cobra.Command{ Use: cmdExpire, Short: "Expire a preauthkey", Aliases: []string{"revoke", aliasExp, "e"}, RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - id, _ := cmd.Flags().GetUint64("id") - - if id == 0 { - return fmt.Errorf("missing --id parameter: %w", errMissingParameter) + id, err := preAuthKeyID(cmd) + if err != nil { + return err } request := &v1.ExpirePreAuthKeyRequest{ @@ -157,10 +166,9 @@ var deletePreAuthKeyCmd = &cobra.Command{ Short: "Delete a preauthkey", Aliases: []string{aliasDel, "rm", "d"}, RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - id, _ := cmd.Flags().GetUint64("id") - - if id == 0 { - return fmt.Errorf("missing --id parameter: %w", errMissingParameter) + id, err := preAuthKeyID(cmd) + if err != nil { + return err } request := &v1.DeletePreAuthKeyRequest{ diff --git a/cmd/headscale/cli/users.go b/cmd/headscale/cli/users.go index bcc8c9139..6cac00519 100644 --- a/cmd/headscale/cli/users.go +++ b/cmd/headscale/cli/users.go @@ -44,6 +44,33 @@ func usernameAndIDFromFlag(cmd *cobra.Command) (uint64, string, error) { return uint64(identifier), username, nil //nolint:gosec // identifier is clamped to >= 0 above } +// resolveSingleUser resolves exactly one user from the --name/--id flags, +// returning the raw flag id and the matched user. +func resolveSingleUser( + ctx context.Context, + client v1.HeadscaleServiceClient, + cmd *cobra.Command, +) (uint64, *v1.User, error) { + id, username, err := usernameAndIDFromFlag(cmd) + if err != nil { + return 0, nil, err + } + + users, err := client.ListUsers(ctx, &v1.ListUsersRequest{ + Name: username, + Id: id, + }) + if err != nil { + return 0, nil, fmt.Errorf("listing users: %w", err) + } + + if len(users.GetUsers()) != 1 { + return 0, nil, errMultipleUsersMatch + } + + return id, users.GetUsers()[0], nil +} + func init() { rootCmd.AddCommand(userCmd) userCmd.AddCommand(createUserCmd) @@ -117,27 +144,11 @@ var destroyUserCmd = &cobra.Command{ Short: "Destroys a user", Aliases: []string{cmdDelete}, RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - id, username, err := usernameAndIDFromFlag(cmd) + _, user, err := resolveSingleUser(ctx, client, cmd) if err != nil { return err } - request := &v1.ListUsersRequest{ - Name: username, - Id: id, - } - - users, err := client.ListUsers(ctx, request) - if err != nil { - return fmt.Errorf("listing users: %w", err) - } - - if len(users.GetUsers()) != 1 { - return errMultipleUsersMatch - } - - user := users.GetUsers()[0] - if !confirmAction(cmd, fmt.Sprintf( "Do you want to remove the user %q (%d) and any associated preauthkeys?", user.GetName(), user.GetId(), @@ -209,25 +220,11 @@ var renameUserCmd = &cobra.Command{ Short: "Renames a user", Aliases: []string{"mv"}, RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - id, username, err := usernameAndIDFromFlag(cmd) + id, _, err := resolveSingleUser(ctx, client, cmd) if err != nil { return err } - listReq := &v1.ListUsersRequest{ - Name: username, - Id: id, - } - - users, err := client.ListUsers(ctx, listReq) - if err != nil { - return fmt.Errorf("listing users: %w", err) - } - - if len(users.GetUsers()) != 1 { - return errMultipleUsersMatch - } - newName, _ := cmd.Flags().GetString("new-name") renameReq := &v1.RenameUserRequest{ diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go index 364779ad5..002147b38 100644 --- a/cmd/headscale/cli/utils.go +++ b/cmd/headscale/cli/utils.go @@ -85,6 +85,23 @@ func grpcRunE( } } +// withGRPC opens a gRPC client, runs fn with it, and tears the +// connection down afterwards. It is the building block for commands +// that branch on a flag before deciding to talk to the server, where +// grpcRunE's whole-RunE wrapping does not fit. +func withGRPC( + fn func(ctx context.Context, client v1.HeadscaleServiceClient) error, +) error { + ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig() + if err != nil { + return fmt.Errorf("connecting to headscale: %w", err) + } + defer cancel() + defer conn.Close() + + return fn(ctx, client) +} + func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *grpc.ClientConn, context.CancelFunc, error) { cfg, err := types.LoadCLIConfig() if err != nil { From b56326427c6ce3a7f4f14b8d904ccc5f5a7f39e5 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 16 Jun 2026 12:07:36 +0000 Subject: [PATCH 022/127] cli: consolidate output and table rendering --- cmd/headscale/cli/api_key.go | 9 +++---- cmd/headscale/cli/preauthkeys.go | 27 ++++++++++----------- cmd/headscale/cli/root.go | 8 +------ cmd/headscale/cli/users.go | 11 ++++----- cmd/headscale/cli/utils.go | 41 ++++++++++++++++++-------------- 5 files changed, 43 insertions(+), 53 deletions(-) diff --git a/cmd/headscale/cli/api_key.go b/cmd/headscale/cli/api_key.go index 5504bbf90..5c6848142 100644 --- a/cmd/headscale/cli/api_key.go +++ b/cmd/headscale/cli/api_key.go @@ -7,7 +7,6 @@ import ( v1 "github.com/juanfont/headscale/gen/go/headscale/v1" "github.com/juanfont/headscale/hscontrol/util" - "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -51,9 +50,7 @@ var listAPIKeys = &cobra.Command{ } return printListOutput(cmd, response.GetApiKeys(), func() error { - tableData := make(pterm.TableData, 1, 1+len(response.GetApiKeys())) - tableData[0] = []string{"ID", "Prefix", colExpiration, colCreated} - + rows := make([][]string, 0, len(response.GetApiKeys())) for _, key := range response.GetApiKeys() { expiration := "-" @@ -61,7 +58,7 @@ var listAPIKeys = &cobra.Command{ expiration = ColourTime(key.GetExpiration().AsTime()) } - tableData = append(tableData, []string{ + rows = append(rows, []string{ strconv.FormatUint(key.GetId(), util.Base10), key.GetPrefix(), expiration, @@ -69,7 +66,7 @@ var listAPIKeys = &cobra.Command{ }) } - return pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + return renderTable([]string{"ID", "Prefix", colExpiration, colCreated}, rows) }) }), } diff --git a/cmd/headscale/cli/preauthkeys.go b/cmd/headscale/cli/preauthkeys.go index 7eb8cf51b..06e2f07fc 100644 --- a/cmd/headscale/cli/preauthkeys.go +++ b/cmd/headscale/cli/preauthkeys.go @@ -8,7 +8,6 @@ import ( v1 "github.com/juanfont/headscale/gen/go/headscale/v1" "github.com/juanfont/headscale/hscontrol/util" - "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -52,18 +51,7 @@ var listPreAuthKeys = &cobra.Command{ } return printListOutput(cmd, response.GetPreAuthKeys(), func() error { - tableData := make(pterm.TableData, 1, 1+len(response.GetPreAuthKeys())) - tableData[0] = []string{ - "ID", - "Key/Prefix", - "Reusable", - "Ephemeral", - "Used", - colExpiration, - colCreated, - "Owner", - } - + rows := make([][]string, 0, len(response.GetPreAuthKeys())) for _, key := range response.GetPreAuthKeys() { expiration := "-" if key.GetExpiration() != nil { @@ -79,7 +67,7 @@ var listPreAuthKeys = &cobra.Command{ owner = "-" } - tableData = append(tableData, []string{ + rows = append(rows, []string{ strconv.FormatUint(key.GetId(), util.Base10), key.GetKey(), strconv.FormatBool(key.GetReusable()), @@ -91,7 +79,16 @@ var listPreAuthKeys = &cobra.Command{ }) } - return pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + return renderTable([]string{ + "ID", + "Key/Prefix", + "Reusable", + "Ephemeral", + "Used", + colExpiration, + colCreated, + "Owner", + }, rows) }) }), } diff --git a/cmd/headscale/cli/root.go b/cmd/headscale/cli/root.go index 36967ed55..212645cec 100644 --- a/cmd/headscale/cli/root.go +++ b/cmd/headscale/cli/root.go @@ -120,13 +120,7 @@ func filterPreReleasesIfStable(versionFunc func() string) func(string) bool { } // If we are on a stable release, filter out pre-releases. - for _, ignore := range prereleases { - if strings.Contains(tag, ignore) { - return true - } - } - - return false + return isPreReleaseVersion(tag) } } diff --git a/cmd/headscale/cli/users.go b/cmd/headscale/cli/users.go index 6cac00519..5639aa82a 100644 --- a/cmd/headscale/cli/users.go +++ b/cmd/headscale/cli/users.go @@ -10,7 +10,6 @@ import ( v1 "github.com/juanfont/headscale/gen/go/headscale/v1" "github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/hscontrol/util/zlog/zf" - "github.com/pterm/pterm" "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) @@ -194,12 +193,10 @@ var listUsersCmd = &cobra.Command{ } return printListOutput(cmd, response.GetUsers(), func() error { - tableData := make(pterm.TableData, 1, 1+len(response.GetUsers())) - - tableData[0] = []string{"ID", "Name", "Username", "Email", colCreated} + rows := make([][]string, 0, len(response.GetUsers())) for _, user := range response.GetUsers() { - tableData = append( - tableData, + rows = append( + rows, []string{ strconv.FormatUint(user.GetId(), util.Base10), user.GetDisplayName(), @@ -210,7 +207,7 @@ var listUsersCmd = &cobra.Command{ ) } - return pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() + return renderTable([]string{"ID", "Name", "Username", "Email", colCreated}, rows) }) }), } diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go index 002147b38..53ae45138 100644 --- a/cmd/headscale/cli/utils.go +++ b/cmd/headscale/cli/utils.go @@ -15,6 +15,7 @@ import ( "github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/hscontrol/util/zlog/zf" "github.com/prometheus/common/model" + "github.com/pterm/pterm" "github.com/rs/zerolog/log" "github.com/spf13/cobra" "google.golang.org/grpc" @@ -161,7 +162,8 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g return nil, nil, nil, nil, errAPIKeyNotSet } - grpcOptions = append(grpcOptions, + grpcOptions = append( + grpcOptions, grpc.WithPerRPCCredentials(tokenAuth{ token: apiKey, }), @@ -175,11 +177,13 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g InsecureSkipVerify: true, } - grpcOptions = append(grpcOptions, + grpcOptions = append( + grpcOptions, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), ) } else { - grpcOptions = append(grpcOptions, + grpcOptions = append( + grpcOptions, grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, "")), ) } @@ -268,9 +272,19 @@ func confirmAction(cmd *cobra.Command, prompt string) bool { return util.YesNo(prompt) } +// renderTable prints a human-readable pterm table with the given header row +// and data rows, using the shared header styling. +func renderTable(header []string, rows [][]string) error { + tableData := make(pterm.TableData, 0, 1+len(rows)) + tableData = append(tableData, header) + tableData = append(tableData, rows...) + + return pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() +} + // printListOutput checks the --output flag: when a machine-readable format is -// requested it serialises data as JSON/YAML; otherwise it calls renderTable -// to produce the human-readable pterm table. +// requested it serialises data as JSON/YAML; otherwise it calls the render +// callback to produce the human-readable pterm table. func printListOutput( cmd *cobra.Command, data any, @@ -292,24 +306,15 @@ func printError(err error, outputFormat string) { Error string `json:"error"` } - e := errOutput{Error: err.Error()} - - var formatted []byte - - switch outputFormat { - case outputFormatJSON: - formatted, _ = json.MarshalIndent(e, "", "\t") //nolint:errchkjson // errOutput contains only a string field - case outputFormatJSONLine: - formatted, _ = json.Marshal(e) //nolint:errchkjson // errOutput contains only a string field - case outputFormatYAML: - formatted, _ = yaml.Marshal(e) - default: + if outputFormat == "" { fmt.Fprintf(os.Stderr, "Error: %s\n", err) return } - fmt.Fprintf(os.Stderr, "%s\n", formatted) + // formatOutput cannot fail here: errOutput is a single string field. + out, _ := formatOutput(errOutput{Error: err.Error()}, "", outputFormat) + fmt.Fprintf(os.Stderr, "%s\n", out) } func hasMachineOutputFlag() bool { From 55b9e3cfdace0a8da7cc358a019d8a9e2dfbfbb7 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 16 Jun 2026 08:45:11 +0000 Subject: [PATCH 023/127] db, sqliteconfig: consolidate query and config helpers --- cmd/headscale/cli/policy.go | 4 +- hscontrol/db/api_key.go | 59 ++----------- hscontrol/db/db.go | 67 ++++----------- hscontrol/db/db_test.go | 2 +- hscontrol/db/node.go | 75 ++++++++--------- hscontrol/db/node_test.go | 2 +- hscontrol/db/preauth_keys.go | 123 ++++++++++++++++------------ hscontrol/db/sqliteconfig/config.go | 55 ++++++------- hscontrol/db/text_serialiser.go | 72 ++++++++-------- hscontrol/db/users.go | 40 +++------ hscontrol/db/users_test.go | 4 +- hscontrol/db/versioncheck.go | 50 +++++------ hscontrol/state/state.go | 4 +- 13 files changed, 232 insertions(+), 325 deletions(-) diff --git a/cmd/headscale/cli/policy.go b/cmd/headscale/cli/policy.go index 039fedd07..094960542 100644 --- a/cmd/headscale/cli/policy.go +++ b/cmd/headscale/cli/policy.go @@ -135,7 +135,7 @@ var setPolicy = &cobra.Command{ } defer d.Close() - users, err := d.ListUsers() + users, err := d.ListUsers(nil) if err != nil { return fmt.Errorf("loading users for policy validation: %w", err) } @@ -194,7 +194,7 @@ var checkPolicy = &cobra.Command{ } defer d.Close() - users, err := d.ListUsers() + users, err := d.ListUsers(nil) if err != nil { return fmt.Errorf("loading users: %w", err) } diff --git a/hscontrol/db/api_key.go b/hscontrol/db/api_key.go index e64648c98..b395c38fb 100644 --- a/hscontrol/db/api_key.go +++ b/hscontrol/db/api_key.go @@ -205,61 +205,20 @@ func validateAPIKey(db *gorm.DB, keyStr string) (*types.APIKey, error) { } // New format: parse and verify - const expectedMinLength = apiKeyPrefixLength + 1 + apiKeyHashLength - if len(prefixAndSecret) < expectedMinLength { - return nil, fmt.Errorf( - "%w: key too short, expected at least %d chars after prefix, got %d", - ErrAPIKeyFailedToParse, - expectedMinLength, - len(prefixAndSecret), - ) - } - - // Use fixed-length parsing - prefix := prefixAndSecret[:apiKeyPrefixLength] - - // Validate separator at expected position - if prefixAndSecret[apiKeyPrefixLength] != '-' { - return nil, fmt.Errorf( - "%w: expected separator '-' at position %d, got '%c'", - ErrAPIKeyFailedToParse, - apiKeyPrefixLength, - prefixAndSecret[apiKeyPrefixLength], - ) - } - - secret := prefixAndSecret[apiKeyPrefixLength+1:] - - // Validate secret length - if len(secret) != apiKeyHashLength { - return nil, fmt.Errorf( - "%w: secret length mismatch, expected %d chars, got %d", - ErrAPIKeyFailedToParse, - apiKeyHashLength, - len(secret), - ) - } - - // Validate prefix contains only base64 URL-safe characters - if !isValidBase64URLSafe(prefix) { - return nil, fmt.Errorf( - "%w: prefix contains invalid characters (expected base64 URL-safe: A-Za-z0-9_-)", - ErrAPIKeyFailedToParse, - ) - } - - // Validate secret contains only base64 URL-safe characters - if !isValidBase64URLSafe(secret) { - return nil, fmt.Errorf( - "%w: secret contains invalid characters (expected base64 URL-safe: A-Za-z0-9_-)", - ErrAPIKeyFailedToParse, - ) + prefix, secret, err := parsePrefixedKey( + prefixAndSecret, + apiKeyPrefixLength, + apiKeyHashLength, + ErrAPIKeyFailedToParse, + ) + if err != nil { + return nil, err } // Look up by prefix (indexed) var key types.APIKey - err := db.First(&key, "prefix = ?", prefix).Error + err = db.First(&key, "prefix = ?", prefix).Error if err != nil { return nil, fmt.Errorf("API key not found: %w", err) } diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index e963373a2..5a18f783b 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -220,7 +220,7 @@ AND auth_key_id NOT IN ( { ID: "202505141324", Migrate: func(tx *gorm.DB) error { - users, err := ListUsers(tx) + users, err := ListUsers(tx, nil) if err != nil { return fmt.Errorf("listing users: %w", err) } @@ -617,7 +617,7 @@ AND auth_key_id NOT IN ( } // 2. Load users and nodes to create PolicyManager - users, err := ListUsers(tx) + users, err := ListUsers(tx, nil) if err != nil { return fmt.Errorf("loading users for RequestTags migration: %w", err) } @@ -970,59 +970,20 @@ func openDB(cfg types.DatabaseConfig) (*gorm.DB, error) { func runMigrations(cfg types.DatabaseConfig, dbConn *gorm.DB, migrations *gormigrate.Gormigrate) error { if cfg.Type == types.DatabaseSqlite { - // SQLite: Run migrations step-by-step, only disabling foreign keys when necessary - - // List of migration IDs that require foreign keys to be disabled - // These are migrations that perform complex schema changes that GORM cannot handle safely with FK enabled - // NO NEW MIGRATIONS SHOULD BE ADDED HERE. ALL NEW MIGRATIONS MUST RUN WITH FOREIGN KEYS ENABLED. - migrationsRequiringFKDisabled := map[string]bool{ - "202501221827": true, // Route table automigration with FK constraint issues - "202501311657": true, // PreAuthKey table automigration with FK constraint issues - // Add other migration IDs here as they are identified to need FK disabled + // SQLite: Run the early migrations that GORM cannot handle safely with + // foreign keys enabled (route and pre-auth-key automigrations) with FK + // disabled, then run everything else with FK enabled. + // + // NO NEW MIGRATIONS SHOULD RUN WITH FK DISABLED. As of 2025-07-02, all + // new migrations must run with foreign keys enabled via the + // migrations.Migrate() call below. + if err := dbConn.Exec("PRAGMA foreign_keys = OFF").Error; err != nil { //nolint:noinlineerr + return fmt.Errorf("disabling foreign keys: %w", err) } - // Get all migration IDs in order from the actual migration definitions - // Only IDs that are in the migrationsRequiringFKDisabled map will be processed with FK disabled - // any other new migrations are ran after. - migrationIDs := []string{ - // v0.25.0 - "202501221827", - "202501311657", - "202502070949", - - // v0.26.0 - "202502131714", - "202502171819", - "202505091439", - "202505141324", - - // As of 2025-07-02, no new IDs should be added here. - // They will be ran by the migrations.Migrate() call below. - } - - for _, migrationID := range migrationIDs { - log.Trace().Caller().Str("migration_id", migrationID).Msg("running migration") - needsFKDisabled := migrationsRequiringFKDisabled[migrationID] - - if needsFKDisabled { - // Disable foreign keys for this migration - err := dbConn.Exec("PRAGMA foreign_keys = OFF").Error - if err != nil { - return fmt.Errorf("disabling foreign keys for migration %s: %w", migrationID, err) - } - } else { - // Ensure foreign keys are enabled for this migration - err := dbConn.Exec("PRAGMA foreign_keys = ON").Error - if err != nil { - return fmt.Errorf("enabling foreign keys for migration %s: %w", migrationID, err) - } - } - - // Run up to this specific migration (will only run the next pending migration) - err := migrations.MigrateTo(migrationID) - if err != nil { - return fmt.Errorf("running migration %s: %w", migrationID, err) - } + // Run up to and including the last migration that requires FK disabled. + if err := migrations.MigrateTo("202501311657"); err != nil { //nolint:noinlineerr + return fmt.Errorf("running migration 202501311657: %w", err) } if err := dbConn.Exec("PRAGMA foreign_keys = ON").Error; err != nil { //nolint:noinlineerr diff --git a/hscontrol/db/db_test.go b/hscontrol/db/db_test.go index 60abcfa07..303c4321e 100644 --- a/hscontrol/db/db_test.go +++ b/hscontrol/db/db_test.go @@ -36,7 +36,7 @@ func TestSQLiteMigrationAndDataValidation(t *testing.T) { // Verify users data preservation users, err := Read(hsdb.DB, func(rx *gorm.DB) ([]types.User, error) { - return ListUsers(rx) + return ListUsers(rx, nil) }) require.NoError(t, err) assert.Len(t, users, 1, "should preserve all 1 user from original schema") diff --git a/hscontrol/db/node.go b/hscontrol/db/node.go index 192f05ade..226f7b1d1 100644 --- a/hscontrol/db/node.go +++ b/hscontrol/db/node.go @@ -30,6 +30,15 @@ const ( // ErrNodeNameNotUnique is returned when a node name is not unique. var ErrNodeNameNotUnique = errors.New("node name is not unique") +// preloadNode returns a session that eager-loads a node's AuthKey, the +// AuthKey's User, and the node's User. +func preloadNode(tx *gorm.DB) *gorm.DB { + return tx. + Preload("AuthKey"). + Preload("AuthKey.User"). + Preload("User") +} + var ( ErrNodeNotFound = errors.New("node not found") ErrNodeRouteIsNotAvailable = errors.New("route is not available on node") @@ -52,10 +61,7 @@ func (hsdb *HSDatabase) ListPeers(nodeID types.NodeID, peerIDs ...types.NodeID) func ListPeers(tx *gorm.DB, nodeID types.NodeID, peerIDs ...types.NodeID) (types.Nodes, error) { nodes := types.Nodes{} - err := tx. - Preload("AuthKey"). - Preload("AuthKey.User"). - Preload("User"). + err := preloadNode(tx). Where("id <> ?", nodeID). Where(peerIDs).Find(&nodes).Error if err != nil { @@ -78,10 +84,7 @@ func (hsdb *HSDatabase) ListNodes(nodeIDs ...types.NodeID) (types.Nodes, error) func ListNodes(tx *gorm.DB, nodeIDs ...types.NodeID) (types.Nodes, error) { nodes := types.Nodes{} - err := tx. - Preload("AuthKey"). - Preload("AuthKey.User"). - Preload("User"). + err := preloadNode(tx). Where(nodeIDs).Find(&nodes).Error if err != nil { return nil, err @@ -111,18 +114,22 @@ func (hsdb *HSDatabase) getNode(uid types.UserID, name string) (*types.Node, err // getNode finds a [types.Node] by name and user and returns the [types.Node] struct. func getNode(tx *gorm.DB, uid types.UserID, name string) (*types.Node, error) { - nodes, err := ListNodesByUser(tx, uid) + uidPtr := uint(uid) + + node := types.Node{} + + err := preloadNode(tx). + Where(&types.Node{UserID: &uidPtr, Hostname: name}). + First(&node).Error if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNodeNotFound + } + return nil, err } - for _, m := range nodes { - if m.Hostname == name { - return m, nil - } - } - - return nil, ErrNodeNotFound + return &node, nil } func (hsdb *HSDatabase) GetNodeByID(id types.NodeID) (*types.Node, error) { @@ -132,11 +139,8 @@ func (hsdb *HSDatabase) GetNodeByID(id types.NodeID) (*types.Node, error) { // GetNodeByID finds a [types.Node] by ID and returns the [types.Node] struct. func GetNodeByID(tx *gorm.DB, id types.NodeID) (*types.Node, error) { mach := types.Node{} - if result := tx. - Preload("AuthKey"). - Preload("AuthKey.User"). - Preload("User"). - Find(&types.Node{ID: id}).First(&mach); result.Error != nil { + if result := preloadNode(tx). + First(&mach, "id = ?", id); result.Error != nil { return nil, result.Error } @@ -153,10 +157,7 @@ func GetNodeByNodeKey( nodeKey key.NodePublic, ) (*types.Node, error) { mach := types.Node{} - if result := tx. - Preload("AuthKey"). - Preload("AuthKey.User"). - Preload("User"). + if result := preloadNode(tx). First(&mach, "node_key = ?", nodeKey.String()); result.Error != nil { return nil, result.Error } @@ -522,6 +523,15 @@ func (e *EphemeralGarbageCollector) Start() { } } +// firstOr returns the first non-empty option, or def if none is provided. +func firstOr(def string, opt []string) string { + if len(opt) > 0 && opt[0] != "" { + return opt[0] + } + + return def +} + func (hsdb *HSDatabase) CreateNodeForTest(user *types.User, hostname ...string) *types.Node { if !testing.Testing() { panic("CreateNodeForTest can only be called during tests") @@ -531,10 +541,7 @@ func (hsdb *HSDatabase) CreateNodeForTest(user *types.User, hostname ...string) panic("CreateNodeForTest requires a valid user") } - nodeName := defaultTestNodePrefix - if len(hostname) > 0 && hostname[0] != "" { - nodeName = hostname[0] - } + nodeName := firstOr(defaultTestNodePrefix, hostname) // Create a preauth key for the node pak, err := hsdb.CreatePreAuthKey(user.TypedID(), false, false, nil, nil) @@ -604,10 +611,7 @@ func (hsdb *HSDatabase) CreateNodesForTest(user *types.User, count int, hostname panic("CreateNodesForTest requires a valid user") } - prefix := defaultTestNodePrefix - if len(hostnamePrefix) > 0 && hostnamePrefix[0] != "" { - prefix = hostnamePrefix[0] - } + prefix := firstOr(defaultTestNodePrefix, hostnamePrefix) nodes := make([]*types.Node, count) for i := range count { @@ -627,10 +631,7 @@ func (hsdb *HSDatabase) CreateRegisteredNodesForTest(user *types.User, count int panic("CreateRegisteredNodesForTest requires a valid user") } - prefix := defaultTestNodePrefix - if len(hostnamePrefix) > 0 && hostnamePrefix[0] != "" { - prefix = hostnamePrefix[0] - } + prefix := firstOr(defaultTestNodePrefix, hostnamePrefix) nodes := make([]*types.Node, count) for i := range count { diff --git a/hscontrol/db/node_test.go b/hscontrol/db/node_test.go index 7bbcf3d95..b628bf71f 100644 --- a/hscontrol/db/node_test.go +++ b/hscontrol/db/node_test.go @@ -346,7 +346,7 @@ func TestAutoApproveRoutes(t *testing.T) { err = adb.DB.Save(&nodeTagged).Error require.NoError(t, err) - users, err := adb.ListUsers() + users, err := adb.ListUsers(nil) require.NoError(t, err) nodes, err := adb.ListNodes() diff --git a/hscontrol/db/preauth_keys.go b/hscontrol/db/preauth_keys.go index c8369f794..fbdcd8c7c 100644 --- a/hscontrol/db/preauth_keys.go +++ b/hscontrol/db/preauth_keys.go @@ -211,60 +211,18 @@ func findAuthKey(tx *gorm.DB, keyStr string) (*types.PreAuthKey, error) { } // New format: hskey-auth-{12-char-prefix}-{64-char-hash} - // Expected minimum length: 12 (prefix) + 1 (separator) + 64 (hash) = 77 - const expectedMinLength = authKeyPrefixLength + 1 + authKeyLength - if len(prefixAndHash) < expectedMinLength { - return nil, fmt.Errorf( - "%w: key too short, expected at least %d chars after prefix, got %d", - ErrPreAuthKeyFailedToParse, - expectedMinLength, - len(prefixAndHash), - ) - } - - // Use fixed-length parsing instead of separator-based to handle dashes in base64 URL-safe - prefix := prefixAndHash[:authKeyPrefixLength] - - // Validate separator at expected position - if prefixAndHash[authKeyPrefixLength] != '-' { - return nil, fmt.Errorf( - "%w: expected separator '-' at position %d, got '%c'", - ErrPreAuthKeyFailedToParse, - authKeyPrefixLength, - prefixAndHash[authKeyPrefixLength], - ) - } - - hash := prefixAndHash[authKeyPrefixLength+1:] - - // Validate hash length - if len(hash) != authKeyLength { - return nil, fmt.Errorf( - "%w: hash length mismatch, expected %d chars, got %d", - ErrPreAuthKeyFailedToParse, - authKeyLength, - len(hash), - ) - } - - // Validate prefix contains only base64 URL-safe characters - if !isValidBase64URLSafe(prefix) { - return nil, fmt.Errorf( - "%w: prefix contains invalid characters (expected base64 URL-safe: A-Za-z0-9_-)", - ErrPreAuthKeyFailedToParse, - ) - } - - // Validate hash contains only base64 URL-safe characters - if !isValidBase64URLSafe(hash) { - return nil, fmt.Errorf( - "%w: hash contains invalid characters (expected base64 URL-safe: A-Za-z0-9_-)", - ErrPreAuthKeyFailedToParse, - ) + prefix, hash, err := parsePrefixedKey( + prefixAndHash, + authKeyPrefixLength, + authKeyLength, + ErrPreAuthKeyFailedToParse, + ) + if err != nil { + return nil, err } // Look up key by prefix - err := tx.Preload("User").First(&pak, "prefix = ?", prefix).Error + err = tx.Preload("User").First(&pak, "prefix = ?", prefix).Error if err != nil { return nil, ErrPreAuthKeyNotFound } @@ -278,6 +236,69 @@ func findAuthKey(tx *gorm.DB, keyStr string) (*types.PreAuthKey, error) { return &pak, nil } +// parsePrefixedKey splits the prefix-and-secret portion of a new-format key +// (the part after the "hskey-*-" prefix) into its fixed-length prefix and +// secret components, validating the length, separator position, and that both +// components are base64 URL-safe. Fixed-length parsing is used instead of +// separator-based to handle dashes in base64 URL-safe characters. +func parsePrefixedKey( + prefixAndSecret string, + prefixLen, secretLen int, + parseErr error, +) (string, string, error) { + expectedMinLength := prefixLen + 1 + secretLen + if len(prefixAndSecret) < expectedMinLength { + return "", "", fmt.Errorf( + "%w: key too short, expected at least %d chars after prefix, got %d", + parseErr, + expectedMinLength, + len(prefixAndSecret), + ) + } + + prefix := prefixAndSecret[:prefixLen] + + // Validate separator at expected position + if prefixAndSecret[prefixLen] != '-' { + return "", "", fmt.Errorf( + "%w: expected separator '-' at position %d, got '%c'", + parseErr, + prefixLen, + prefixAndSecret[prefixLen], + ) + } + + secret := prefixAndSecret[prefixLen+1:] + + // Validate secret length + if len(secret) != secretLen { + return "", "", fmt.Errorf( + "%w: secret length mismatch, expected %d chars, got %d", + parseErr, + secretLen, + len(secret), + ) + } + + // Validate prefix contains only base64 URL-safe characters + if !isValidBase64URLSafe(prefix) { + return "", "", fmt.Errorf( + "%w: prefix contains invalid characters (expected base64 URL-safe: A-Za-z0-9_-)", + parseErr, + ) + } + + // Validate secret contains only base64 URL-safe characters + if !isValidBase64URLSafe(secret) { + return "", "", fmt.Errorf( + "%w: secret contains invalid characters (expected base64 URL-safe: A-Za-z0-9_-)", + parseErr, + ) + } + + return prefix, secret, nil +} + // isValidBase64URLSafe checks if a string contains only base64 URL-safe characters. func isValidBase64URLSafe(s string) bool { for _, c := range s { diff --git a/hscontrol/db/sqliteconfig/config.go b/hscontrol/db/sqliteconfig/config.go index 711e434ac..66fec5906 100644 --- a/hscontrol/db/sqliteconfig/config.go +++ b/hscontrol/db/sqliteconfig/config.go @@ -347,33 +347,6 @@ func (c *Config) ToURL() (string, error) { return "", fmt.Errorf("invalid config: %w", err) } - var pragmas []string - - // Add pragma parameters only if they're set (non-zero/non-empty) - if c.BusyTimeout > 0 { - pragmas = append(pragmas, fmt.Sprintf("busy_timeout=%d", c.BusyTimeout)) - } - - if c.JournalMode != "" { - pragmas = append(pragmas, fmt.Sprintf("journal_mode=%s", c.JournalMode)) - } - - if c.AutoVacuum != "" { - pragmas = append(pragmas, fmt.Sprintf("auto_vacuum=%s", c.AutoVacuum)) - } - - if c.WALAutocheckpoint >= 0 { - pragmas = append(pragmas, fmt.Sprintf("wal_autocheckpoint=%d", c.WALAutocheckpoint)) - } - - if c.Synchronous != "" { - pragmas = append(pragmas, fmt.Sprintf("synchronous=%s", c.Synchronous)) - } - - if c.ForeignKeys { - pragmas = append(pragmas, "foreign_keys=ON") - } - // Handle different database types var baseURL string if c.Path == ":memory:" { @@ -383,16 +356,36 @@ func (c *Config) ToURL() (string, error) { } // Build query parameters - queryParts := make([]string, 0, 1+len(pragmas)) + var queryParts []string // Add _txlock first (it's a connection parameter, not a pragma) if c.TxLock != "" { queryParts = append(queryParts, "_txlock="+string(c.TxLock)) } - // Add pragma parameters - for _, pragma := range pragmas { - queryParts = append(queryParts, "_pragma="+pragma) + // Add pragma parameters only if they're set (non-zero/non-empty) + if c.BusyTimeout > 0 { + queryParts = append(queryParts, fmt.Sprintf("_pragma=busy_timeout=%d", c.BusyTimeout)) + } + + if c.JournalMode != "" { + queryParts = append(queryParts, fmt.Sprintf("_pragma=journal_mode=%s", c.JournalMode)) + } + + if c.AutoVacuum != "" { + queryParts = append(queryParts, fmt.Sprintf("_pragma=auto_vacuum=%s", c.AutoVacuum)) + } + + if c.WALAutocheckpoint >= 0 { + queryParts = append(queryParts, fmt.Sprintf("_pragma=wal_autocheckpoint=%d", c.WALAutocheckpoint)) + } + + if c.Synchronous != "" { + queryParts = append(queryParts, fmt.Sprintf("_pragma=synchronous=%s", c.Synchronous)) + } + + if c.ForeignKeys { + queryParts = append(queryParts, "_pragma=foreign_keys=ON") } if len(queryParts) > 0 { diff --git a/hscontrol/db/text_serialiser.go b/hscontrol/db/text_serialiser.go index 7c39b8bed..c315772bc 100644 --- a/hscontrol/db/text_serialiser.go +++ b/hscontrol/db/text_serialiser.go @@ -47,45 +47,45 @@ func (TextSerialiser) Scan(ctx context.Context, field *schema.Field, dst reflect fieldValue = fieldValue.Elem() } - if dbValue != nil { - var bytes []byte + if dbValue == nil { + return nil + } - switch v := dbValue.(type) { - case []byte: - bytes = v - case string: - bytes = []byte(v) - default: - return fmt.Errorf("%w: %#v", errUnmarshalTextValue, dbValue) + var bytes []byte + + switch v := dbValue.(type) { + case []byte: + bytes = v + case string: + bytes = []byte(v) + default: + return fmt.Errorf("%w: %#v", errUnmarshalTextValue, dbValue) + } + + if !isTextUnmarshaler(fieldValue) { + return fmt.Errorf("%w: %T", errUnsupportedType, fieldValue.Interface()) + } + + maybeInstantiatePtr(fieldValue) + f := fieldValue.MethodByName("UnmarshalText") + args := []reflect.Value{reflect.ValueOf(bytes)} + + ret := f.Call(args) + if !ret[0].IsNil() { + if err, ok := ret[0].Interface().(error); ok { + return decodingError(field.Name, err) } + } - if isTextUnmarshaler(fieldValue) { - maybeInstantiatePtr(fieldValue) - f := fieldValue.MethodByName("UnmarshalText") - args := []reflect.Value{reflect.ValueOf(bytes)} - - ret := f.Call(args) - if !ret[0].IsNil() { - if err, ok := ret[0].Interface().(error); ok { - return decodingError(field.Name, err) - } - } - - // If the underlying field is to a pointer type, we need to - // assign the value as a pointer to it. - // If it is not a pointer, we need to assign the value to the - // field. - dstField := field.ReflectValueOf(ctx, dst) - if dstField.Kind() == reflect.Pointer { - dstField.Set(fieldValue) - } else { - dstField.Set(fieldValue.Elem()) - } - - return nil - } else { - return fmt.Errorf("%w: %T", errUnsupportedType, fieldValue.Interface()) - } + // If the underlying field is to a pointer type, we need to + // assign the value as a pointer to it. + // If it is not a pointer, we need to assign the value to the + // field. + dstField := field.ReflectValueOf(ctx, dst) + if dstField.Kind() == reflect.Pointer { + dstField.Set(fieldValue) + } else { + dstField.Set(fieldValue.Elem()) } return nil diff --git a/hscontrol/db/users.go b/hscontrol/db/users.go index 271c3e3df..86ee936fe 100644 --- a/hscontrol/db/users.go +++ b/hscontrol/db/users.go @@ -12,11 +12,10 @@ import ( ) var ( - ErrUserExists = errors.New("user already exists") - ErrUserNotFound = errors.New("user not found") - ErrUserStillHasNodes = errors.New("user not empty: node(s) found") - ErrUserWhereInvalidCount = errors.New("expect 0 or 1 where User structs") - ErrUserNotUnique = errors.New("expected exactly one user") + ErrUserExists = errors.New("user already exists") + ErrUserNotFound = errors.New("user not found") + ErrUserStillHasNodes = errors.New("user not empty: node(s) found") + ErrUserNotUnique = errors.New("expected exactly one user") ) func (hsdb *HSDatabase) CreateUser(user types.User) (*types.User, error) { @@ -152,24 +151,15 @@ func GetUserByOIDCIdentifier(tx *gorm.DB, id string) (*types.User, error) { return &user, nil } -func (hsdb *HSDatabase) ListUsers(where ...*types.User) ([]types.User, error) { - return ListUsers(hsdb.DB, where...) +func (hsdb *HSDatabase) ListUsers(filter *types.User) ([]types.User, error) { + return ListUsers(hsdb.DB, filter) } -// ListUsers gets all the existing users. -func ListUsers(tx *gorm.DB, where ...*types.User) ([]types.User, error) { - if len(where) > 1 { - return nil, fmt.Errorf("%w, got %d", ErrUserWhereInvalidCount, len(where)) - } - - var user *types.User - if len(where) == 1 { - user = where[0] - } - +// ListUsers gets all the existing users, optionally filtered by a non-nil filter. +func ListUsers(tx *gorm.DB, filter *types.User) ([]types.User, error) { users := []types.User{} - err := tx.Where(user).Find(&users).Error + err := tx.Where(filter).Find(&users).Error if err != nil { return nil, err } @@ -202,7 +192,7 @@ func ListNodesByUser(tx *gorm.DB, uid types.UserID) (types.Nodes, error) { uidPtr := uint(uid) - err := tx.Preload("AuthKey").Preload("AuthKey.User").Preload("User").Where(&types.Node{UserID: &uidPtr}).Find(&nodes).Error + err := preloadNode(tx).Where(&types.Node{UserID: &uidPtr}).Find(&nodes).Error if err != nil { return nil, err } @@ -215,10 +205,7 @@ func (hsdb *HSDatabase) CreateUserForTest(name ...string) *types.User { panic("CreateUserForTest can only be called during tests") } - userName := "testuser" - if len(name) > 0 && name[0] != "" { - userName = name[0] - } + userName := firstOr("testuser", name) user, err := hsdb.CreateUser(types.User{Name: userName}) if err != nil { @@ -233,10 +220,7 @@ func (hsdb *HSDatabase) CreateUsersForTest(count int, namePrefix ...string) []*t panic("CreateUsersForTest can only be called during tests") } - prefix := "testuser" - if len(namePrefix) > 0 && namePrefix[0] != "" { - prefix = namePrefix[0] - } + prefix := firstOr("testuser", namePrefix) users := make([]*types.User, count) for i := range count { diff --git a/hscontrol/db/users_test.go b/hscontrol/db/users_test.go index 36d836f91..2a755ff3c 100644 --- a/hscontrol/db/users_test.go +++ b/hscontrol/db/users_test.go @@ -17,7 +17,7 @@ func TestCreateAndDestroyUser(t *testing.T) { user := db.CreateUserForTest("test") assert.Equal(t, "test", user.Name) - users, err := db.ListUsers() + users, err := db.ListUsers(nil) require.NoError(t, err) assert.Len(t, users, 1) @@ -227,7 +227,7 @@ func TestRenameUser(t *testing.T) { userTest := db.CreateUserForTest("test") assert.Equal(t, "test", userTest.Name) - users, err := db.ListUsers() + users, err := db.ListUsers(nil) require.NoError(t, err) assert.Len(t, users, 1) diff --git a/hscontrol/db/versioncheck.go b/hscontrol/db/versioncheck.go index a071348c1..199b5964d 100644 --- a/hscontrol/db/versioncheck.go +++ b/hscontrol/db/versioncheck.go @@ -11,6 +11,7 @@ import ( "github.com/juanfont/headscale/hscontrol/types" "github.com/rs/zerolog/log" "gorm.io/gorm" + "gorm.io/gorm/clause" ) var errVersionUpgrade = errors.New("version upgrade not supported") @@ -66,22 +67,20 @@ func parseVersion(s string) (semver, error) { return semver{}, fmt.Errorf("%q: %w", s, errVersionFormat) } - major, err := strconv.Atoi(parts[0]) - if err != nil { - return semver{}, fmt.Errorf("invalid major version in %q: %w", s, err) + var out [3]int + + names := [...]string{"major", "minor", "patch"} + + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil { + return semver{}, fmt.Errorf("invalid %s version in %q: %w", names[i], s, err) + } + + out[i] = n } - minor, err := strconv.Atoi(parts[1]) - if err != nil { - return semver{}, fmt.Errorf("invalid minor version in %q: %w", s, err) - } - - patch, err := strconv.Atoi(parts[2]) - if err != nil { - return semver{}, fmt.Errorf("invalid patch version in %q: %w", s, err) - } - - return semver{Major: major, Minor: minor, Patch: patch}, nil + return semver{Major: out[0], Minor: out[1], Patch: out[2]}, nil } // ensureDatabaseVersionTable creates the database_versions table if it @@ -118,23 +117,12 @@ func getDatabaseVersion(db *gorm.DB) (string, error) { func setDatabaseVersion(db *gorm.DB, version string) error { now := time.Now().UTC() - // Try update first, then insert if no rows affected. - result := db.Exec( - "UPDATE database_versions SET version = ?, updated_at = ? WHERE id = 1", - version, now, - ) - if result.Error != nil { - return fmt.Errorf("updating database version: %w", result.Error) - } - - if result.RowsAffected == 0 { - err := db.Exec( - "INSERT INTO database_versions (id, version, updated_at) VALUES (1, ?, ?)", - version, now, - ).Error - if err != nil { - return fmt.Errorf("inserting database version: %w", err) - } + err := db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.AssignmentColumns([]string{"version", "updated_at"}), + }).Create(&DatabaseVersion{ID: 1, Version: version, UpdatedAt: now}).Error + if err != nil { + return fmt.Errorf("upserting database version: %w", err) } return nil diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index edbed2e5d..a56ffff1e 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -247,7 +247,7 @@ func NewState(cfg *types.Config) (*State, error) { node.IsOnline = new(false) } - users, err := db.ListUsers() + users, err := db.ListUsers(nil) if err != nil { return nil, fmt.Errorf("loading users: %w", err) } @@ -508,7 +508,7 @@ func (s *State) ListUsersWithFilter(filter *types.User) ([]types.User, error) { // ListAllUsers retrieves all users in the system. func (s *State) ListAllUsers() ([]types.User, error) { - return s.db.ListUsers() + return s.db.ListUsers(nil) } // persistNodeRowToDB writes the node's database row, re-reading the From 77f289a6274653550017da338362269ce840a87b Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 16 Jun 2026 12:09:44 +0000 Subject: [PATCH 024/127] db: consolidate IP allocation helpers --- hscontrol/db/ip.go | 43 +++++++-------------------- hscontrol/db/ip_backfill_race_test.go | 6 ++-- 2 files changed, 14 insertions(+), 35 deletions(-) diff --git a/hscontrol/db/ip.go b/hscontrol/db/ip.go index 014172edb..fede11b7f 100644 --- a/hscontrol/db/ip.go +++ b/hscontrol/db/ip.go @@ -138,9 +138,6 @@ func NewIPAllocator( } func (i *IPAllocator) Next() (*netip.Addr, *netip.Addr, error) { - i.mu.Lock() - defer i.mu.Unlock() - var ( err error ret4 *netip.Addr @@ -148,21 +145,17 @@ func (i *IPAllocator) Next() (*netip.Addr, *netip.Addr, error) { ) if i.prefix4 != nil { - ret4, err = i.next(i.prev4, i.prefix4) + ret4, err = i.allocateNext(&i.prev4, i.prefix4) if err != nil { return nil, nil, fmt.Errorf("allocating IPv4 address: %w", err) } - - i.prev4 = *ret4 } if i.prefix6 != nil { - ret6, err = i.next(i.prev6, i.prefix6) + ret6, err = i.allocateNext(&i.prev6, i.prefix6) if err != nil { return nil, nil, fmt.Errorf("allocating IPv6 address: %w", err) } - - i.prev6 = *ret6 } return ret4, ret6, nil @@ -170,34 +163,20 @@ func (i *IPAllocator) Next() (*netip.Addr, *netip.Addr, error) { var ErrCouldNotAllocateIP = errors.New("failed to allocate IP") -// allocateNext4 allocates the next IPv4 under i.mu, advancing prev4 so a run of -// allocations (e.g. BackfillNodeIPs) does not rescan already-issued addresses, -// and so prev4 is read under the lock rather than in the caller's frame. -func (i *IPAllocator) allocateNext4() (*netip.Addr, error) { +// allocateNext allocates the next address from prefix under i.mu, advancing +// prev so a run of allocations (e.g. BackfillNodeIPs) does not rescan +// already-issued addresses, and so prev is read under the lock rather than in +// the caller's frame. +func (i *IPAllocator) allocateNext(prev *netip.Addr, prefix *netip.Prefix) (*netip.Addr, error) { i.mu.Lock() defer i.mu.Unlock() - ret, err := i.next(i.prev4, i.prefix4) + ret, err := i.next(*prev, prefix) if err != nil { return nil, err } - i.prev4 = *ret - - return ret, nil -} - -// allocateNext6 mirrors allocateNext4 for the IPv6 prefix. -func (i *IPAllocator) allocateNext6() (*netip.Addr, error) { - i.mu.Lock() - defer i.mu.Unlock() - - ret, err := i.next(i.prev6, i.prefix6) - if err != nil { - return nil, err - } - - i.prev6 = *ret + *prev = *ret return ret, nil } @@ -348,7 +327,7 @@ func (db *HSDatabase) BackfillNodeIPs(i *IPAllocator) ([]string, error) { changed := false // IPv4 prefix is set, but node ip is missing, alloc if i.prefix4 != nil && node.IPv4 == nil { - ret4, err := i.allocateNext4() + ret4, err := i.allocateNext(&i.prev4, i.prefix4) if err != nil { return fmt.Errorf("allocating IPv4 for node(%d): %w", node.ID, err) } @@ -361,7 +340,7 @@ func (db *HSDatabase) BackfillNodeIPs(i *IPAllocator) ([]string, error) { // IPv6 prefix is set, but node ip is missing, alloc if i.prefix6 != nil && node.IPv6 == nil { - ret6, err := i.allocateNext6() + ret6, err := i.allocateNext(&i.prev6, i.prefix6) if err != nil { return fmt.Errorf("allocating IPv6 for node(%d): %w", node.ID, err) } diff --git a/hscontrol/db/ip_backfill_race_test.go b/hscontrol/db/ip_backfill_race_test.go index 2c3e4b997..25c7ad197 100644 --- a/hscontrol/db/ip_backfill_race_test.go +++ b/hscontrol/db/ip_backfill_race_test.go @@ -10,7 +10,7 @@ import ( ) // TestAllocatorConcurrentNextAndBackfillNoRace exercises the registration path -// (Next) concurrently with the backfill allocation path (allocateNext4/6) on +// (Next) concurrently with the backfill allocation path (allocateNext) on // the same allocator. Backfill used to read prev4/prev6 in the caller's frame // without the lock, racing Next's writes; both must now take i.mu. Run with // -race. @@ -36,12 +36,12 @@ func TestAllocatorConcurrentNextAndBackfillNoRace(t *testing.T) { wg.Go(func() { for range iterations { - _, err := alloc.allocateNext4() + _, err := alloc.allocateNext(&alloc.prev4, alloc.prefix4) if err != nil { return } - _, err = alloc.allocateNext6() + _, err = alloc.allocateNext(&alloc.prev6, alloc.prefix6) if err != nil { return } From 145672f0b69cb8cdff68b44f5aac907f7485cb73 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 16 Jun 2026 07:00:35 +0000 Subject: [PATCH 025/127] state: consolidate route-election and node helpers --- hscontrol/state/debug.go | 71 +++++-------------------- hscontrol/state/ha_health.go | 26 ++++------ hscontrol/state/node_store.go | 98 +++++++++++++++-------------------- hscontrol/state/state.go | 22 ++++---- 4 files changed, 78 insertions(+), 139 deletions(-) diff --git a/hscontrol/state/debug.go b/hscontrol/state/debug.go index 3fbd0902e..91a9c4493 100644 --- a/hscontrol/state/debug.go +++ b/hscontrol/state/debug.go @@ -62,51 +62,23 @@ type DebugStringInfo struct { // DebugOverview returns a comprehensive overview of the current state for debugging. func (s *State) DebugOverview() string { - allNodes := s.nodeStore.ListNodes() - users, _ := s.ListAllUsers() + info := s.DebugOverviewJSON() var sb strings.Builder sb.WriteString("=== Headscale State Overview ===\n\n") // Node statistics - fmt.Fprintf(&sb, "Nodes: %d total\n", allNodes.Len()) - - userNodeCounts := make(map[string]int) - onlineCount := 0 - expiredCount := 0 - ephemeralCount := 0 - - now := time.Now() - - for _, node := range allNodes.All() { - if node.Valid() { - userName := node.Owner().Name() - userNodeCounts[userName]++ - - if node.IsOnline().Valid() && node.IsOnline().Get() { - onlineCount++ - } - - if node.Expiry().Valid() && node.Expiry().Get().Before(now) { - expiredCount++ - } - - if node.AuthKey().Valid() && node.AuthKey().Ephemeral() { - ephemeralCount++ - } - } - } - - fmt.Fprintf(&sb, " - Online: %d\n", onlineCount) - fmt.Fprintf(&sb, " - Expired: %d\n", expiredCount) - fmt.Fprintf(&sb, " - Ephemeral: %d\n", ephemeralCount) + fmt.Fprintf(&sb, "Nodes: %d total\n", info.Nodes.Total) + fmt.Fprintf(&sb, " - Online: %d\n", info.Nodes.Online) + fmt.Fprintf(&sb, " - Expired: %d\n", info.Nodes.Expired) + fmt.Fprintf(&sb, " - Ephemeral: %d\n", info.Nodes.Ephemeral) sb.WriteString("\n") // User statistics - fmt.Fprintf(&sb, "Users: %d total\n", len(users)) + fmt.Fprintf(&sb, "Users: %d total\n", info.TotalUsers) - for userName, nodeCount := range userNodeCounts { + for userName, nodeCount := range info.Users { fmt.Fprintf(&sb, " - %s: %d nodes\n", userName, nodeCount) } @@ -114,18 +86,17 @@ func (s *State) DebugOverview() string { // Policy information sb.WriteString("Policy:\n") - fmt.Fprintf(&sb, " - Mode: %s\n", s.cfg.Policy.Mode) + fmt.Fprintf(&sb, " - Mode: %s\n", info.Policy.Mode) - if s.cfg.Policy.Mode == types.PolicyModeFile { - fmt.Fprintf(&sb, " - Path: %s\n", s.cfg.Policy.Path) + if info.Policy.Mode == string(types.PolicyModeFile) { + fmt.Fprintf(&sb, " - Path: %s\n", info.Policy.Path) } sb.WriteString("\n") // DERP information - derpMap := s.derpMap.Load() - if derpMap != nil { - fmt.Fprintf(&sb, "DERP: %d regions configured\n", len(derpMap.Regions)) + if info.DERP.Configured { + fmt.Fprintf(&sb, "DERP: %d regions configured\n", info.DERP.Regions) } else { sb.WriteString("DERP: not configured\n") } @@ -133,14 +104,7 @@ func (s *State) DebugOverview() string { sb.WriteString("\n") // Route information - primaryStr := s.PrimaryRoutesString() - - routeCount := len(strings.Split(strings.TrimSpace(primaryStr), "\n")) - if primaryStr == "" { - routeCount = 0 - } - - fmt.Fprintf(&sb, "Primary Routes: %d active\n", routeCount) + fmt.Fprintf(&sb, "Primary Routes: %d active\n", info.PrimaryRoutes) sb.WriteString("\n") // Registration cache @@ -371,14 +335,7 @@ func (s *State) DebugOverviewJSON() DebugOverviewInfo { } // Route information - primaryStr := s.PrimaryRoutesString() - - routeCount := len(strings.Split(strings.TrimSpace(primaryStr), "\n")) - if primaryStr == "" { - routeCount = 0 - } - - info.PrimaryRoutes = routeCount + info.PrimaryRoutes = len(s.nodeStore.PrimaryRoutes()) return info } diff --git a/hscontrol/state/ha_health.go b/hscontrol/state/ha_health.go index e6482b52c..4e76e9a35 100644 --- a/hscontrol/state/ha_health.go +++ b/hscontrol/state/ha_health.go @@ -74,16 +74,24 @@ func (p *HAHealthProber) ProbeOnce( ) { haNodes := p.state.nodeStore.HANodes() - // Drop stable-session entries for nodes that are no longer HA - // candidates so a future reappearance starts fresh. + // Build the deduplicated node-ID set and slice in one pass. + var nodeIDs []types.NodeID + seen := make(set.Set[types.NodeID]) for _, nodes := range haNodes { for _, id := range nodes { + if seen.Contains(id) { + continue + } + seen.Add(id) + nodeIDs = append(nodeIDs, id) } } + // Drop stable-session entries for nodes that are no longer HA + // candidates so a future reappearance starts fresh. p.lastStableSession.Range(func(id types.NodeID, _ uint64) bool { if !seen.Contains(id) { p.lastStableSession.Delete(id) @@ -96,20 +104,6 @@ func (p *HAHealthProber) ProbeOnce( return } - // Deduplicate node IDs across prefixes. - var nodeIDs []types.NodeID - - dedup := make(set.Set[types.NodeID]) - - for _, nodes := range haNodes { - for _, id := range nodes { - if !dedup.Contains(id) { - dedup.Add(id) - nodeIDs = append(nodeIDs, id) - } - } - } - log.Debug(). Int("haNodes", len(nodeIDs)). Msg("HA health prober starting probe cycle") diff --git a/hscontrol/state/node_store.go b/hscontrol/state/node_store.go index e1e17a89e..cece54edd 100644 --- a/hscontrol/state/node_store.go +++ b/hscontrol/state/node_store.go @@ -527,33 +527,24 @@ func (s *NodeStore) applyBatch(batch []work) { // Update node count gauge nodeStoreNodesCount.Set(float64(len(nodes))) - // Send the resulting nodes to all work items that requested them + // Send the resulting nodes to all work items that requested them. + // A zero-value NodeView{} reports Valid()==false, matching node.View() + // for a node that was deleted or never existed. for nodeID, workItems := range nodeResultRequests { + var nodeView types.NodeView if node, exists := nodes[nodeID]; exists { - nodeView := node.View() - for _, w := range workItems { - w.nodeResult <- nodeView + nodeView = node.View() + } - close(w.nodeResult) + for _, w := range workItems { + w.nodeResult <- nodeView - if w.errResult != nil { - w.errResult <- setErrResults[w] + close(w.nodeResult) - close(w.errResult) - } - } - } else { - // Node was deleted or doesn't exist - for _, w := range workItems { - w.nodeResult <- types.NodeView{} // Send invalid view + if w.errResult != nil { + w.errResult <- setErrResults[w] - close(w.nodeResult) - - if w.errResult != nil { - w.errResult <- setErrResults[w] - - close(w.errResult) - } + close(w.errResult) } } } @@ -669,26 +660,13 @@ func snapshotFromNodes( return newSnap } -// electPrimaryRoutes picks the primary advertiser for each non-exit -// prefix. Inputs are restricted to online nodes that advertise the -// prefix. The previous primary is preserved when it is still online -// and healthy (anti-flap); otherwise the lowest-NodeID healthy -// advertiser wins. When every advertiser is unhealthy the previous -// primary is preserved only if still a candidate — falling back to -// any other candidate would point peers at a node the prober has -// already declared unreachable, so leaving the prefix unmapped is -// preferred until a probe cycle finds one that responds. -func electPrimaryRoutes( +// onlineAdvertisers maps each non-exit prefix to the IDs of online nodes +// that approve it. Nodes are visited in the order of ids, so the per-prefix +// slices preserve that order. +func onlineAdvertisers( nodes map[types.NodeID]types.Node, - prev map[netip.Prefix]types.NodeID, -) (map[netip.Prefix]types.NodeID, map[types.NodeID]bool) { - ids := make([]types.NodeID, 0, len(nodes)) - for id := range nodes { - ids = append(ids, id) - } - - slices.Sort(ids) - + ids []types.NodeID, +) map[netip.Prefix][]types.NodeID { advertisers := make(map[netip.Prefix][]types.NodeID) for _, id := range ids { @@ -706,6 +684,24 @@ func electPrimaryRoutes( } } + return advertisers +} + +// electPrimaryRoutes picks the primary advertiser for each non-exit +// prefix. Inputs are restricted to online nodes that advertise the +// prefix. The previous primary is preserved when it is still online +// and healthy (anti-flap); otherwise the lowest-NodeID healthy +// advertiser wins. When every advertiser is unhealthy the previous +// primary is preserved only if still a candidate — falling back to +// any other candidate would point peers at a node the prober has +// already declared unreachable, so leaving the prefix unmapped is +// preferred until a probe cycle finds one that responds. +func electPrimaryRoutes( + nodes map[types.NodeID]types.Node, + prev map[netip.Prefix]types.NodeID, +) (map[netip.Prefix]types.NodeID, map[types.NodeID]bool) { + advertisers := onlineAdvertisers(nodes, slices.Sorted(maps.Keys(nodes))) + routes := make(map[netip.Prefix]types.NodeID, len(advertisers)) for prefix, candidates := range advertisers { if cur, ok := prev[prefix]; ok && @@ -734,7 +730,7 @@ func electPrimaryRoutes( // would point peers at a node the prober has already declared // unreachable; leaving the prefix unmapped is honest until a // probe cycle picks one that responds. - if !found && len(candidates) >= 1 { + if !found { if cur, ok := prev[prefix]; ok && slices.Contains(candidates, cur) { selected = cur found = true @@ -919,21 +915,10 @@ func (s *NodeStore) PrimaryRoutesForNode(id types.NodeID) []netip.Prefix { func (s *NodeStore) HANodes() map[netip.Prefix][]types.NodeID { snap := s.data.Load() - advertisers := make(map[netip.Prefix][]types.NodeID) - - for id, n := range snap.nodesByID { - if n.IsOnline == nil || !*n.IsOnline { - continue - } - - for _, p := range n.AllApprovedRoutes() { - if tsaddr.IsExitRoute(p) { - continue - } - - advertisers[p] = append(advertisers[p], id) - } - } + advertisers := onlineAdvertisers( + snap.nodesByID, + slices.Sorted(maps.Keys(snap.nodesByID)), + ) out := make(map[netip.Prefix][]types.NodeID) @@ -942,7 +927,6 @@ func (s *NodeStore) HANodes() map[netip.Prefix][]types.NodeID { continue } - slices.Sort(ids) out[p] = ids } diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index a56ffff1e..b67d2df1f 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -771,6 +771,16 @@ func (s *State) ResolveNode(query string) (types.NodeView, bool) { return s.GetNodeByID(id) } + // keepLowest returns whichever node has the lower ID, so repeated + // calls resolve deterministically across snapshot iterations. + keepLowest := func(cur, cand types.NodeView) types.NodeView { + if !cur.Valid() || cand.ID() < cur.ID() { + return cand + } + + return cur + } + // Try IP address. addr, addrErr := netip.ParseAddr(query) if addrErr == nil { @@ -781,9 +791,7 @@ func (s *State) ResolveNode(query string) (types.NodeView, bool) { continue } - if !match.Valid() || n.ID() < match.ID() { - match = n - } + match = keepLowest(match, n) } return match, match.Valid() @@ -795,13 +803,9 @@ func (s *State) ResolveNode(query string) (types.NodeView, bool) { for _, n := range s.ListNodes().All() { if n.GivenName() == query { - if !givenMatch.Valid() || n.ID() < givenMatch.ID() { - givenMatch = n - } + givenMatch = keepLowest(givenMatch, n) } else if n.Hostname() == query { - if !hostMatch.Valid() || n.ID() < hostMatch.ID() { - hostMatch = n - } + hostMatch = keepLowest(hostMatch, n) } } From e6ef1dda4d40df1acd414f7fa11fa6fbc0bdea60 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 16 Jun 2026 08:23:41 +0000 Subject: [PATCH 026/127] policy: consolidate cache-invalidation and evaluation helpers --- hscontrol/policy/v2/compiled.go | 28 ++++----- hscontrol/policy/v2/filter.go | 71 +++++++++------------- hscontrol/policy/v2/policy.go | 104 ++++++++++++-------------------- hscontrol/policy/v2/sshtest.go | 49 ++++++--------- hscontrol/policy/v2/test.go | 66 +++++++++----------- hscontrol/policy/v2/types.go | 7 ++- 6 files changed, 129 insertions(+), 196 deletions(-) diff --git a/hscontrol/policy/v2/compiled.go b/hscontrol/policy/v2/compiled.go index 1f8c9ad41..900df6917 100644 --- a/hscontrol/policy/v2/compiled.go +++ b/hscontrol/policy/v2/compiled.go @@ -11,6 +11,7 @@ import ( "go4.org/netipx" "tailscale.com/tailcfg" "tailscale.com/types/views" + "tailscale.com/util/set" ) // grantCategory classifies a grant by what per-node work it needs. @@ -324,17 +325,15 @@ func (pol *Policy) compileOneGrant( ) } - // Classify and store deferred self data. - switch { - case len(autogroupSelfDests) > 0: + // Classify and store deferred self data. The struct literal already + // initializes category to grantCategoryRegular (the zero value). + if len(autogroupSelfDests) > 0 { cg.category = grantCategorySelf cg.self = &selfGrantData{ resolvedSrcs: resolvedSrcs, internetProtocols: grant.InternetProtocols, app: grant.App, } - default: - cg.category = grantCategoryRegular } return cg, nil @@ -472,15 +471,12 @@ func buildSrcIPStrings( // individual IPs from non-wildcard sources alongside the // merged CGNAT ranges rather than absorbing them. if hasWildcard && len(nonWildcardSrcs) > 0 { - seen := make(map[string]bool, len(srcIPStrs)) - for _, s := range srcIPStrs { - seen[s] = true - } + seen := set.SetOf(srcIPStrs) for _, ips := range nonWildcardSrcs { for _, s := range ips.Strings() { - if !seen[s] { - seen[s] = true + if !seen.Contains(s) { + seen.Add(s) srcIPStrs = append(srcIPStrs, s) } } @@ -627,7 +623,7 @@ func collectRelayTargetIPs(grants []compiledGrant) (*netipx.IPSet, error) { // traffic through it must recompute when it goes offline. Returns nil when no // via grants exist. func collectViaTargetTags(grants []compiledGrant) map[Tag]struct{} { - var tags map[Tag]struct{} + tags := make(map[Tag]struct{}) for i := range grants { if grants[i].via == nil { @@ -635,14 +631,14 @@ func collectViaTargetTags(grants []compiledGrant) map[Tag]struct{} { } for _, t := range grants[i].via.viaTags { - if tags == nil { - tags = make(map[Tag]struct{}) - } - tags[t] = struct{}{} } } + if len(tags) == 0 { + return nil + } + return tags } diff --git a/hscontrol/policy/v2/filter.go b/hscontrol/policy/v2/filter.go index 1d2c43568..98e6b06ca 100644 --- a/hscontrol/policy/v2/filter.go +++ b/hscontrol/policy/v2/filter.go @@ -1,7 +1,6 @@ package v2 import ( - "cmp" "errors" "fmt" "net/netip" @@ -23,13 +22,22 @@ var ( errSelfInSources = errors.New("autogroup:self cannot be used in sources") ) -// companionCaps maps certain well-known Tailscale capabilities to +// companionCap pairs a well-known Tailscale capability with its +// companion capability. +type companionCap struct { + original tailcfg.PeerCapability + companion tailcfg.PeerCapability +} + +// companionCaps lists certain well-known Tailscale capabilities and // their companion capability. When a grant includes one of these // capabilities, Tailscale automatically generates an additional // [tailcfg.FilterRule] with the companion capability and a nil CapMap value. -var companionCaps = map[tailcfg.PeerCapability]tailcfg.PeerCapability{ - tailcfg.PeerCapabilityTaildrive: tailcfg.PeerCapabilityTaildriveSharer, - tailcfg.PeerCapabilityRelay: tailcfg.PeerCapabilityRelayTarget, +// The slice is ordered by the original capability name so that +// generated companion rules are emitted deterministically. +var companionCaps = []companionCap{ + {tailcfg.PeerCapabilityTaildrive, tailcfg.PeerCapabilityTaildriveSharer}, + {tailcfg.PeerCapabilityRelay, tailcfg.PeerCapabilityRelayTarget}, } // companionCapGrantRules returns additional [tailcfg.FilterRule]s for any @@ -48,38 +56,22 @@ func companionCapGrantRules( srcPrefixes []netip.Prefix, capMap tailcfg.PeerCapMap, ) []tailcfg.FilterRule { - // Process in deterministic order by original capability name. - type pair struct { - original tailcfg.PeerCapability - companion tailcfg.PeerCapability - } + companions := make([]tailcfg.FilterRule, 0, len(companionCaps)) - var pairs []pair - - for cap, companion := range companionCaps { - if _, ok := capMap[cap]; ok { - pairs = append(pairs, pair{cap, companion}) - } - } - - slices.SortFunc(pairs, func(a, b pair) int { - return cmp.Compare(a.original, b.original) - }) - - companions := make([]tailcfg.FilterRule, 0, len(pairs)) - - for _, p := range pairs { - companions = append(companions, tailcfg.FilterRule{ - SrcIPs: dstIPStrings, - CapGrant: []tailcfg.CapGrant{ - { - Dsts: srcPrefixes, - CapMap: tailcfg.PeerCapMap{ - p.companion: nil, + for _, c := range companionCaps { + if _, ok := capMap[c.original]; ok { + companions = append(companions, tailcfg.FilterRule{ + SrcIPs: dstIPStrings, + CapGrant: []tailcfg.CapGrant{ + { + Dsts: srcPrefixes, + CapMap: tailcfg.PeerCapMap{ + c.companion: nil, + }, }, }, - }, - }) + }) + } } return companions @@ -238,7 +230,7 @@ func checkPeriodFromRule(rule SSH) time.Duration { } } -func sshCheck(baseURL string, _ time.Duration) tailcfg.SSHAction { +func sshCheck(baseURL string) tailcfg.SSHAction { holdURL := baseURL + "/machine/ssh/action/$SRC_NODE_ID/to/$DST_NODE_ID?local_user=$LOCAL_USER" return tailcfg.SSHAction{ @@ -302,7 +294,7 @@ func (pol *Policy) compileSSHPolicy( case SSHActionAccept: action = sshAccept case SSHActionCheck: - action = sshCheck(baseURL, checkPeriodFromRule(rule)) + action = sshCheck(baseURL) default: return nil, fmt.Errorf( "parsing SSH policy, unknown action %q, index: %d: %w", @@ -425,12 +417,7 @@ func (pol *Policy) compileSSHPolicy( allPrincipals = append(allPrincipals, taggedPrincipals...) if len(allPrincipals) > 0 { - rules = append(rules, &tailcfg.SSHRule{ - Principals: allPrincipals, - SSHUsers: baseUserMap, - Action: &action, - AcceptEnv: acceptEnv, - }) + appendRules(allPrincipals, 0, false) } } } else if hasLocalpart && slices.ContainsFunc(node.IPs(), srcIPs.Contains) { diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go index a7b55a2a6..e25166789 100644 --- a/hscontrol/policy/v2/policy.go +++ b/hscontrol/policy/v2/policy.go @@ -822,7 +822,7 @@ func (pm *PolicyManager) SetUsers(users []types.User) (bool, error) { // If SSH policies exist, force a policy change when users are updated // This ensures nodes get updated SSH policies even if other policy hashes didn't change - if pm.pol != nil && pm.pol.SSHs != nil && len(pm.pol.SSHs) > 0 { + if pm.pol != nil && len(pm.pol.SSHs) > 0 { return true, nil } @@ -878,15 +878,23 @@ func (pm *PolicyManager) SetNodes(nodes views.Slice[types.NodeView]) (bool, erro return false, nil } +// nodeIDViewMap indexes a slice of node views by node ID. On duplicate IDs the +// last view wins, matching the open-coded loops it replaces. +func nodeIDViewMap(s views.Slice[types.NodeView]) map[types.NodeID]types.NodeView { + m := make(map[types.NodeID]types.NodeView, s.Len()) + for _, n := range s.All() { + m[n.ID()] = n + } + + return m +} + func (pm *PolicyManager) nodesHavePolicyAffectingChanges(newNodes views.Slice[types.NodeView]) bool { if pm.nodes.Len() != newNodes.Len() { return true } - oldNodes := make(map[types.NodeID]types.NodeView, pm.nodes.Len()) - for _, node := range pm.nodes.All() { - oldNodes[node.ID()] = node - } + oldNodes := nodeIDViewMap(pm.nodes) for _, newNode := range newNodes.All() { oldNode, exists := oldNodes[newNode.ID()] @@ -1387,15 +1395,8 @@ func (pm *PolicyManager) DebugString() string { // the entire cache. func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.Slice[types.NodeView]) { // Build maps for efficient lookup - oldNodeMap := make(map[types.NodeID]types.NodeView) - for _, node := range oldNodes.All() { - oldNodeMap[node.ID()] = node - } - - newNodeMap := make(map[types.NodeID]types.NodeView) - for _, node := range newNodes.All() { - newNodeMap[node.ID()] = node - } + oldNodeMap := nodeIDViewMap(oldNodes) + newNodeMap := nodeIDViewMap(newNodes) // Track which users are affected by changes. // Tagged nodes don't participate in autogroup:self (identity is tag-based), @@ -1476,53 +1477,29 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S // For autogroup:self, we need to clear all nodes belonging to affected users // because autogroup:self rules depend on the entire user's device set. for nodeID := range pm.filterRulesMap { - // Find the user for this cached node + // Find the user for this cached node using the already-built indexes. + node, ok := newNodeMap[nodeID] + if !ok { + node, ok = oldNodeMap[nodeID] + } + + // Node not found in either old or new list, clear it. + if !ok { + delete(pm.filterRulesMap, nodeID) + delete(pm.matchersForNodeMap, nodeID) + + continue + } + + // Tagged nodes don't participate in autogroup:self, so their cache + // doesn't need user-based invalidation; leave nodeUserID at zero. var nodeUserID types.UserID - - found := false - - // Check in new nodes first - for _, node := range newNodes.All() { - if node.ID() == nodeID { - // Tagged nodes don't participate in autogroup:self, - // so their cache doesn't need user-based invalidation. - if node.IsTagged() { - found = true - break - } - - nodeUserID = node.TypedUserID() - found = true - - break - } + if !node.IsTagged() { + nodeUserID = node.TypedUserID() } - // If not found in new nodes, check old nodes - if !found { - for _, node := range oldNodes.All() { - if node.ID() == nodeID { - if node.IsTagged() { - found = true - break - } - - nodeUserID = node.TypedUserID() - found = true - - break - } - } - } - - // If we found the user and they're affected, clear this cache entry - if found { - if _, affected := affectedUsers[nodeUserID]; affected { - delete(pm.filterRulesMap, nodeID) - delete(pm.matchersForNodeMap, nodeID) - } - } else { - // Node not found in either old or new list, clear it + // If the owning user is affected, clear this cache entry. + if _, affected := affectedUsers[nodeUserID]; affected { delete(pm.filterRulesMap, nodeID) delete(pm.matchersForNodeMap, nodeID) } @@ -1553,15 +1530,8 @@ func (pm *PolicyManager) invalidateNodeCache(newNodes views.Slice[types.NodeView // invalidateGlobalPolicyCache invalidates only nodes whose properties affecting // [policyutil.ReduceFilterRules] changed. For global policies, each node's filter is independent. func (pm *PolicyManager) invalidateGlobalPolicyCache(newNodes views.Slice[types.NodeView]) { - oldNodeMap := make(map[types.NodeID]types.NodeView) - for _, node := range pm.nodes.All() { - oldNodeMap[node.ID()] = node - } - - newNodeMap := make(map[types.NodeID]types.NodeView) - for _, node := range newNodes.All() { - newNodeMap[node.ID()] = node - } + oldNodeMap := nodeIDViewMap(pm.nodes) + newNodeMap := nodeIDViewMap(newNodes) // Invalidate nodes whose properties changed for nodeID, newNode := range newNodeMap { diff --git a/hscontrol/policy/v2/sshtest.go b/hscontrol/policy/v2/sshtest.go index d2b07a631..968eb6a25 100644 --- a/hscontrol/policy/v2/sshtest.go +++ b/hscontrol/policy/v2/sshtest.go @@ -134,11 +134,7 @@ func (pm *PolicyManager) RunSSHTests() error { cache := make(map[types.NodeID]*tailcfg.SSHPolicy) results := runSSHPolicyTests(pm.pol, pm.users, pm.nodes, cache) - if results.AllPassed { - return nil - } - - return fmt.Errorf("%w:\n%s", errSSHPolicyTestsFailed, results.Errors()) + return wrapTestResult(errSSHPolicyTestsFailed, results.AllPassed, results.Errors) } // evaluateSSHTests runs the block against pol without mutating live state. @@ -154,11 +150,7 @@ func evaluateSSHTests( cache := make(map[types.NodeID]*tailcfg.SSHPolicy) results := runSSHPolicyTests(pol, users, nodes, cache) - if results.AllPassed { - return nil - } - - return fmt.Errorf("%w:\n%s", errSSHPolicyTestsFailed, results.Errors()) + return wrapTestResult(errSSHPolicyTestsFailed, results.AllPassed, results.Errors) } // runSSHPolicyTests evaluates every sshTests entry. The cache is keyed @@ -251,28 +243,21 @@ func runSSHPolicyTest( return res } - for _, user := range test.Accept { - evaluateAssertion( - pol, users, nodes, cache, - srcAddrs, dstNodes, user.String(), - assertAccept, &res, - ) - } - - for _, user := range test.Deny { - evaluateAssertion( - pol, users, nodes, cache, - srcAddrs, dstNodes, user.String(), - assertDeny, &res, - ) - } - - for _, user := range test.Check { - evaluateAssertion( - pol, users, nodes, cache, - srcAddrs, dstNodes, user.String(), - assertCheck, &res, - ) + for _, g := range []struct { + users []SSHUser + kind sshAssertion + }{ + {test.Accept, assertAccept}, + {test.Deny, assertDeny}, + {test.Check, assertCheck}, + } { + for _, user := range g.users { + evaluateAssertion( + pol, users, nodes, cache, + srcAddrs, dstNodes, user.String(), + g.kind, &res, + ) + } } return res diff --git a/hscontrol/policy/v2/test.go b/hscontrol/policy/v2/test.go index 5f1189f2e..876204501 100644 --- a/hscontrol/policy/v2/test.go +++ b/hscontrol/policy/v2/test.go @@ -196,6 +196,17 @@ func (r PolicyTestResults) Errors() string { return strings.Join(lines, "\n") } +// wrapTestResult returns nil when every test passed, otherwise wraps the +// rendered failure breakdown in sentinel. errs is passed uncalled so it is +// only evaluated on the failure path. +func wrapTestResult(sentinel error, allPassed bool, errs func() string) error { + if allPassed { + return nil + } + + return fmt.Errorf("%w:\n%s", sentinel, errs()) +} + // RunTests evaluates the policy's own `tests` block against the live compiled // filter and returns a wrapped error when any test fails. Callers that need // the per-test breakdown can call runPolicyTests directly. @@ -208,11 +219,8 @@ func (pm *PolicyManager) RunTests() error { defer pm.mu.Unlock() results := runPolicyTests(pm.pol, pm.filter, pm.users, pm.nodes) - if results.AllPassed { - return nil - } - return fmt.Errorf("%w:\n%s", errPolicyTestsFailed, results.Errors()) + return wrapTestResult(errPolicyTestsFailed, results.AllPassed, results.Errors) } // evaluateTests runs the `tests` block against a fresh compilation of pol. @@ -233,11 +241,8 @@ func evaluateTests(pol *Policy, users []types.User, nodes views.Slice[types.Node } results := runPolicyTests(pol, filter, users, nodes) - if results.AllPassed { - return nil - } - return fmt.Errorf("%w:\n%s", errPolicyTestsFailed, results.Errors()) + return wrapTestResult(errPolicyTestsFailed, results.AllPassed, results.Errors) } // runPolicyTests is the pure evaluation function: given a policy, the @@ -285,39 +290,28 @@ func runPolicyTest(test PolicyTest, pol *Policy, filter []tailcfg.FilterRule, us return res } - for _, dst := range test.Accept { - allowed, err := evalReachability(srcPrefixes, dst, test.Proto, pol, filter, users, nodes) - if err != nil { - res.Passed = false - res.Errors = append(res.Errors, fmt.Sprintf("error testing %q: %v", dst, err)) + check := func(dsts []string, wantAllowed bool, ok, fail *[]string) { + for _, dst := range dsts { + allowed, err := evalReachability(srcPrefixes, dst, test.Proto, pol, filter, users, nodes) + if err != nil { + res.Passed = false + res.Errors = append(res.Errors, fmt.Sprintf("error testing %q: %v", dst, err)) - continue - } + continue + } - if allowed { - res.AcceptOK = append(res.AcceptOK, dst) - } else { - res.Passed = false - res.AcceptFail = append(res.AcceptFail, dst) + if allowed == wantAllowed { + *ok = append(*ok, dst) + } else { + res.Passed = false + + *fail = append(*fail, dst) + } } } - for _, dst := range test.Deny { - allowed, err := evalReachability(srcPrefixes, dst, test.Proto, pol, filter, users, nodes) - if err != nil { - res.Passed = false - res.Errors = append(res.Errors, fmt.Sprintf("error testing %q: %v", dst, err)) - - continue - } - - if !allowed { - res.DenyOK = append(res.DenyOK, dst) - } else { - res.Passed = false - res.DenyFail = append(res.DenyFail, dst) - } - } + check(test.Accept, true, &res.AcceptOK, &res.AcceptFail) + check(test.Deny, false, &res.DenyOK, &res.DenyFail) return res } diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index d38d9cb08..d27967a82 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -20,6 +20,7 @@ import ( "tailscale.com/tailcfg" "tailscale.com/types/views" "tailscale.com/util/multierr" + "tailscale.com/util/set" "tailscale.com/util/slicesx" ) @@ -341,15 +342,15 @@ func (a Asterix) resolve(_ *Policy, _ types.Users, _ views.Slice[types.NodeView] // IPSet merges overlapping ranges (e.g. 10.0.0.0/8 absorbs // 10.33.0.0/16), but Tailscale preserves individual route entries. func approvedSubnetRoutes(nodes views.Slice[types.NodeView]) []string { - seen := make(map[string]bool) + seen := make(set.Set[string]) var routes []string for _, node := range nodes.All() { for _, route := range node.SubnetRoutes() { s := route.String() - if !seen[s] { - seen[s] = true + if !seen.Contains(s) { + seen.Add(s) routes = append(routes, s) } } From 03e2d24c79854b10245e49aea567871eb78f4549 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 16 Jun 2026 09:05:25 +0000 Subject: [PATCH 027/127] policy/v2, policy/matcher: consolidate alias and IP-set helpers --- hscontrol/policy/matcher/export_test.go | 31 +------ hscontrol/policy/matcher/matcher.go | 26 ++++-- hscontrol/policy/v2/compiled.go | 40 ++++----- hscontrol/policy/v2/types.go | 112 ++---------------------- 4 files changed, 47 insertions(+), 162 deletions(-) diff --git a/hscontrol/policy/matcher/export_test.go b/hscontrol/policy/matcher/export_test.go index c218d51a4..63ec9f09d 100644 --- a/hscontrol/policy/matcher/export_test.go +++ b/hscontrol/policy/matcher/export_test.go @@ -1,38 +1,13 @@ package matcher -import ( - "github.com/juanfont/headscale/hscontrol/util" - "go4.org/netipx" -) - // MatchFromStrings builds a [Match] from raw source and destination // strings. Unparseable entries are silently dropped (fail-open): the // resulting [Match] is narrower than the input described, but never // wider. Callers that need strict validation should pre-validate // their inputs via [util.ParseIPSet]. func MatchFromStrings(sources, destinations []string) Match { - srcs := new(netipx.IPSetBuilder) - dests := new(netipx.IPSetBuilder) - - for _, srcIP := range sources { - set, _ := util.ParseIPSet(srcIP, nil) - - srcs.AddSet(set) + return Match{ + srcs: buildIPSet(sources), + dests: buildIPSet(destinations), } - - for _, dest := range destinations { - set, _ := util.ParseIPSet(dest, nil) - - dests.AddSet(set) - } - - srcsSet, _ := srcs.IPSet() - destsSet, _ := dests.IPSet() - - match := Match{ - srcs: srcsSet, - dests: destsSet, - } - - return match } diff --git a/hscontrol/policy/matcher/matcher.go b/hscontrol/policy/matcher/matcher.go index 39e7b573c..55b36cd71 100644 --- a/hscontrol/policy/matcher/matcher.go +++ b/hscontrol/policy/matcher/matcher.go @@ -52,14 +52,8 @@ func MatchesFromFilterRules(rules []tailcfg.FilterRule) []Match { // [policy.ReduceNodes], hiding the cap target // from the source unless a companion IP-level rule also exists. func MatchFromFilterRule(rule tailcfg.FilterRule) Match { - srcs := new(netipx.IPSetBuilder) dests := new(netipx.IPSetBuilder) - for _, srcIP := range rule.SrcIPs { - set, _ := util.ParseIPSet(srcIP, nil) - srcs.AddSet(set) - } - for _, dp := range rule.DstPorts { set, _ := util.ParseIPSet(dp.IP, nil) dests.AddSet(set) @@ -71,15 +65,31 @@ func MatchFromFilterRule(rule tailcfg.FilterRule) Match { } } - srcsSet, _ := srcs.IPSet() destsSet, _ := dests.IPSet() return Match{ - srcs: srcsSet, + srcs: buildIPSet(rule.SrcIPs), dests: destsSet, } } +// buildIPSet parses each string via [util.ParseIPSet] and unions the +// results into a single [netipx.IPSet]. Unparseable entries are silently +// dropped (fail-open): the result is narrower than the input described, +// but never wider. +func buildIPSet(addrs []string) *netipx.IPSet { + builder := new(netipx.IPSetBuilder) + + for _, addr := range addrs { + set, _ := util.ParseIPSet(addr, nil) + builder.AddSet(set) + } + + set, _ := builder.IPSet() + + return set +} + func (m *Match) SrcsContainsIPs(ips ...netip.Addr) bool { return slices.ContainsFunc(ips, m.srcs.Contains) } diff --git a/hscontrol/policy/v2/compiled.go b/hscontrol/policy/v2/compiled.go index 900df6917..642100c7d 100644 --- a/hscontrol/policy/v2/compiled.go +++ b/hscontrol/policy/v2/compiled.go @@ -339,6 +339,20 @@ func (pol *Policy) compileOneGrant( return cg, nil } +// mergeResolvedSrcs merges every prefix from the resolved sources into a +// single [resolved] address set. +func mergeResolvedSrcs(resolvedSrcs []ResolvedAddresses) (resolved, error) { + var b netipx.IPSetBuilder + + for _, ips := range resolvedSrcs { + for _, pref := range ips.Prefixes() { + b.AddPrefix(pref) + } + } + + return newResolved(&b) +} + // compileOneViaGrant resolves sources for a via grant and stores the // deferred per-node data. The actual via-node matching and route // intersection happens in [compileViaForNode]. @@ -363,15 +377,7 @@ func (pol *Policy) compileOneViaGrant( } // Build merged SrcIPs. - var srcIPs netipx.IPSetBuilder - - for _, ips := range resolvedSrcs { - for _, pref := range ips.Prefixes() { - srcIPs.AddPrefix(pref) - } - } - - srcResolved, err := newResolved(&srcIPs) + srcResolved, err := mergeResolvedSrcs(resolvedSrcs) if err != nil { return nil, err } @@ -445,20 +451,8 @@ func buildSrcIPStrings( hasWildcard, hasDangerAll bool, nodes views.Slice[types.NodeView], ) []string { - var merged netipx.IPSetBuilder - - for _, ips := range resolvedSrcs { - for _, pref := range ips.Prefixes() { - merged.AddPrefix(pref) - } - } - - srcResolved, err := newResolved(&merged) - if err != nil { - return nil - } - - if srcResolved.Empty() { + srcResolved, err := mergeResolvedSrcs(resolvedSrcs) + if err != nil || srcResolved.Empty() { return nil } diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index d27967a82..b2b8fddf3 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -105,9 +105,6 @@ var nodeAttrUnsupportedCaps = map[tailcfg.NodeCapability]string{ // Policy validation errors. var ( - ErrUnknownAliasType = errors.New("unknown alias type") - ErrUnknownAutoApprover = errors.New("unknown auto approver type") - ErrUnknownOwnerType = errors.New("unknown owner type") ErrInvalidUsername = errors.New("username must contain @") ErrUserNotFound = errors.New("user not found") ErrMultipleUsersFound = errors.New("multiple users found") @@ -145,8 +142,6 @@ var ( ErrHostNotDefined = errors.New("host not defined in policy") ErrSSHSourceAliasNotSupported = errors.New("alias not supported for SSH source") ErrSSHDestAliasNotSupported = errors.New("alias not supported for SSH destination") - ErrUnknownSSHDestAlias = errors.New("unknown SSH destination alias type") - ErrUnknownSSHSrcAlias = errors.New("unknown SSH source alias type") ErrUnknownField = errors.New("unknown field") ErrProtocolNoSpecificPorts = errors.New("protocol does not support specific ports") ErrTestEmptyAssertions = errors.New("test entry must have at least one of \"accept\" or \"deny\"") @@ -263,26 +258,7 @@ func (a AliasWithPorts) MarshalJSON() ([]byte, error) { return []byte(`""`), nil } - var alias string - - switch v := a.Alias.(type) { - case *Username: - alias = string(*v) - case *Group: - alias = string(*v) - case *Tag: - alias = string(*v) - case *Host: - alias = string(*v) - case *Prefix: - alias = v.String() - case *AutoGroup: - alias = string(*v) - case Asterix: - alias = "*" - default: - return nil, fmt.Errorf("%w: %T", ErrUnknownAliasType, v) - } + alias := a.String() // If no ports are specified if len(a.Ports) == 0 { @@ -1133,24 +1109,7 @@ func (a *Aliases) MarshalJSON() ([]byte, error) { aliases := make([]string, len(*a)) for i, alias := range *a { - switch v := alias.(type) { - case *Username: - aliases[i] = string(*v) - case *Group: - aliases[i] = string(*v) - case *Tag: - aliases[i] = string(*v) - case *Host: - aliases[i] = string(*v) - case *Prefix: - aliases[i] = v.String() - case *AutoGroup: - aliases[i] = string(*v) - case Asterix: - aliases[i] = "*" - default: - return nil, fmt.Errorf("%w: %T", ErrUnknownAliasType, v) - } + aliases[i] = alias.String() } return json.Marshal(aliases) @@ -1227,16 +1186,7 @@ func (aa AutoApprovers) MarshalJSON() ([]byte, error) { approvers := make([]string, len(aa)) for i, approver := range aa { - switch v := approver.(type) { - case *Username: - approvers[i] = string(*v) - case *Tag: - approvers[i] = string(*v) - case *Group: - approvers[i] = string(*v) - default: - return nil, fmt.Errorf("%w: %T", ErrUnknownAutoApprover, v) - } + approvers[i] = approver.String() } return json.Marshal(approvers) @@ -1321,16 +1271,7 @@ func (o Owners) MarshalJSON() ([]byte, error) { owners := make([]string, len(o)) for i, owner := range o { - switch v := owner.(type) { - case *Username: - owners[i] = string(*v) - case *Group: - owners[i] = string(*v) - case *Tag: - owners[i] = string(*v) - default: - return nil, fmt.Errorf("%w: %T", ErrUnknownOwnerType, v) - } + owners[i] = owner.String() } return json.Marshal(owners) @@ -1525,16 +1466,7 @@ func (to TagOwners) MarshalJSON() ([]byte, error) { ownerStrs := make([]string, len(owners)) for i, owner := range owners { - switch v := owner.(type) { - case *Username: - ownerStrs[i] = string(*v) - case *Group: - ownerStrs[i] = string(*v) - case *Tag: - ownerStrs[i] = string(*v) - default: - return nil, fmt.Errorf("%w: %T", ErrUnknownOwnerType, v) - } + ownerStrs[i] = owner.String() } rawTagOwners[tagStr] = ownerStrs @@ -3021,22 +2953,9 @@ func (a SSHDstAliases) MarshalJSON() ([]byte, error) { aliases := make([]string, len(a)) for i, alias := range a { - switch v := alias.(type) { - case *Username: - aliases[i] = string(*v) - case *Tag: - aliases[i] = string(*v) - case *AutoGroup: - aliases[i] = string(*v) - case *Host: - aliases[i] = string(*v) - case Asterix: - // Marshal wildcard as "*" so it gets rejected during unmarshal - // with a proper error message explaining alternatives - aliases[i] = "*" - default: - return nil, fmt.Errorf("%w: %T", ErrUnknownSSHDestAlias, v) - } + // A wildcard renders as "*" so it gets rejected during unmarshal + // with a proper error message explaining alternatives. + aliases[i] = alias.String() } return json.Marshal(aliases) @@ -3050,20 +2969,7 @@ func (a *SSHSrcAliases) MarshalJSON() ([]byte, error) { aliases := make([]string, len(*a)) for i, alias := range *a { - switch v := alias.(type) { - case *Username: - aliases[i] = string(*v) - case *Group: - aliases[i] = string(*v) - case *Tag: - aliases[i] = string(*v) - case *AutoGroup: - aliases[i] = string(*v) - case Asterix: - aliases[i] = "*" - default: - return nil, fmt.Errorf("%w: %T", ErrUnknownSSHSrcAlias, v) - } + aliases[i] = alias.String() } return json.Marshal(aliases) From 3979887d8d27d7f23aac24731b59cf63322cdfaf Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 16 Jun 2026 11:33:35 +0000 Subject: [PATCH 028/127] mapper: consolidate builder and connection helpers --- hscontrol/mapper/batcher.go | 12 ++---------- hscontrol/mapper/builder.go | 25 +++++++++++++++---------- hscontrol/mapper/mapper.go | 34 ++++++++++++++++++++-------------- hscontrol/mapper/node_conn.go | 10 ++++++++++ 4 files changed, 47 insertions(+), 34 deletions(-) diff --git a/hscontrol/mapper/batcher.go b/hscontrol/mapper/batcher.go index bcbcbae7d..078ebd8b5 100644 --- a/hscontrol/mapper/batcher.go +++ b/hscontrol/mapper/batcher.go @@ -315,11 +315,7 @@ func (b *Batcher) AddNode( initialMap, err := b.MapResponseFromChange(id, change.FullSelf(id)) if err != nil { nlog.Error().Err(err).Msg("initial map generation failed") - nodeConn.removeConnectionByChannel(c) - - if !nodeConn.hasActiveConnections() { - nodeConn.markDisconnected() - } + nodeConn.detach(c) return fmt.Errorf("generating initial map for node %d: %w", id, err) } @@ -343,11 +339,7 @@ func (b *Batcher) AddNode( nlog.Error().Err(ErrInitialMapSendTimeout).Msg("initial map send timeout") nlog.Debug().Caller().Dur("timeout.duration", 5*time.Second). //nolint:mnd Msg("initial map send timed out because channel was blocked or receiver not ready") - nodeConn.removeConnectionByChannel(c) - - if !nodeConn.hasActiveConnections() { - nodeConn.markDisconnected() - } + nodeConn.detach(c) return fmt.Errorf("%w for node %d", ErrInitialMapSendTimeout, id) } diff --git a/hscontrol/mapper/builder.go b/hscontrol/mapper/builder.go index ee3653ae4..ba773e6b0 100644 --- a/hscontrol/mapper/builder.go +++ b/hscontrol/mapper/builder.go @@ -61,6 +61,16 @@ func (b *MapResponseBuilder) hasErrors() bool { return len(b.errs) > 0 } +// node looks up the requesting node, recording ErrNodeNotFoundMapper on miss. +func (b *MapResponseBuilder) node() (types.NodeView, bool) { + nv, ok := b.mapper.state.GetNodeByID(b.nodeID) + if !ok { + b.addError(ErrNodeNotFoundMapper) + } + + return nv, ok +} + // WithCapabilityVersion sets the capability version for the response. func (b *MapResponseBuilder) WithCapabilityVersion(capVer tailcfg.CapabilityVersion) *MapResponseBuilder { b.capVer = capVer @@ -69,9 +79,8 @@ func (b *MapResponseBuilder) WithCapabilityVersion(capVer tailcfg.CapabilityVers // WithSelfNode adds the requesting node to the response. func (b *MapResponseBuilder) WithSelfNode() *MapResponseBuilder { - nv, ok := b.mapper.state.GetNodeByID(b.nodeID) + nv, ok := b.node() if !ok { - b.addError(ErrNodeNotFoundMapper) return b } @@ -136,9 +145,8 @@ func (b *MapResponseBuilder) WithDebugConfig() *MapResponseBuilder { // WithSSHPolicy adds SSH policy configuration for the requesting node. func (b *MapResponseBuilder) WithSSHPolicy() *MapResponseBuilder { - node, ok := b.mapper.state.GetNodeByID(b.nodeID) + node, ok := b.node() if !ok { - b.addError(ErrNodeNotFoundMapper) return b } @@ -155,9 +163,8 @@ func (b *MapResponseBuilder) WithSSHPolicy() *MapResponseBuilder { // WithDNSConfig adds DNS configuration for the requesting node. func (b *MapResponseBuilder) WithDNSConfig() *MapResponseBuilder { - node, ok := b.mapper.state.GetNodeByID(b.nodeID) + node, ok := b.node() if !ok { - b.addError(ErrNodeNotFoundMapper) return b } @@ -168,9 +175,8 @@ func (b *MapResponseBuilder) WithDNSConfig() *MapResponseBuilder { // WithUserProfiles adds user profiles for the requesting node and given peers. func (b *MapResponseBuilder) WithUserProfiles(peers views.Slice[types.NodeView]) *MapResponseBuilder { - node, ok := b.mapper.state.GetNodeByID(b.nodeID) + node, ok := b.node() if !ok { - b.addError(ErrNodeNotFoundMapper) return b } @@ -185,9 +191,8 @@ func (b *MapResponseBuilder) WithUserProfiles(peers views.Slice[types.NodeView]) // For autogroup:self policies, it returns per-node compiled rules. // For global policies, it returns the global filter reduced for this node. func (b *MapResponseBuilder) WithPacketFilters() *MapResponseBuilder { - node, ok := b.mapper.state.GetNodeByID(b.nodeID) + node, ok := b.node() if !ok { - b.addError(ErrNodeNotFoundMapper) return b } diff --git a/hscontrol/mapper/mapper.go b/hscontrol/mapper/mapper.go index af5852ce2..8b83a62a2 100644 --- a/hscontrol/mapper/mapper.go +++ b/hscontrol/mapper/mapper.go @@ -499,15 +499,9 @@ func (m *mapper) filterVisiblePeerPatches( return nil } - var filtered []*tailcfg.PeerChange - - for _, patch := range patches { - if _, vis := visible[patch.NodeID]; vis { - filtered = append(filtered, patch) - } - } - - return filtered + return filterByVisible(visible, patches, func(p *tailcfg.PeerChange) tailcfg.NodeID { + return p.NodeID + }) } // filterVisibleNodes restricts a peer slice to the nodes the recipient can see @@ -524,15 +518,27 @@ func (m *mapper) filterVisibleNodes( return views.SliceOf([]types.NodeView{}) } - var filtered []types.NodeView + return views.SliceOf(filterByVisible(visible, peers.AsSlice(), func(p types.NodeView) tailcfg.NodeID { + return p.ID().NodeID() + })) +} - for _, peer := range peers.All() { - if _, vis := visible[peer.ID().NodeID()]; vis { - filtered = append(filtered, peer) +// filterByVisible keeps only the items whose key resolves to a NodeID present +// in the visible set, preserving input order. +func filterByVisible[T any]( + visible map[tailcfg.NodeID]struct{}, + items []T, + key func(T) tailcfg.NodeID, +) []T { + var filtered []T + + for _, it := range items { + if _, ok := visible[key(it)]; ok { + filtered = append(filtered, it) } } - return views.SliceOf(filtered) + return filtered } func writeDebugMapResponse( diff --git a/hscontrol/mapper/node_conn.go b/hscontrol/mapper/node_conn.go index b91d2c7c2..18ab34f41 100644 --- a/hscontrol/mapper/node_conn.go +++ b/hscontrol/mapper/node_conn.go @@ -183,6 +183,16 @@ func (mc *multiChannelNodeConn) removeConnectionByChannel(c chan<- *tailcfg.MapR return false } +// detach removes the connection for the given channel and marks the node +// disconnected if no active connections remain. +func (mc *multiChannelNodeConn) detach(c chan<- *tailcfg.MapResponse) { + mc.removeConnectionByChannel(c) + + if !mc.hasActiveConnections() { + mc.markDisconnected() + } +} + // hasActiveConnections checks if the node has any active connections. func (mc *multiChannelNodeConn) hasActiveConnections() bool { mc.mutex.RLock() From 8c10a96921dc89c7afc7aafce33f46f54402bca5 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 16 Jun 2026 09:00:19 +0000 Subject: [PATCH 029/127] hscontrol: consolidate debug-endpoint and template helpers --- hscontrol/debug.go | 233 ++++++----------------------------- hscontrol/handlers.go | 21 ++-- hscontrol/oidc.go | 10 +- hscontrol/platform_config.go | 84 ++++--------- 4 files changed, 76 insertions(+), 272 deletions(-) diff --git a/hscontrol/debug.go b/hscontrol/debug.go index 49be244f2..c9e44637c 100644 --- a/hscontrol/debug.go +++ b/hscontrol/debug.go @@ -48,51 +48,45 @@ func protectedDebugHandler(h http.Handler) http.Handler { }) } +// writeJSON marshals v with indentation and writes it as a 200 JSON response. +func writeJSON(w http.ResponseWriter, v any) { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + httpError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(b) +} + +// writeDebug renders a debug endpoint as JSON or text/plain depending on the +// request's Accept header. JSON is produced only when explicitly requested; +// text/plain is the default for backward compatibility. +func writeDebug(w http.ResponseWriter, r *http.Request, jsonVal func() any, textVal func() string) { + if strings.Contains(r.Header.Get("Accept"), "application/json") { + writeJSON(w, jsonVal()) + return + } + + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(textVal())) +} + func (h *Headscale) debugHTTPServer() *http.Server { debugMux := http.NewServeMux() debug := tsweb.Debugger(debugMux) // State overview endpoint debug.Handle("overview", "State overview", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check Accept header to determine response format - acceptHeader := r.Header.Get("Accept") - wantsJSON := strings.Contains(acceptHeader, "application/json") - - if wantsJSON { - overview := h.state.DebugOverviewJSON() - - overviewJSON, err := json.MarshalIndent(overview, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(overviewJSON) - } else { - // Default to text/plain for backward compatibility - overview := h.state.DebugOverview() - - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(overview)) - } + writeDebug(w, r, func() any { return h.state.DebugOverviewJSON() }, h.state.DebugOverview) })) // Configuration endpoint debug.Handle("config", "Current configuration", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - config := h.state.DebugConfig() - - configJSON, err := json.MarshalIndent(config, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(configJSON) + writeJSON(w, h.state.DebugConfig()) })) // Policy endpoint @@ -123,157 +117,37 @@ func (h *Headscale) debugHTTPServer() *http.Server { return } - filterJSON, err := json.MarshalIndent(filter, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(filterJSON) + writeJSON(w, filter) })) // SSH policies endpoint debug.Handle("ssh", "SSH policies per node", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sshPolicies := h.state.DebugSSHPolicies() - - sshJSON, err := json.MarshalIndent(sshPolicies, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(sshJSON) + writeJSON(w, h.state.DebugSSHPolicies()) })) // DERP map endpoint debug.Handle("derp", "DERP map configuration", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check Accept header to determine response format - acceptHeader := r.Header.Get("Accept") - wantsJSON := strings.Contains(acceptHeader, "application/json") - - if wantsJSON { - derpInfo := h.state.DebugDERPJSON() - - derpJSON, err := json.MarshalIndent(derpInfo, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(derpJSON) - } else { - // Default to text/plain for backward compatibility - derpInfo := h.state.DebugDERPMap() - - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(derpInfo)) - } + writeDebug(w, r, func() any { return h.state.DebugDERPJSON() }, h.state.DebugDERPMap) })) // [state.NodeStore] endpoint debug.Handle("nodestore", "NodeStore information", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check Accept header to determine response format - acceptHeader := r.Header.Get("Accept") - wantsJSON := strings.Contains(acceptHeader, "application/json") - - if wantsJSON { - nodeStoreNodes := h.state.DebugNodeStoreJSON() - - nodeStoreJSON, err := json.MarshalIndent(nodeStoreNodes, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(nodeStoreJSON) - } else { - // Default to text/plain for backward compatibility - nodeStoreInfo := h.state.DebugNodeStore() - - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(nodeStoreInfo)) - } + writeDebug(w, r, func() any { return h.state.DebugNodeStoreJSON() }, h.state.DebugNodeStore) })) // Registration cache endpoint debug.Handle("registration-cache", "Registration cache information", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - cacheInfo := h.state.DebugRegistrationCache() - - cacheJSON, err := json.MarshalIndent(cacheInfo, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(cacheJSON) + writeJSON(w, h.state.DebugRegistrationCache()) })) // Routes endpoint debug.Handle("routes", "Primary routes", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check Accept header to determine response format - acceptHeader := r.Header.Get("Accept") - wantsJSON := strings.Contains(acceptHeader, "application/json") - - if wantsJSON { - routes := h.state.DebugRoutes() - - routesJSON, err := json.MarshalIndent(routes, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(routesJSON) - } else { - // Default to text/plain for backward compatibility - routes := h.state.DebugRoutesString() - - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(routes)) - } + writeDebug(w, r, func() any { return h.state.DebugRoutes() }, h.state.DebugRoutesString) })) // Policy manager endpoint debug.Handle("policy-manager", "Policy manager state", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check Accept header to determine response format - acceptHeader := r.Header.Get("Accept") - wantsJSON := strings.Contains(acceptHeader, "application/json") - - if wantsJSON { - policyManagerInfo := h.state.DebugPolicyManagerJSON() - - policyManagerJSON, err := json.MarshalIndent(policyManagerInfo, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(policyManagerJSON) - } else { - // Default to text/plain for backward compatibility - policyManagerInfo := h.state.DebugPolicyManager() - - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(policyManagerInfo)) - } + writeDebug(w, r, func() any { return h.state.DebugPolicyManagerJSON() }, h.state.DebugPolicyManager) })) debug.Handle("mapresponses", "Map responses for all nodes", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -290,43 +164,12 @@ func (h *Headscale) debugHTTPServer() *http.Server { return } - resJSON, err := json.MarshalIndent(res, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(resJSON) + writeJSON(w, res) })) // [mapper.Batcher] endpoint debug.Handle("batcher", "Batcher connected nodes", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Check Accept header to determine response format - acceptHeader := r.Header.Get("Accept") - wantsJSON := strings.Contains(acceptHeader, "application/json") - - if wantsJSON { - batcherInfo := h.debugBatcherJSON() - - batcherJSON, err := json.MarshalIndent(batcherInfo, "", " ") - if err != nil { - httpError(w, err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(batcherJSON) - } else { - // Default to text/plain for backward compatibility - batcherInfo := h.debugBatcher() - - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(batcherInfo)) - } + writeDebug(w, r, func() any { return h.debugBatcherJSON() }, h.debugBatcher) })) // Ping endpoint: sends a [tailcfg.PingRequest] to a node and waits for it to respond. diff --git a/hscontrol/handlers.go b/hscontrol/handlers.go index 750915ec8..f8d2f1780 100644 --- a/hscontrol/handlers.go +++ b/hscontrol/handlers.go @@ -300,18 +300,23 @@ func NewAuthProviderWeb(serverURL string) *AuthProviderWeb { } } -func (a *AuthProviderWeb) RegisterURL(authID types.AuthID) string { +// authPathURL builds an auth-flow URL of the form +// "//", trimming a trailing slash from serverURL. +func authPathURL(serverURL, kind string, authID types.AuthID) string { return fmt.Sprintf( - "%s/register/%s", - strings.TrimSuffix(a.serverURL, "/"), - authID.String()) + "%s/%s/%s", + strings.TrimSuffix(serverURL, "/"), + kind, + authID.String(), + ) +} + +func (a *AuthProviderWeb) RegisterURL(authID types.AuthID) string { + return authPathURL(a.serverURL, "register", authID) } func (a *AuthProviderWeb) AuthURL(authID types.AuthID) string { - return fmt.Sprintf( - "%s/auth/%s", - strings.TrimSuffix(a.serverURL, "/"), - authID.String()) + return authPathURL(a.serverURL, "auth", authID) } func (a *AuthProviderWeb) AuthHandler( diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index 7f1e3fae5..706b58206 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -116,10 +116,7 @@ func NewAuthProviderOIDC( } func (a *AuthProviderOIDC) AuthURL(authID types.AuthID) string { - return fmt.Sprintf( - "%s/auth/%s", - strings.TrimSuffix(a.serverURL, "/"), - authID.String()) + return authPathURL(a.serverURL, "auth", authID) } func (a *AuthProviderOIDC) AuthHandler( @@ -130,10 +127,7 @@ func (a *AuthProviderOIDC) AuthHandler( } func (a *AuthProviderOIDC) RegisterURL(authID types.AuthID) string { - return fmt.Sprintf( - "%s/register/%s", - strings.TrimSuffix(a.serverURL, "/"), - authID.String()) + return authPathURL(a.serverURL, "register", authID) } // RegisterHandler registers the OIDC callback handler with the given router. diff --git a/hscontrol/platform_config.go b/hscontrol/platform_config.go index bc19d7ca0..28a918bce 100644 --- a/hscontrol/platform_config.go +++ b/hscontrol/platform_config.go @@ -3,7 +3,6 @@ package hscontrol import ( "bytes" _ "embed" - "html/template" "net/http" textTemplate "text/template" @@ -59,32 +58,20 @@ func (h *Headscale) ApplePlatformConfig( URL: h.cfg.ServerURL, } - var payload bytes.Buffer - - switch platform { - case "macos-standalone": - err := macosStandaloneTemplate.Execute(&payload, platformConfig) - if err != nil { - httpError(writer, err) - return - } - case "macos-app-store": - err := macosAppStoreTemplate.Execute(&payload, platformConfig) - if err != nil { - httpError(writer, err) - return - } - case "ios": - err := iosTemplate.Execute(&payload, platformConfig) - if err != nil { - httpError(writer, err) - return - } - default: + payloadType, ok := applePayloadType[platform] + if !ok { httpError(writer, NewHTTPError(http.StatusBadRequest, "platform must be ios, macos-app-store or macos-standalone", nil)) return } + platformConfig.PayloadType = payloadType + + var payload bytes.Buffer + if err := payloadTemplate.Execute(&payload, platformConfig); err != nil { //nolint:noinlineerr + httpError(writer, err) + return + } + config := AppleMobileConfig{ UUID: id, URL: h.cfg.ServerURL, @@ -110,8 +97,17 @@ type AppleMobileConfig struct { } type AppleMobilePlatformConfig struct { - UUID uuid.UUID - URL string + UUID uuid.UUID + URL string + PayloadType string +} + +// applePayloadType maps a platform request path to the Tailscale IPN +// PayloadType emitted in the rendered Apple profile. +var applePayloadType = map[string]string{ + "ios": "io.tailscale.ipn.ios", + "macos-app-store": "io.tailscale.ipn.macos", + "macos-standalone": "io.tailscale.ipn.macsys", } var commonTemplate = textTemplate.Must( @@ -141,10 +137,10 @@ var commonTemplate = textTemplate.Must( `), ) -var iosTemplate = textTemplate.Must(textTemplate.New("iosTemplate").Parse(` +var payloadTemplate = textTemplate.Must(textTemplate.New("payloadTemplate").Parse(` PayloadType - io.tailscale.ipn.ios + {{.PayloadType}} PayloadUUID {{.UUID}} PayloadIdentifier @@ -158,37 +154,3 @@ var iosTemplate = textTemplate.Must(textTemplate.New("iosTemplate").Parse(` {{.URL}} `)) - -var macosAppStoreTemplate = template.Must(template.New("macosTemplate").Parse(` - - PayloadType - io.tailscale.ipn.macos - PayloadUUID - {{.UUID}} - PayloadIdentifier - com.github.juanfont.headscale - PayloadVersion - 1 - PayloadEnabled - - ControlURL - {{.URL}} - -`)) - -var macosStandaloneTemplate = template.Must(template.New("macosStandaloneTemplate").Parse(` - - PayloadType - io.tailscale.ipn.macsys - PayloadUUID - {{.UUID}} - PayloadIdentifier - com.github.juanfont.headscale - PayloadVersion - 1 - PayloadEnabled - - ControlURL - {{.URL}} - -`)) From 468f70a9e502e59553d3ad3cf6e8a7414b1c97a4 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 16 Jun 2026 08:21:29 +0000 Subject: [PATCH 030/127] templates: consolidate shared page and component helpers --- hscontrol/templates/apple.go | 333 ++++++++++++------------ hscontrol/templates/auth_error.go | 10 +- hscontrol/templates/auth_success.go | 28 +- hscontrol/templates/auth_web.go | 14 +- hscontrol/templates/design.go | 72 +++-- hscontrol/templates/general.go | 81 +++--- hscontrol/templates/ping.go | 45 ++-- hscontrol/templates/register_confirm.go | 69 +++-- hscontrol/templates/windows.go | 26 +- 9 files changed, 332 insertions(+), 346 deletions(-) diff --git a/hscontrol/templates/apple.go b/hscontrol/templates/apple.go index 3b1200696..8376b19a5 100644 --- a/hscontrol/templates/apple.go +++ b/hscontrol/templates/apple.go @@ -1,191 +1,196 @@ package templates import ( - "fmt" - "github.com/chasefleming/elem-go" "github.com/chasefleming/elem-go/attrs" "github.com/chasefleming/elem-go/styles" ) func Apple(url string) *elem.Element { - return HtmlStructure( - elem.Title(nil, - elem.Text("headscale - Apple")), - mdTypesetBody( - headscaleLogo(), - H1(elem.Text("iOS configuration")), - H2(elem.Text("GUI")), - Ol( - elem.Li( - nil, - elem.Text("Install the official Tailscale iOS client from the "), - externalLink("https://apps.apple.com/app/tailscale/id1470499037", "App Store"), - ), - elem.Li( - nil, - elem.Text("Open the "), - elem.Strong(nil, elem.Text("Tailscale")), - elem.Text(" app"), - ), - elem.Li( - nil, - elem.Text("Click the account icon in the top-right corner and select "), - elem.Strong(nil, elem.Text("Log in…")), - ), - elem.Li( - nil, - elem.Text("Tap the top-right options menu button and select "), - elem.Strong(nil, elem.Text("Use custom coordination server")), - ), - elem.Li( - nil, - elem.Text("Enter your instance URL: "), - Code(elem.Text(url)), - ), - elem.Li( - nil, - elem.Text( - "Enter your credentials and log in. Headscale should now be working on your iOS device", - ), - ), + return page( + "headscale - Apple", + H1(elem.Text("iOS configuration")), + H2(elem.Text("GUI")), + Ol( + elem.Li( + nil, + elem.Text("Install the official Tailscale iOS client from the "), + externalLink("https://apps.apple.com/app/tailscale/id1470499037", "App Store"), ), - H1(elem.Text("macOS configuration")), - H2(elem.Text("Command line")), - P( - elem.Text("Use Tailscale's login command to add your profile:"), + elem.Li( + nil, + elem.Text("Open the "), + elem.Strong(nil, elem.Text("Tailscale")), + elem.Text(" app"), ), - Pre(PreCode("tailscale login --login-server "+url)), - H2(elem.Text("GUI")), - Ol( - elem.Li( - nil, - elem.Text("Option + Click the "), - elem.Strong(nil, elem.Text("Tailscale")), - elem.Text(" icon in the menu and hover over the "), - elem.Strong(nil, elem.Text("Debug")), - elem.Text(" menu"), - ), - elem.Li(nil, - elem.Text("Under "), - elem.Strong(nil, elem.Text("Custom Login Server")), - elem.Text(", select "), - elem.Strong(nil, elem.Text("Add Account...")), - ), - elem.Li( - nil, - elem.Text("Enter "), - Code(elem.Text(url)), - elem.Text(" of the headscale instance and press "), - elem.Strong(nil, elem.Text("Add Account")), - ), - elem.Li(nil, - elem.Text("Follow the login procedure in the browser"), - ), + elem.Li( + nil, + elem.Text("Click the account icon in the top-right corner and select "), + elem.Strong(nil, elem.Text("Log in…")), ), - H2(elem.Text("Profiles")), - P( + elem.Li( + nil, + elem.Text("Tap the top-right options menu button and select "), + elem.Strong(nil, elem.Text("Use custom coordination server")), + ), + elem.Li( + nil, + elem.Text("Enter your instance URL: "), + Code(elem.Text(url)), + ), + elem.Li( + nil, elem.Text( - "Headscale can be set to the default server by installing a Headscale configuration profile:", + "Enter your credentials and log in. Headscale should now be working on your iOS device", ), ), - elem.Div(attrs.Props{attrs.Style: styles.Props{styles.MarginTop: spaceL, styles.MarginBottom: spaceL}.ToInline()}, - downloadButton("/apple/macos-app-store", "macOS AppStore profile"), - downloadButton("/apple/macos-standalone", "macOS Standalone profile"), + ), + H1(elem.Text("macOS configuration")), + H2(elem.Text("Command line")), + P( + elem.Text("Use Tailscale's login command to add your profile:"), + ), + codeBlockText("tailscale login --login-server "+url), + H2(elem.Text("GUI")), + Ol( + elem.Li( + nil, + elem.Text("Option + Click the "), + elem.Strong(nil, elem.Text("Tailscale")), + elem.Text(" icon in the menu and hover over the "), + elem.Strong(nil, elem.Text("Debug")), + elem.Text(" menu"), ), - Ol( - elem.Li( - nil, - elem.Text( - "Download the profile, then open it. When it has been opened, there should be a notification that a profile can be installed", - ), - ), - elem.Li(nil, - elem.Text("Open "), - elem.Strong(nil, elem.Text("System Preferences")), - elem.Text(" and go to "), - elem.Strong(nil, elem.Text("Profiles")), - ), - elem.Li(nil, - elem.Text("Find and install the "), - elem.Strong(nil, elem.Text("Headscale")), - elem.Text(" profile"), - ), - elem.Li(nil, - elem.Text("Restart "), - elem.Strong(nil, elem.Text("Tailscale.app")), - elem.Text(" and log in"), - ), + elem.Li( + nil, + elem.Text("Under "), + elem.Strong(nil, elem.Text("Custom Login Server")), + elem.Text(", select "), + elem.Strong(nil, elem.Text("Add Account...")), ), - orDivider(), - P( + elem.Li( + nil, + elem.Text("Enter "), + Code(elem.Text(url)), + elem.Text(" of the headscale instance and press "), + elem.Strong(nil, elem.Text("Add Account")), + ), + elem.Li( + nil, + elem.Text("Follow the login procedure in the browser"), + ), + ), + H2(elem.Text("Profiles")), + P( + elem.Text( + "Headscale can be set to the default server by installing a Headscale configuration profile:", + ), + ), + elem.Div( + attrs.Props{attrs.Style: styles.Props{styles.MarginTop: spaceL, styles.MarginBottom: spaceL}.ToInline()}, + downloadButton("/apple/macos-app-store", "macOS AppStore profile"), + downloadButton("/apple/macos-standalone", "macOS Standalone profile"), + ), + Ol( + elem.Li( + nil, elem.Text( - "Use your terminal to configure the default setting for Tailscale by issuing one of the following commands:", + "Download the profile, then open it. When it has been opened, there should be a notification that a profile can be installed", ), ), - P(elem.Text("For app store client:")), - Pre(PreCode("defaults write io.tailscale.ipn.macos ControlURL "+url)), - P(elem.Text("For standalone client:")), - Pre(PreCode("defaults write io.tailscale.ipn.macsys ControlURL "+url)), - P( + elem.Li( + nil, + elem.Text("Open "), + elem.Strong(nil, elem.Text("System Preferences")), + elem.Text(" and go to "), + elem.Strong(nil, elem.Text("Profiles")), + ), + elem.Li( + nil, + elem.Text("Find and install the "), + elem.Strong(nil, elem.Text("Headscale")), + elem.Text(" profile"), + ), + elem.Li( + nil, elem.Text("Restart "), elem.Strong(nil, elem.Text("Tailscale.app")), - elem.Text(" and log in."), + elem.Text(" and log in"), ), - warningBox("Caution", "You should always download and inspect the profile before installing it."), - P(elem.Text("For app store client:")), - Pre(PreCode(fmt.Sprintf(`curl %s/apple/macos-app-store`, url))), - P(elem.Text("For standalone client:")), - Pre(PreCode(fmt.Sprintf(`curl %s/apple/macos-standalone`, url))), - H1(elem.Text("tvOS configuration")), - H2(elem.Text("GUI")), - Ol( - elem.Li( - nil, - elem.Text("Install the official Tailscale tvOS client from the "), - externalLink("https://apps.apple.com/app/tailscale/id1470499037", "App Store"), - ), - elem.Li( - nil, - elem.Text("Open "), - elem.Strong(nil, elem.Text("Settings")), - elem.Text(" (the Apple tvOS settings) > "), - elem.Strong(nil, elem.Text("Apps")), - elem.Text(" > "), - elem.Strong(nil, elem.Text("Tailscale")), - ), - elem.Li( - nil, - elem.Text("Enter "), - Code(elem.Text(url)), - elem.Text(" under "), - elem.Strong(nil, elem.Text("ALTERNATE COORDINATION SERVER URL")), - ), - elem.Li(nil, - elem.Text("Return to the tvOS "), - elem.Strong(nil, elem.Text("Home")), - elem.Text(" screen"), - ), - elem.Li(nil, - elem.Text("Open "), - elem.Strong(nil, elem.Text("Tailscale")), - ), - elem.Li(nil, - elem.Text("Select "), - elem.Strong(nil, elem.Text("Install VPN configuration")), - ), - elem.Li(nil, - elem.Text("Select "), - elem.Strong(nil, elem.Text("Allow")), - ), - elem.Li(nil, - elem.Text("Scan the QR code and follow the login procedure"), - ), - elem.Li(nil, - elem.Text("Headscale should now be working on your tvOS device"), - ), + ), + orDivider(), + P( + elem.Text( + "Use your terminal to configure the default setting for Tailscale by issuing one of the following commands:", + ), + ), + P(elem.Text("For app store client:")), + codeBlockText("defaults write io.tailscale.ipn.macos ControlURL "+url), + P(elem.Text("For standalone client:")), + codeBlockText("defaults write io.tailscale.ipn.macsys ControlURL "+url), + P( + elem.Text("Restart "), + elem.Strong(nil, elem.Text("Tailscale.app")), + elem.Text(" and log in."), + ), + warningBox("Caution", "You should always download and inspect the profile before installing it."), + P(elem.Text("For app store client:")), + codeBlockText("curl "+url+"/apple/macos-app-store"), + P(elem.Text("For standalone client:")), + codeBlockText("curl "+url+"/apple/macos-standalone"), + H1(elem.Text("tvOS configuration")), + H2(elem.Text("GUI")), + Ol( + elem.Li( + nil, + elem.Text("Install the official Tailscale tvOS client from the "), + externalLink("https://apps.apple.com/app/tailscale/id1470499037", "App Store"), + ), + elem.Li( + nil, + elem.Text("Open "), + elem.Strong(nil, elem.Text("Settings")), + elem.Text(" (the Apple tvOS settings) > "), + elem.Strong(nil, elem.Text("Apps")), + elem.Text(" > "), + elem.Strong(nil, elem.Text("Tailscale")), + ), + elem.Li( + nil, + elem.Text("Enter "), + Code(elem.Text(url)), + elem.Text(" under "), + elem.Strong(nil, elem.Text("ALTERNATE COORDINATION SERVER URL")), + ), + elem.Li( + nil, + elem.Text("Return to the tvOS "), + elem.Strong(nil, elem.Text("Home")), + elem.Text(" screen"), + ), + elem.Li( + nil, + elem.Text("Open "), + elem.Strong(nil, elem.Text("Tailscale")), + ), + elem.Li( + nil, + elem.Text("Select "), + elem.Strong(nil, elem.Text("Install VPN configuration")), + ), + elem.Li( + nil, + elem.Text("Select "), + elem.Strong(nil, elem.Text("Allow")), + ), + elem.Li( + nil, + elem.Text("Scan the QR code and follow the login procedure"), + ), + elem.Li( + nil, + elem.Text("Headscale should now be working on your tvOS device"), ), - pageFooter(), ), ) } diff --git a/hscontrol/templates/auth_error.go b/hscontrol/templates/auth_error.go index 7b48974c1..8bcaa8063 100644 --- a/hscontrol/templates/auth_error.go +++ b/hscontrol/templates/auth_error.go @@ -30,12 +30,8 @@ func AuthError(result AuthErrorResult) *elem.Element { elem.Text(result.Message), ) - return HtmlStructure( - elem.Title(nil, elem.Text(result.Title)), - mdTypesetBody( - headscaleLogo(), - box, - pageFooter(), - ), + return page( + result.Title, + box, ) } diff --git a/hscontrol/templates/auth_success.go b/hscontrol/templates/auth_success.go index c0f275e54..631de3b14 100644 --- a/hscontrol/templates/auth_success.go +++ b/hscontrol/templates/auth_success.go @@ -41,22 +41,20 @@ func AuthSuccess(result AuthSuccessResult) *elem.Element { elem.Text(". "+result.Message), ) - return HtmlStructure( - elem.Title(nil, elem.Text(result.Title)), - mdTypesetBody( - headscaleLogo(), - box, - H2(elem.Text("Getting started")), - P(elem.Text("Check out the documentation to learn more about headscale and Tailscale:")), - Ul( - elem.Li(nil, - externalLink("https://headscale.net/stable/", "Headscale documentation"), - ), - elem.Li(nil, - externalLink("https://tailscale.com/docs", "Tailscale docs"), - ), + return page( + result.Title, + box, + H2(elem.Text("Getting started")), + P(elem.Text("Check out the documentation to learn more about headscale and Tailscale:")), + Ul( + elem.Li( + nil, + externalLink("https://headscale.net/stable/", "Headscale documentation"), + ), + elem.Li( + nil, + externalLink("https://tailscale.com/docs", "Tailscale docs"), ), - pageFooter(), ), ) } diff --git a/hscontrol/templates/auth_web.go b/hscontrol/templates/auth_web.go index 8b6d6f971..ff97250da 100644 --- a/hscontrol/templates/auth_web.go +++ b/hscontrol/templates/auth_web.go @@ -8,14 +8,10 @@ import ( // to complete an authentication or registration flow. // It is used by both the registration and auth-approve web handlers. func AuthWeb(title, description, command string) *elem.Element { - return HtmlStructure( - elem.Title(nil, elem.Text(title+" - Headscale")), - mdTypesetBody( - headscaleLogo(), - H1(elem.Text(title)), - P(elem.Text(description)), - Pre(PreCode(command)), - pageFooter(), - ), + return page( + title+" - Headscale", + H1(elem.Text(title)), + P(elem.Text(description)), + codeBlockText(command), ) } diff --git a/hscontrol/templates/design.go b/hscontrol/templates/design.go index 7c9194b0c..422d85db7 100644 --- a/hscontrol/templates/design.go +++ b/hscontrol/templates/design.go @@ -78,12 +78,17 @@ func orDivider() *elem.Element { ) } -// successBox creates a green success feedback box with a checkmark icon. -// The heading is displayed as bold green text, and children are rendered below it. -// Pairs with warningBox for consistent feedback styling. +// feedbackBox creates a coloured feedback box with an icon and a bold heading. +// colorVar provides both the border and heading colour, bgVar the background; +// role and ariaLive set the accessibility attributes. Children render below the +// heading. // -//nolint:unused // Used in auth_success.go template. -func successBox(heading string, children ...elem.Node) *elem.Element { +//nolint:unused // Wrapped by successBox and errorBox. +func feedbackBox( + icon elem.Node, + colorVar, bgVar, role, ariaLive, heading string, + children ...elem.Node, +) *elem.Element { return elem.Div( attrs.Props{ attrs.Style: styles.Props{ @@ -91,22 +96,22 @@ func successBox(heading string, children ...elem.Node) *elem.Element { styles.AlignItems: cssCenter, styles.Gap: spaceM, styles.Padding: spaceL, - styles.BackgroundColor: "var(--hs-success-bg)", - styles.Border: "1px solid var(--hs-success)", + styles.BackgroundColor: bgVar, + styles.Border: "1px solid " + colorVar, styles.BorderRadius: spaceS, styles.MarginBottom: spaceXL, }.ToInline(), - attrs.Role: "status", - "aria-live": "polite", + attrs.Role: role, + "aria-live": ariaLive, }, - checkboxIcon(), + icon, elem.Div( nil, append([]elem.Node{ elem.Strong(attrs.Props{ attrs.Style: styles.Props{ styles.Display: "block", - styles.Color: "var(--hs-success)", + styles.Color: colorVar, styles.FontSize: fontSizeH3, styles.FontWeight: "700", styles.MarginBottom: spaceXS, @@ -117,6 +122,19 @@ func successBox(heading string, children ...elem.Node) *elem.Element { ) } +// successBox creates a green success feedback box with a checkmark icon. +// The heading is displayed as bold green text, and children are rendered below it. +// Pairs with warningBox for consistent feedback styling. +// +//nolint:unused // Used in auth_success.go template. +func successBox(heading string, children ...elem.Node) *elem.Element { + return feedbackBox( + checkboxIcon(), + "var(--hs-success)", "var(--hs-success-bg)", "status", "polite", + heading, children..., + ) +} + // checkboxIcon returns the success checkbox SVG icon as raw HTML. func checkboxIcon() elem.Node { return elem.Raw(` - - - - - - -
- - -`)) - - var payload bytes.Buffer - if err := swaggerTemplate.Execute(&payload, struct{}{}); err != nil { //nolint:noinlineerr - log.Error(). - Caller(). - Err(err). - Msg("Could not render Swagger") - - writer.Header().Set("Content-Type", "text/plain; charset=utf-8") - writer.WriteHeader(http.StatusInternalServerError) - - _, err := writer.Write([]byte("Could not render Swagger")) - if err != nil { - log.Error(). - Caller(). - Err(err). - Msg("Failed to write response") - } - - return - } - - writer.Header().Set("Content-Type", "text/html; charset=utf-8") - writer.WriteHeader(http.StatusOK) - - _, err := writer.Write(payload.Bytes()) - if err != nil { - log.Error(). - Caller(). - Err(err). - Msg("Failed to write response") - } -} - -func SwaggerAPIv1( - writer http.ResponseWriter, - req *http.Request, -) { - writer.Header().Set("Content-Type", "application/json; charset=utf-8") - writer.WriteHeader(http.StatusOK) - - if _, err := writer.Write(apiV1JSON); err != nil { //nolint:noinlineerr - log.Error(). - Caller(). - Err(err). - Msg("Failed to write response") - } -} From 1572ac551d3512d32701d8293e91dd16da2f5b38 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 19 Jun 2026 06:12:57 +0000 Subject: [PATCH 058/127] test: add golden parity tests for the v1 API Pin every endpoint's behaviour with golden fixtures captured from the retiring grpc-gateway (timestamps and secrets neutralised). Error cases assert their HTTP status independently of the golden via assertStatus, so a blind regenerate cannot re-pin a wrong code; HEADSCALE_UPDATE_GOLDEN rewrites the fixtures. A unit test exercises the API-key auth middleware on the real server router. Exclude the emitted spec and goldens from pre-commit checks. --- .pre-commit-config.yaml | 5 +- hscontrol/apiv1_apikeys_test.go | 212 +++++++++ hscontrol/apiv1_auth_test.go | 187 ++++++++ hscontrol/apiv1_authmw_test.go | 110 +++++ hscontrol/apiv1_harness_test.go | 276 ++++++++++++ hscontrol/apiv1_health_test.go | 24 + hscontrol/apiv1_nodes_test.go | 416 ++++++++++++++++++ hscontrol/apiv1_policy_test.go | 186 ++++++++ hscontrol/apiv1_preauthkeys_test.go | 163 +++++++ hscontrol/apiv1_users_test.go | 123 ++++++ ...piKeyDelete_both_id_and_prefix_parity.json | 4 + ...stAPIV1ApiKeyDelete_invalid_id_parity.json | 4 + ...iKeyDelete_not_found_by_prefix_parity.json | 4 + ...x_with_id_query_is_both_->_400_parity.json | 4 + ...piKeyExpire_both_id_and_prefix_parity.json | 4 + .../TestAPIV1ApiKeyExpire_by_id_parity.json | 4 + ...estAPIV1ApiKeyExpire_by_prefix_parity.json | 4 + ...eyExpire_neither_id_nor_prefix_parity.json | 4 + ...V1ApiKeyExpire_not_found_by_id_parity.json | 4 + .../TestAPIV1ApiKeyList_empty_parity.json | 6 + ...nil_timestamps_emitted_as_null_parity.json | 14 + .../TestAPIV1ApiKeyList_populated_parity.json | 21 + ...ration_emitted_as_zero_instant_parity.json | 14 + ...IV1AuthApprove_invalid_auth_id_parity.json | 4 + ...AuthApprove_no_pending_session_parity.json | 4 + ...stAPIV1AuthRegister_happy_path_parity.json | 34 ++ ...V1AuthRegister_invalid_auth_id_parity.json | 4 + ...uthRegister_no_pending_session_parity.json | 4 + ...APIV1AuthRegister_unknown_user_parity.json | 4 + ...PIV1AuthReject_invalid_auth_id_parity.json | 4 + ...1AuthReject_no_pending_session_parity.json | 4 + ...V1CreatePreAuthKey_invalid_tag_parity.json | 4 + ...eatePreAuthKey_invalid_user_id_parity.json | 4 + ...thKey_neither_tagged_nor_owned_parity.json | 4 + ...atePreAuthKey_nonexistent_user_parity.json | 4 + ...APIV1CreateUser_duplicate_name_parity.json | 4 + .../TestAPIV1CreateUser_parity.json | 15 + ...IV1DeletePreAuthKey_invalid_id_parity.json | 4 + ...V1DeletePreAuthKey_nonexistent_parity.json | 4 + .../TestAPIV1DeletePreAuthKey_parity.json | 4 + ...estAPIV1DeleteUser_nonexistent_parity.json | 4 + .../TestAPIV1DeleteUser_parity.json | 4 + ...IV1ExpirePreAuthKey_invalid_id_parity.json | 4 + ...V1ExpirePreAuthKey_nonexistent_parity.json | 4 + .../TestAPIV1ExpirePreAuthKey_parity.json | 4 + .../TestAPIV1Health_parity_with_gateway.json | 6 + .../TestAPIV1ListPreAuthKeys_all_parity.json | 40 ++ ...TestAPIV1ListPreAuthKeys_empty_parity.json | 6 + .../TestAPIV1ListUsers_all_parity.json | 37 ++ .../TestAPIV1ListUsers_empty_parity.json | 6 + ...estAPIV1ListUsers_filter_by_id_parity.json | 17 + ...tAPIV1ListUsers_filter_by_name_parity.json | 17 + .../TestAPIV1ListUsers_invalid_id_parity.json | 4 + ...odeBackfillIPs_confirmed_empty_parity.json | 6 + ...IV1NodeBackfillIPs_unconfirmed_parity.json | 4 + ...IV1NodeDebugCreate_invalid_key_parity.json | 4 + ...V1NodeDebugCreate_unknown_user_parity.json | 4 + .../TestAPIV1NodeDelete_happy_parity.json | 4 + ...TestAPIV1NodeDelete_invalid_id_parity.json | 4 + .../TestAPIV1NodeDelete_not_found_parity.json | 4 + ...TestAPIV1NodeExpire_invalid_id_parity.json | 4 + .../TestAPIV1NodeExpire_not_found_parity.json | 4 + .../TestAPIV1NodeGet_happy_parity.json | 53 +++ .../TestAPIV1NodeGet_invalid_id_parity.json | 4 + .../TestAPIV1NodeGet_not_found_parity.json | 4 + .../TestAPIV1NodeGet_tagged_node_parity.json | 48 ++ .../TestAPIV1NodeList_all_parity.json | 103 +++++ .../TestAPIV1NodeList_empty_parity.json | 6 + ...stAPIV1NodeList_filter_by_user_parity.json | 55 +++ ...TestAPIV1NodeList_unknown_user_parity.json | 4 + .../TestAPIV1NodeRegister_happy_parity.json | 34 ++ ...tAPIV1NodeRegister_invalid_key_parity.json | 4 + ...APIV1NodeRegister_unknown_user_parity.json | 4 + ...TestAPIV1NodeRename_invalid_id_parity.json | 4 + .../TestAPIV1NodeRename_not_found_parity.json | 4 + ...deSetApprovedRoutes_invalid_id_parity.json | 4 + ...odeSetApprovedRoutes_not_found_parity.json | 4 + ...estAPIV1NodeSetTags_empty_tags_parity.json | 4 + ...estAPIV1NodeSetTags_invalid_id_parity.json | 4 + ...TestAPIV1NodeSetTags_not_found_parity.json | 4 + ...V1NodeSetTags_unauthorized_tag_parity.json | 4 + .../TestAPIV1PolicyCheck_invalid_parity.json | 4 + .../TestAPIV1PolicyCheck_valid_parity.json | 4 + .../TestAPIV1PolicyGet_empty_parity.json | 4 + .../TestAPIV1PolicyGet_set_parity.json | 7 + .../TestAPIV1PolicySet_invalid_parity.json | 4 + .../TestAPIV1PolicySet_valid_parity.json | 7 + ...TestAPIV1RenameUser_invalid_id_parity.json | 4 + ...estAPIV1RenameUser_nonexistent_parity.json | 4 + .../TestAPIV1RenameUser_parity.json | 15 + 90 files changed, 2495 insertions(+), 2 deletions(-) create mode 100644 hscontrol/apiv1_apikeys_test.go create mode 100644 hscontrol/apiv1_auth_test.go create mode 100644 hscontrol/apiv1_authmw_test.go create mode 100644 hscontrol/apiv1_harness_test.go create mode 100644 hscontrol/apiv1_health_test.go create mode 100644 hscontrol/apiv1_nodes_test.go create mode 100644 hscontrol/apiv1_policy_test.go create mode 100644 hscontrol/apiv1_preauthkeys_test.go create mode 100644 hscontrol/apiv1_users_test.go create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_both_id_and_prefix_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_not_found_by_prefix_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_path_prefix_with_id_query_is_both_->_400_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_both_id_and_prefix_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_by_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_by_prefix_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_neither_id_nor_prefix_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_not_found_by_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_empty_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_nil_timestamps_emitted_as_null_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_populated_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_zero_expiration_emitted_as_zero_instant_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1AuthApprove_invalid_auth_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1AuthApprove_no_pending_session_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_happy_path_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_invalid_auth_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_no_pending_session_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_unknown_user_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1AuthReject_invalid_auth_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1AuthReject_no_pending_session_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_invalid_tag_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_invalid_user_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_neither_tagged_nor_owned_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_nonexistent_user_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1CreateUser_duplicate_name_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1CreateUser_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_nonexistent_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1DeleteUser_nonexistent_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1DeleteUser_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_nonexistent_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1Health_parity_with_gateway.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ListPreAuthKeys_all_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ListPreAuthKeys_empty_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_all_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_empty_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_filter_by_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_filter_by_name_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeBackfillIPs_confirmed_empty_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeBackfillIPs_unconfirmed_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeDebugCreate_invalid_key_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeDebugCreate_unknown_user_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_happy_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_not_found_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeExpire_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeExpire_not_found_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_happy_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_not_found_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_tagged_node_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_all_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_empty_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_filter_by_user_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_unknown_user_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_happy_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_invalid_key_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_unknown_user_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeRename_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeRename_not_found_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetApprovedRoutes_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetApprovedRoutes_not_found_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_empty_tags_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_not_found_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_unauthorized_tag_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1PolicyCheck_invalid_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1PolicyCheck_valid_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1PolicyGet_empty_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1PolicyGet_set_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1PolicySet_invalid_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1PolicySet_valid_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_invalid_id_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_nonexistent_parity.json create mode 100644 hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_parity.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c1b065670..aa4355c4c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,8 +2,9 @@ # See: https://prek.j178.dev/quickstart/ # See: https://prek.j178.dev/builtin/ -# Global exclusions - ignore generated code -exclude: ^gen/ +# Global exclusions - ignore generated code (proto output and emitted OpenAPI) +# and recorded golden fixtures. +exclude: ^(gen|openapi)/|^hscontrol/testdata/apiv1_golden/ repos: # Built-in hooks from pre-commit/pre-commit-hooks diff --git a/hscontrol/apiv1_apikeys_test.go b/hscontrol/apiv1_apikeys_test.go new file mode 100644 index 000000000..74b4634ae --- /dev/null +++ b/hscontrol/apiv1_apikeys_test.go @@ -0,0 +1,212 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seedAPIKey creates a single API key and returns its database ID and the +// masked display prefix the API exposes, so tests can address it by either. +func seedAPIKey(t *testing.T, app *Headscale) (uint64, string) { + t.Helper() + + _, key, err := app.state.CreateAPIKey(nil) + require.NoError(t, err) + + return key.ID, "hskey-api-" + key.Prefix + "-***" +} + +func TestAPIV1ApiKeyCreate(t *testing.T) { + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodPost, "/api/v1/apikey", []byte(`{}`)) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + APIKey string `json:"apiKey"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + // The full secret is returned exactly once, on creation. + assert.NotEmpty(t, got.APIKey) + assert.Contains(t, got.APIKey, "hskey-api-") + }) + + t.Run("creates secret", func(t *testing.T) { + // The secret is random, so it can't be captured in a golden; just assert + // success and that a secret is returned. + humaApp := createTestApp(t) + + hum := callHandler(newHumaTestHandler(humaApp), http.MethodPost, "/api/v1/apikey", []byte(`{}`)) + + assert.Equal(t, http.StatusOK, hum.status) + + var humBody struct { + APIKey string `json:"apiKey"` + } + require.NoError(t, json.Unmarshal(hum.body, &humBody)) + assert.NotEmpty(t, humBody.APIKey) + }) +} + +func TestAPIV1ApiKeyList(t *testing.T) { + t.Run("empty returns empty array", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodGet, "/api/v1/apikey", nil) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{"apiKeys":[]}`, string(res.body)) + }) + + t.Run("empty parity", func(t *testing.T) { + h := newAPIV1Harness(t) + h.assertParity(t, http.MethodGet, "/api/v1/apikey", nil) + }) + + t.Run("populated parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedAPIKey(t, h.app) + seedAPIKey(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/apikey", nil) + }) + + t.Run("nil timestamps emitted as null parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedAPIKey(t, h.app) + // Nil expiration: lastSeen and expiration are unset and must emit as + // JSON null. + res := h.assertParity(t, http.MethodGet, "/api/v1/apikey", nil) + + var got struct { + APIKeys []map[string]any `json:"apiKeys"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + require.Len(t, got.APIKeys, 1) + assert.Nil(t, got.APIKeys[0]["lastSeen"]) + assert.Nil(t, got.APIKeys[0]["expiration"]) + assert.Contains(t, got.APIKeys[0], "createdAt") + assert.Contains(t, got.APIKeys[0]["prefix"], "hskey-api-") + }) + + t.Run("zero expiration emitted as zero instant parity", func(t *testing.T) { + h := newAPIV1Harness(t) + // Non-nil zero expiration (as gRPC CreateApiKey does for a missing one) + // must render as the zero instant, not null. + var zero time.Time + + _, _, err := h.app.state.CreateAPIKey(&zero) + require.NoError(t, err) + + res := h.assertParity(t, http.MethodGet, "/api/v1/apikey", nil) + + var got struct { + APIKeys []map[string]any `json:"apiKeys"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + require.Len(t, got.APIKeys, 1) + assert.Equal(t, "0001-01-01T00:00:00Z", got.APIKeys[0]["expiration"]) + }) +} + +func TestAPIV1ApiKeyExpire(t *testing.T) { + t.Run("by id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + id, _ := seedAPIKey(t, h.app) + h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire", + []byte(`{"id":"`+strconv.FormatUint(id, 10)+`"}`)) + }) + + t.Run("by prefix parity", func(t *testing.T) { + h := newAPIV1Harness(t) + _, prefix := seedAPIKey(t, h.app) + h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire", + []byte(`{"prefix":"`+prefix+`"}`)) + }) + + t.Run("neither id nor prefix parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire", []byte(`{}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("both id and prefix parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire", + []byte(`{"id":"1","prefix":"abc"}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("not found by id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/apikey/expire", []byte(`{"id":"999"}`)) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("expires the key", func(t *testing.T) { + h := newAPIV1Harness(t) + id, _ := seedAPIKey(t, h.app) + + res := h.callHuma(http.MethodPost, "/api/v1/apikey/expire", + []byte(`{"id":"`+strconv.FormatUint(id, 10)+`"}`)) + require.Equal(t, http.StatusOK, res.status) + + listRes := h.callHuma(http.MethodGet, "/api/v1/apikey", nil) + + var got struct { + APIKeys []struct { + Expiration *time.Time `json:"expiration"` + } `json:"apiKeys"` + } + require.NoError(t, json.Unmarshal(listRes.body, &got)) + require.Len(t, got.APIKeys, 1) + require.NotNil(t, got.APIKeys[0].Expiration) + assert.False(t, got.APIKeys[0].Expiration.IsZero(), "expiration should be set") + }) +} + +func TestAPIV1ApiKeyDelete(t *testing.T) { + t.Run("by prefix deletes the key", func(t *testing.T) { + h := newAPIV1Harness(t) + _, prefix := seedAPIKey(t, h.app) + + res := h.callHuma(http.MethodDelete, "/api/v1/apikey/"+prefix, nil) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{}`, string(res.body)) + + listRes := h.callHuma(http.MethodGet, "/api/v1/apikey", nil) + assert.JSONEq(t, `{"apiKeys":[]}`, string(listRes.body)) + }) + + t.Run("path prefix with id query is both -> 400 parity", func(t *testing.T) { + h := newAPIV1Harness(t) + id, prefix := seedAPIKey(t, h.app) + res := h.assertParity(t, http.MethodDelete, + "/api/v1/apikey/"+prefix+"?id="+strconv.FormatUint(id, 10), nil) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("not found by prefix parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/apikey/doesnotexist", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("both id and prefix parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/apikey/abc?id=1", nil) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/apikey/abc?id=notanumber", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} diff --git a/hscontrol/apiv1_auth_test.go b/hscontrol/apiv1_auth_test.go new file mode 100644 index 000000000..1f157f414 --- /dev/null +++ b/hscontrol/apiv1_auth_test.go @@ -0,0 +1,187 @@ +package hscontrol + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" + "tailscale.com/types/key" +) + +// seedAuthRequest stores a pending registration auth cache entry under authID so +// an AuthRegister/Approve/Reject call has a real session to act on. Capturing +// the keys/authID at the call site keeps isolated apps identical. +func seedAuthRequest( + user string, + authID types.AuthID, + machineKey key.MachinePublic, + nodeKey key.NodePublic, + discoKey key.DiscoPublic, + hostname string, +) func(t *testing.T, app *Headscale) { + return func(t *testing.T, app *Headscale) { + t.Helper() + + app.state.CreateUserForTest(user) + + regData := &types.RegistrationData{ + MachineKey: machineKey, + NodeKey: nodeKey, + DiscoKey: discoKey, + Hostname: hostname, + Hostinfo: &tailcfg.Hostinfo{Hostname: hostname}, + } + + app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(regData)) + } +} + +func TestAPIV1AuthRegister(t *testing.T) { + t.Run("happy path parity", func(t *testing.T) { + // Capture keys/authID once so both isolated apps register the identical + // node and produce byte-equal bodies. + authID := types.MustAuthID() + machineKey := key.NewMachine().Public() + nodeKey := key.NewNode().Public() + discoKey := key.NewDisco().Public() + + seed := seedAuthRequest("alice", authID, machineKey, nodeKey, discoKey, "regnode") + body := fmt.Appendf(nil, `{"user":"alice","authId":%q}`, authID.String()) + + assertParityIsolated(t, seed, http.MethodPost, "/api/v1/auth/register", body) + }) + + t.Run("happy path response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + + authID := types.MustAuthID() + seedAuthRequest( + "alice", authID, + key.NewMachine().Public(), + key.NewNode().Public(), + key.NewDisco().Public(), + "regnode", + )(t, h.app) + + body := fmt.Appendf(nil, `{"user":"alice","authId":%q}`, authID.String()) + res := h.callHuma(http.MethodPost, "/api/v1/auth/register", body) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Node map[string]any `json:"node"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "1", got.Node["id"]) + assert.Equal(t, "REGISTER_METHOD_CLI", got.Node["registerMethod"]) + // EmitUnpopulated parity: an unset expiry (we passed none) is null, an + // unset embedded pre-auth key is null, and repeated fields are [] + // rather than omitted. + assert.Contains(t, got.Node, "expiry") + assert.Nil(t, got.Node["expiry"]) + assert.Contains(t, got.Node, "preAuthKey") + assert.Nil(t, got.Node["preAuthKey"]) + assert.Equal(t, []any{}, got.Node["subnetRoutes"]) + assert.NotNil(t, got.Node["user"]) + }) + + // A malformed auth_id is bad input (400), matching AuthApprove/AuthReject. + t.Run("invalid auth_id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedUsers("alice")(t, h.app) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/register", + []byte(`{"user":"alice","authId":"not-a-valid-auth-id"}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("unknown user parity", func(t *testing.T) { + h := newAPIV1Harness(t) + body := fmt.Appendf(nil, `{"user":"ghost","authId":%q}`, types.MustAuthID().String()) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/register", body) + assertStatus(t, res, http.StatusNotFound) + }) + + // Valid auth_id but no cached session: HandleNodeFromAuthPath returns + // ErrNodeNotFoundRegistrationCache, which maps to 404. + t.Run("no pending session parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedUsers("alice")(t, h.app) + + body := fmt.Appendf(nil, `{"user":"alice","authId":%q}`, types.MustAuthID().String()) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/register", body) + assertStatus(t, res, http.StatusNotFound) + }) +} + +func TestAPIV1AuthApprove(t *testing.T) { + t.Run("happy path", func(t *testing.T) { + h := newAPIV1Harness(t) + + authID := types.MustAuthID() + authReq := types.NewAuthRequest() + h.app.state.SetAuthCacheEntry(authID, authReq) + + body := fmt.Appendf(nil, `{"authId":%q}`, authID.String()) + res := h.callHuma(http.MethodPost, "/api/v1/auth/approve", body) + + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{}`, string(res.body)) + + verdict := <-authReq.WaitForAuth() + assert.True(t, verdict.Accept(), "approve must finish the session with a passing verdict") + }) + + // Malformed auth_id: AuthApprove returns codes.InvalidArgument → 400. + t.Run("invalid auth_id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/approve", + []byte(`{"authId":"not-a-valid-auth-id"}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + // Well-formed auth_id with no pending session: codes.NotFound → 404. + t.Run("no pending session parity", func(t *testing.T) { + h := newAPIV1Harness(t) + body := fmt.Appendf(nil, `{"authId":%q}`, types.MustAuthID().String()) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/approve", body) + assertStatus(t, res, http.StatusNotFound) + }) +} + +func TestAPIV1AuthReject(t *testing.T) { + t.Run("happy path", func(t *testing.T) { + h := newAPIV1Harness(t) + + authID := types.MustAuthID() + authReq := types.NewAuthRequest() + h.app.state.SetAuthCacheEntry(authID, authReq) + + body := fmt.Appendf(nil, `{"authId":%q}`, authID.String()) + res := h.callHuma(http.MethodPost, "/api/v1/auth/reject", body) + + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{}`, string(res.body)) + + verdict := <-authReq.WaitForAuth() + assert.False(t, verdict.Accept(), "reject must finish the session with a failing verdict") + }) + + t.Run("invalid auth_id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/reject", + []byte(`{"authId":"not-a-valid-auth-id"}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("no pending session parity", func(t *testing.T) { + h := newAPIV1Harness(t) + body := fmt.Appendf(nil, `{"authId":%q}`, types.MustAuthID().String()) + res := h.assertParity(t, http.MethodPost, "/api/v1/auth/reject", body) + assertStatus(t, res, http.StatusNotFound) + }) +} diff --git a/hscontrol/apiv1_authmw_test.go b/hscontrol/apiv1_authmw_test.go new file mode 100644 index 000000000..814f839d0 --- /dev/null +++ b/hscontrol/apiv1_authmw_test.go @@ -0,0 +1,110 @@ +package hscontrol + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAPIV1AuthMiddleware proves the v1 API, mounted on the real router, is +// guarded by the Huma security middleware: missing or bad Bearer tokens are +// rejected with 401 before reaching a handler; a valid key passes to 200. The +// per-endpoint harness bypasses auth via WithLocalTrust, so this is the one +// place the middleware itself is exercised. +func TestAPIV1AuthMiddleware(t *testing.T) { + app := createTestApp(t) + handler := app.HTTPHandler() + + expiry := time.Now().Add(time.Hour) + valid, _, err := app.state.CreateAPIKey(&expiry) + require.NoError(t, err) + + tests := []struct { + name string + authHeader string + wantStatus int + }{ + {name: "missing bearer", authHeader: "", wantStatus: http.StatusUnauthorized}, + {name: "no bearer prefix", authHeader: "tskey-invalid", wantStatus: http.StatusUnauthorized}, + {name: "invalid bearer token", authHeader: "Bearer tskey-invalid", wantStatus: http.StatusUnauthorized}, + {name: "valid api key", authHeader: "Bearer " + valid, wantStatus: http.StatusOK}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequestWithContext( + context.Background(), http.MethodGet, "/api/v1/node", nil, + ) + if tt.authHeader != "" { + req.Header.Set("Authorization", tt.authHeader) + } + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equalf(t, tt.wantStatus, rec.Code, "body: %s", rec.Body.String()) + }) + } +} + +// TestAPIV1DocsArePublic proves the OpenAPI document and docs UI live under +// /api/v1 and are reachable without an API key, while the operations beside them +// stay key-gated. The docs page points at the versioned spec so a future +// /api/v2 can carry its own. +func TestAPIV1DocsArePublic(t *testing.T) { + app := createTestApp(t) + handler := app.HTTPHandler() + + tests := []struct { + path string + contains string + }{ + {path: "/api/v1/openapi.yaml", contains: "openapi:"}, + {path: "/api/v1/docs", contains: "/api/v1/openapi.yaml"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + // No Authorization header: these must be public. + req := httptest.NewRequestWithContext( + context.Background(), http.MethodGet, tt.path, nil, + ) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equalf(t, http.StatusOK, rec.Code, "%s should be public; body: %s", tt.path, rec.Body.String()) + assert.Containsf(t, rec.Body.String(), tt.contains, "%s body", tt.path) + }) + } +} + +// TestAPIV1Unauthorized401 pins the 401 body: RFC 7807 JSON containing +// "Unauthorized", under 100 bytes and leaking no data, as the integration +// auth-bypass tests require. +func TestAPIV1Unauthorized401(t *testing.T) { + app := createTestApp(t) + handler := app.HTTPHandler() + + req := httptest.NewRequestWithContext( + context.Background(), http.MethodGet, "/api/v1/node", nil, + ) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusUnauthorized, rec.Code) + + body := rec.Body.Bytes() + assert.Contains(t, string(body), "Unauthorized") + assert.Lessf(t, len(body), 100, "401 body must stay small (no data leak): %s", body) + + var problem map[string]any + require.NoError(t, json.Unmarshal(body, &problem), "401 body must be JSON: %s", body) + assert.NotContains(t, problem, "users") +} diff --git a/hscontrol/apiv1_harness_test.go b/hscontrol/apiv1_harness_test.go new file mode 100644 index 000000000..256ccf89b --- /dev/null +++ b/hscontrol/apiv1_harness_test.go @@ -0,0 +1,276 @@ +package hscontrol + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + apiv1 "github.com/juanfont/headscale/hscontrol/api/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// apiV1Harness drives a request through the Huma service and compares the result +// against a golden keyed by the test name. Goldens neutralise timestamps so they +// stay stable across runs; refresh with HEADSCALE_UPDATE_GOLDEN=1 after an +// intentional contract change. assertParity suits reads/errors; mutations use +// assertParityIsolated against a freshly-seeded app. +type apiV1Harness struct { + app *Headscale + huma http.Handler +} + +func newAPIV1Harness(t *testing.T) *apiV1Harness { + t.Helper() + + app := createTestApp(t) + + return &apiV1Harness{ + app: app, + huma: newHumaTestHandler(app), + } +} + +// newHumaTestHandler wraps the Huma handler in WithLocalTrust to bypass the +// bearer-key middleware — the same local-trust the unix socket gets — so these +// tests can exercise response shapes. Auth itself is covered against the full +// router in TestAPIV1AuthMiddleware. +func newHumaTestHandler(app *Headscale) http.Handler { + mux, _ := apiv1.Handler(apiv1.Backend{ + State: app.state, + Change: app.Change, + Cfg: app.cfg, + }) + + return apiv1.WithLocalTrust(mux) +} + +// httpResult is one HTTP exchange captured for comparison. +type httpResult struct { + status int + body []byte +} + +func callHandler(handler http.Handler, method, path string, body []byte) httpResult { + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + + req := httptest.NewRequestWithContext(context.Background(), method, path, reader) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + return httpResult{status: rec.Code, body: rec.Body.Bytes()} +} + +func (h *apiV1Harness) callHuma(method, path string, body []byte) httpResult { + return callHandler(h.huma, method, path, body) +} + +// assertParity asserts the Huma response matches the golden: equal status, and +// for success an equal JSON body (timestamps neutralised). Error bodies are not +// compared — the shape deliberately deviates from legacy +// ({code,message,details} vs Huma RFC7807). +func (h *apiV1Harness) assertParity(t *testing.T, method, path string, body []byte) httpResult { + t.Helper() + + hum := h.callHuma(method, path, body) + + assertAgainstGolden(t, method, path, hum) + + return hum +} + +// assertParityIsolated runs the request against a fresh app seeded by seed (may +// be nil) and compares to the golden. Use for mutations. +func assertParityIsolated( + t *testing.T, + seed func(t *testing.T, app *Headscale), + method, path string, + body []byte, +) httpResult { + t.Helper() + + humaApp := createTestApp(t) + if seed != nil { + seed(t, humaApp) + } + + hum := callHandler(newHumaTestHandler(humaApp), method, path, body) + + assertAgainstGolden(t, method, path, hum) + + return hum +} + +// assertStatus pins the exact HTTP status independently of the golden, so a +// regression fails even if the golden is blindly regenerated. +func assertStatus(t *testing.T, got httpResult, want int) { + t.Helper() + + assert.Equalf(t, want, got.status, + "unexpected HTTP status (golden-independent guard); body: %s", got.body) +} + +func isSuccess(status int) bool { + return status >= 200 && status < 300 +} + +const goldenDir = "testdata/apiv1_golden" + +// goldenRecord is the persisted shape of a captured response: the HTTP status +// and, when present, the normalised JSON body. +type goldenRecord struct { + Status int `json:"status"` + Body json.RawMessage `json:"body"` +} + +func goldenPath(t *testing.T) string { + t.Helper() + + name := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + + return filepath.Join(goldenDir, name+".json") +} + +// assertAgainstGolden compares the Huma response to the recorded golden: status +// always, body only for success responses. +func assertAgainstGolden(t *testing.T, method, path string, hum httpResult) { + t.Helper() + + gpath := goldenPath(t) + + if os.Getenv("HEADSCALE_UPDATE_GOLDEN") != "" { + writeGolden(t, gpath, hum) + + return + } + + raw, err := os.ReadFile(gpath) + require.NoErrorf(t, err, + "missing golden %s for %s %s — run with HEADSCALE_UPDATE_GOLDEN=1 to generate", + gpath, method, path) + + var golden goldenRecord + require.NoErrorf(t, json.Unmarshal(raw, &golden), "decoding golden %s", gpath) + + assert.Equalf(t, golden.Status, hum.status, + "status mismatch for %s %s\nhuma body: %s", method, path, hum.body) + + if isSuccess(golden.Status) { + var goldenBody any + if len(golden.Body) > 0 { + goldenBody = normalizeJSON(t, golden.Body, true) + } + + assert.Equalf(t, goldenBody, normalizeJSON(t, hum.body, true), + "body mismatch for %s %s", method, path) + } +} + +// writeGolden persists the response under HEADSCALE_UPDATE_GOLDEN. Success +// bodies are normalised the same way the comparison path does, so the file stays +// stable across runs. Error responses are stored status-only, since the reader +// never compares error bodies. +func writeGolden(t *testing.T, gpath string, hum httpResult) { + t.Helper() + + rec := goldenRecord{Status: hum.status} + + if isSuccess(hum.status) { + if normalised := normalizeJSON(t, hum.body, true); normalised != nil { + raw, err := json.Marshal(normalised) + require.NoErrorf(t, err, "marshalling golden body for %s", gpath) + + rec.Body = raw + } + } + + out, err := json.MarshalIndent(rec, "", " ") + require.NoErrorf(t, err, "marshalling golden record for %s", gpath) + + require.NoErrorf(t, os.MkdirAll(filepath.Dir(gpath), 0o755), + "creating golden dir for %s", gpath) + require.NoErrorf(t, os.WriteFile(gpath, append(out, '\n'), 0o600), + "writing golden %s", gpath) + + t.Logf("updated golden %s", gpath) +} + +// nonDeterministicRe matches values that embed per-app random material and so +// cannot match byte-for-byte across apps: masked key/secret prefixes +// (hskey-auth--***, hskey-api--***) and Tailscale key material +// (mkey:/nodekey:/discokey:). Neutralised to a sentinel when neutralise is +// true. +var nonDeterministicRe = regexp.MustCompile( + `^(hskey-(auth|api)-[0-9a-f]+-\*\*\*|(mkey|nodekey|discokey):[0-9a-f]+)$`, +) + +// normalizeJSON decodes JSON for order-independent, type-aware comparison. +// Numbers decode as json.Number so "5" and 5 are not conflated, surfacing +// string-encoding differences. With neutralise, timestamps and per-app random +// values (see nonDeterministicRe) become sentinels; otherwise timestamps are +// canonicalised to a UTC instant. +func normalizeJSON(t *testing.T, b []byte, neutralise bool) any { + t.Helper() + + if len(bytes.TrimSpace(b)) == 0 { + return nil + } + + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + + var v any + require.NoErrorf(t, dec.Decode(&v), "decoding JSON: %s", b) + + return canonicalizeTimestamps(v, neutralise) +} + +func canonicalizeTimestamps(v any, neutralise bool) any { + switch val := v.(type) { + case map[string]any: + for k, child := range val { + val[k] = canonicalizeTimestamps(child, neutralise) + } + + return val + case []any: + for i, child := range val { + val[i] = canonicalizeTimestamps(child, neutralise) + } + + return val + case string: + if neutralise && nonDeterministicRe.MatchString(val) { + return "" + } + + ts, err := time.Parse(time.RFC3339Nano, val) + if err != nil { + return val + } + + if neutralise { + return "" + } + + return ts.UTC().Format(time.RFC3339Nano) + default: + return v + } +} diff --git a/hscontrol/apiv1_health_test.go b/hscontrol/apiv1_health_test.go new file mode 100644 index 000000000..6e25ddb24 --- /dev/null +++ b/hscontrol/apiv1_health_test.go @@ -0,0 +1,24 @@ +package hscontrol + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIV1Health(t *testing.T) { + h := newAPIV1Harness(t) + + t.Run("huma returns healthy with database connectivity", func(t *testing.T) { + res := h.callHuma(http.MethodGet, "/api/v1/health", nil) + + assert.Equal(t, http.StatusOK, res.status) + require.JSONEq(t, `{"databaseConnectivity":true}`, string(res.body)) + }) + + t.Run("parity with gateway", func(t *testing.T) { + h.assertParity(t, http.MethodGet, "/api/v1/health", nil) + }) +} diff --git a/hscontrol/apiv1_nodes_test.go b/hscontrol/apiv1_nodes_test.go new file mode 100644 index 000000000..dc6e22228 --- /dev/null +++ b/hscontrol/apiv1_nodes_test.go @@ -0,0 +1,416 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" + "tailscale.com/types/key" +) + +// nodeSeed carries fixed key material so isolated apps in a mutation parity test +// register byte-identical nodes; otherwise random keys would defeat body +// comparison. +type nodeSeed struct { + user string + machineKey key.MachinePrivate + nodeKey key.NodePrivate + hostname string + tags []string +} + +func newNodeSeed(user, hostname string, tags ...string) nodeSeed { + return nodeSeed{ + user: user, + machineKey: key.NewMachine(), + nodeKey: key.NewNode(), + hostname: hostname, + tags: tags, + } +} + +// register inserts the user, a matching pre-auth key, and the node itself using +// the seed's fixed keys, returning the created node ID. +func (s nodeSeed) register(t *testing.T, app *Headscale) types.NodeID { + t.Helper() + + user := app.state.CreateUserForTest(s.user) + + pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, s.tags) + require.NoError(t, err) + + req := tailcfg.RegisterRequest{ + Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key}, + NodeKey: s.nodeKey.Public(), + Hostinfo: &tailcfg.Hostinfo{Hostname: s.hostname}, + } + + _, err = app.handleRegisterWithAuthKey(req, s.machineKey.Public()) + require.NoError(t, err) + + node, found := app.state.GetNodeByNodeKey(s.nodeKey.Public()) + require.True(t, found) + + return node.ID() +} + +func seedNodes(seeds ...nodeSeed) func(t *testing.T, app *Headscale) { + return func(t *testing.T, app *Headscale) { + t.Helper() + + for _, s := range seeds { + s.register(t, app) + } + } +} + +func TestAPIV1NodeGet(t *testing.T) { + t.Run("happy parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/node/1", nil) + }) + + t.Run("tagged node parity", func(t *testing.T) { + h := newAPIV1Harness(t) + // No tagOwners means policy won't authorise a tag, so register via the + // pre-auth key path, which forces tags regardless of policy. + seedNodes(newNodeSeed("bob", "node-b", "tag:initial"))(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/node/1", nil) + }) + + t.Run("not found parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodGet, "/api/v1/node/99999", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodGet, "/api/v1/node/abc", nil) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("carol", "node-c"))(t, h.app) + + res := h.callHuma(http.MethodGet, "/api/v1/node/1", nil) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Node map[string]any `json:"node"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "1", got.Node["id"]) + assert.Equal(t, "node-c", got.Node["name"]) + assert.Equal(t, "REGISTER_METHOD_AUTH_KEY", got.Node["registerMethod"]) + // EmitUnpopulated parity: slices present as [], expiry as null. + assert.Equal(t, []any{}, got.Node["ipAddresses"]) + assert.Equal(t, []any{}, got.Node["tags"]) + assert.Nil(t, got.Node["expiry"]) + assert.Contains(t, got.Node, "user") + assert.Contains(t, got.Node, "preAuthKey") + assert.Contains(t, got.Node, "createdAt") + }) +} + +func TestAPIV1NodeList(t *testing.T) { + t.Run("empty returns empty array", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodGet, "/api/v1/node", nil) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{"nodes":[]}`, string(res.body)) + }) + + t.Run("empty parity", func(t *testing.T) { + h := newAPIV1Harness(t) + h.assertParity(t, http.MethodGet, "/api/v1/node", nil) + }) + + t.Run("all parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes( + newNodeSeed("alice", "node-a"), + newNodeSeed("bob", "node-b"), + )(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/node", nil) + }) + + t.Run("filter by user parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes( + newNodeSeed("alice", "node-a"), + newNodeSeed("bob", "node-b"), + )(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/node?user=alice", nil) + }) + + t.Run("unknown user parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + res := h.assertParity(t, http.MethodGet, "/api/v1/node?user=nope", nil) + assertStatus(t, res, http.StatusNotFound) + }) +} + +func TestAPIV1NodeDelete(t *testing.T) { + t.Run("happy parity", func(t *testing.T) { + assertParityIsolated(t, seedNodes(newNodeSeed("alice", "node-a")), + http.MethodDelete, "/api/v1/node/1", nil) + }) + + t.Run("not found parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/node/99999", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/node/abc", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1NodeExpire(t *testing.T) { + // The embedded pre-auth key's masked prefix is random per app, so isolated + // body comparison is impossible. GetNode/ListNodes prove full serialisation; + // here we just assert the mutation took effect. + t.Run("huma expires node", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + + res := h.callHuma(http.MethodPost, "/api/v1/node/1/expire", nil) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Node map[string]any `json:"node"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "1", got.Node["id"]) + // Expiry was nil before; expiring sets it to a concrete timestamp. + assert.NotNil(t, got.Node["expiry"]) + }) + + t.Run("not found parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/expire", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/expire", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1NodeRename(t *testing.T) { + t.Run("huma renames node", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + + res := h.callHuma(http.MethodPost, "/api/v1/node/1/rename/renamed-node", nil) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Node map[string]any `json:"node"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "renamed-node", got.Node["givenName"]) + }) + + t.Run("not found parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/rename/whatever", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/rename/whatever", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1NodeSetTags(t *testing.T) { + t.Run("not found parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/tags", + []byte(`{"tags":["tag:foo"]}`)) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("empty tags parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/1/tags", []byte(`{"tags":[]}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("unauthorized tag parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + // No tagOwners in policy → SetNodeTags rejects with InvalidArgument (400). + res := h.assertParity(t, http.MethodPost, "/api/v1/node/1/tags", + []byte(`{"tags":["tag:server"]}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/tags", + []byte(`{"tags":["tag:foo"]}`)) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1NodeSetApprovedRoutes(t *testing.T) { + t.Run("huma sets approved routes", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + + res := h.callHuma(http.MethodPost, "/api/v1/node/1/approve_routes", + []byte(`{"routes":[]}`)) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Node map[string]any `json:"node"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "1", got.Node["id"]) + // SubnetRoutes is recomputed from primary routes; empty here but present. + assert.Equal(t, []any{}, got.Node["approvedRoutes"]) + assert.Equal(t, []any{}, got.Node["subnetRoutes"]) + }) + + t.Run("not found parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/approve_routes", + []byte(`{"routes":["10.0.0.0/24"]}`)) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/approve_routes", + []byte(`{"routes":[]}`)) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1NodeRegister(t *testing.T) { + t.Run("happy parity", func(t *testing.T) { + authID := types.MustAuthID() + mk := key.NewMachine() + nk := key.NewNode() + + seed := func(t *testing.T, app *Headscale) { + t.Helper() + + app.state.CreateUserForTest("alice") + + regData := &types.RegistrationData{ + NodeKey: nk.Public(), + MachineKey: mk.Public(), + Hostname: "registered-node", + } + app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(regData)) + } + + assertParityIsolated(t, seed, http.MethodPost, + "/api/v1/node/register?user=alice&key="+authID.String(), nil) + }) + + t.Run("invalid key parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + res := h.assertParity(t, http.MethodPost, + "/api/v1/node/register?user=alice&key=invalidkey", nil) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("unknown user parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, + "/api/v1/node/register?user=nope&key="+types.MustAuthID().String(), nil) + assertStatus(t, res, http.StatusNotFound) + }) +} + +func TestAPIV1NodeBackfillIPs(t *testing.T) { + t.Run("confirmed empty parity", func(t *testing.T) { + assertParityIsolated(t, nil, http.MethodPost, + "/api/v1/node/backfillips?confirmed=true", nil) + }) + + t.Run("confirmed returns empty array", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodPost, "/api/v1/node/backfillips?confirmed=true", nil) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{"changes":[]}`, string(res.body)) + }) + + t.Run("unconfirmed parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/node/backfillips", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1NodeDebugCreate(t *testing.T) { + t.Run("unknown user parity", func(t *testing.T) { + h := newAPIV1Harness(t) + body := []byte(`{"user":"nope","key":"` + types.MustAuthID().String() + + `","name":"dbg","routes":[]}`) + res := h.assertParity(t, http.MethodPost, "/api/v1/debug/node", body) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid key parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedNodes(newNodeSeed("alice", "node-a"))(t, h.app) + + body := []byte(`{"user":"alice","key":"badkey","name":"dbg","routes":[]}`) + res := h.assertParity(t, http.MethodPost, "/api/v1/debug/node", body) + assertStatus(t, res, http.StatusBadRequest) + }) + + // The handler mints fresh key material per call, so isolated apps can't + // produce byte-identical bodies; assert the shape directly instead. + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + h.app.state.CreateUserForTest("alice") + + body := []byte(`{"user":"alice","key":"` + types.MustAuthID().String() + + `","name":"dbgnode","routes":["10.0.0.0/24"]}`) + + res := h.callHuma(http.MethodPost, "/api/v1/debug/node", body) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Node map[string]any `json:"node"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "dbgnode", got.Node["name"]) + assert.Equal(t, "REGISTER_METHOD_UNSPECIFIED", got.Node["registerMethod"]) + assert.Equal(t, []any{"10.0.0.0/24"}, got.Node["availableRoutes"]) + // The synthetic echo node has no pre-auth key: emitted as null. + assert.Nil(t, got.Node["preAuthKey"]) + // Zero-time expiry/lastSeen are emitted as the zero instant, not null. + assert.Equal(t, "0001-01-01T00:00:00Z", got.Node["expiry"]) + assert.Equal(t, "0001-01-01T00:00:00Z", got.Node["lastSeen"]) + }) +} diff --git a/hscontrol/apiv1_policy_test.go b/hscontrol/apiv1_policy_test.go new file mode 100644 index 000000000..0c4cfefa0 --- /dev/null +++ b/hscontrol/apiv1_policy_test.go @@ -0,0 +1,186 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/juanfont/headscale/hscontrol/mapper" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// validPolicy is a minimal policy that passes validation without nodes or users. +const validPolicy = `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + +// invalidPolicy is not valid HuJSON, so policy manager construction fails. +const invalidPolicy = `{"acls": [` + +// seedPolicy stores a policy in the database so reads return it. +func seedPolicy(policy string) func(t *testing.T, app *Headscale) { + return func(t *testing.T, app *Headscale) { + t.Helper() + + _, err := app.state.SetPolicyInDB(policy) + require.NoError(t, err) + } +} + +func TestAPIV1PolicyGet(t *testing.T) { + t.Run("empty parity", func(t *testing.T) { + // With no policy stored, the DB load fails; this is treated as a server + // fault (500), matching the legacy contract. + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodGet, "/api/v1/policy", nil) + assertStatus(t, res, http.StatusInternalServerError) + }) + + t.Run("set parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedPolicy(validPolicy)(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/policy", nil) + }) + + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + seedPolicy(validPolicy)(t, h.app) + + res := h.callHuma(http.MethodGet, "/api/v1/policy", nil) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Policy string `json:"policy"` + UpdatedAt string `json:"updatedAt"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.JSONEq(t, validPolicy, got.Policy) + // updatedAt is emitted even though no omitempty is set. + assert.NotEmpty(t, got.UpdatedAt) + }) +} + +func TestAPIV1PolicySet(t *testing.T) { + t.Run("valid parity", func(t *testing.T) { + body, err := json.Marshal(map[string]string{"policy": validPolicy}) + require.NoError(t, err) + + assertParityIsolated(t, nil, http.MethodPut, "/api/v1/policy", body) + }) + + t.Run("invalid parity", func(t *testing.T) { + body, err := json.Marshal(map[string]string{"policy": invalidPolicy}) + require.NoError(t, err) + + res := assertParityIsolated(t, nil, http.MethodPut, "/api/v1/policy", body) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + + body, err := json.Marshal(map[string]string{"policy": validPolicy}) + require.NoError(t, err) + + res := h.callHuma(http.MethodPut, "/api/v1/policy", body) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + Policy string `json:"policy"` + UpdatedAt string `json:"updatedAt"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.JSONEq(t, validPolicy, got.Policy) + assert.NotEmpty(t, got.UpdatedAt) + }) + + t.Run("not-db mode rejected", func(t *testing.T) { + // Policy updates are only valid in DB mode; file mode rejects with 400. + // createTestApp is DB mode, so build a file-mode app here. + humaApp := createFilePolicyApp(t) + + body, err := json.Marshal(map[string]string{"policy": validPolicy}) + require.NoError(t, err) + + hum := callHandler(newHumaTestHandler(humaApp), http.MethodPut, "/api/v1/policy", body) + + assert.Equal(t, http.StatusBadRequest, hum.status, + "huma body: %s", hum.body) + }) +} + +func TestAPIV1PolicyCheck(t *testing.T) { + t.Run("valid parity", func(t *testing.T) { + h := newAPIV1Harness(t) + + body, err := json.Marshal(map[string]string{"policy": validPolicy}) + require.NoError(t, err) + + h.assertParity(t, http.MethodPost, "/api/v1/policy/check", body) + }) + + t.Run("invalid parity", func(t *testing.T) { + h := newAPIV1Harness(t) + + body, err := json.Marshal(map[string]string{"policy": invalidPolicy}) + require.NoError(t, err) + + res := h.assertParity(t, http.MethodPost, "/api/v1/policy/check", body) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("valid returns empty object", func(t *testing.T) { + h := newAPIV1Harness(t) + + body, err := json.Marshal(map[string]string{"policy": validPolicy}) + require.NoError(t, err) + + res := h.callHuma(http.MethodPost, "/api/v1/policy/check", body) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{}`, string(res.body)) + }) +} + +// createFilePolicyApp mirrors createTestApp but with file-based policy mode so +// the policy-update-disabled path can be exercised. +func createFilePolicyApp(t *testing.T) *Headscale { + t.Helper() + + tmpDir := t.TempDir() + + cfg := types.Config{ + ServerURL: "http://localhost:8080", + NoisePrivateKeyPath: tmpDir + "/noise_private.key", + Database: types.DatabaseConfig{ + Type: "sqlite3", + Sqlite: types.SqliteConfig{ + Path: tmpDir + "/headscale_test.db", + }, + }, + OIDC: types.OIDCConfig{}, + Policy: types.PolicyConfig{ + Mode: types.PolicyModeFile, + }, + Tuning: types.Tuning{ + BatchChangeDelay: 100 * time.Millisecond, + BatcherWorkers: 1, + }, + } + + app, err := NewHeadscale(&cfg) + require.NoError(t, err) + + app.mapBatcher = mapper.NewBatcherAndMapper(&cfg, app.state) + app.mapBatcher.Start() + + t.Cleanup(func() { + if app.mapBatcher != nil { + app.mapBatcher.Close() + } + }) + + return app +} diff --git a/hscontrol/apiv1_preauthkeys_test.go b/hscontrol/apiv1_preauthkeys_test.go new file mode 100644 index 000000000..2df5f8e6d --- /dev/null +++ b/hscontrol/apiv1_preauthkeys_test.go @@ -0,0 +1,163 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seedPreAuthKeys creates a user-owned key and a tagged (system-created) key; +// the tagged key exercises the user:null path. +func seedPreAuthKeys() func(t *testing.T, app *Headscale) { + return func(t *testing.T, app *Headscale) { + t.Helper() + + user := app.state.CreateUserForTest("alice") + uid := types.UserID(user.ID) + exp := time.Time{} + + _, err := app.state.CreatePreAuthKey(&uid, true, false, &exp, nil) + require.NoError(t, err) + + // Tagged, system-created: no user. + _, err = app.state.CreatePreAuthKey(nil, false, true, &exp, []string{"tag:test"}) + require.NoError(t, err) + } +} + +func TestAPIV1CreatePreAuthKey(t *testing.T) { + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + h.app.state.CreateUserForTest("alice") + + res := h.callHuma(http.MethodPost, "/api/v1/preauthkey", + []byte(`{"user":"1","reusable":true}`)) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + PreAuthKey map[string]any `json:"preAuthKey"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "1", got.PreAuthKey["id"]) + assert.Equal(t, true, got.PreAuthKey["reusable"]) + // Zero-value fields are emitted (EmitUnpopulated parity). + assert.Equal(t, false, got.PreAuthKey["ephemeral"]) + assert.Equal(t, false, got.PreAuthKey["used"]) + assert.Contains(t, got.PreAuthKey, "expiration") + assert.Contains(t, got.PreAuthKey, "createdAt") + // Empty tags serialize as [], not null. + assert.Equal(t, []any{}, got.PreAuthKey["aclTags"]) + // The freshly created key returns its full secret. + assert.NotEmpty(t, got.PreAuthKey["key"]) + user, ok := got.PreAuthKey["user"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "1", user["id"]) + }) + + t.Run("tagged key has null user", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodPost, "/api/v1/preauthkey", + []byte(`{"aclTags":["tag:test"]}`)) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + PreAuthKey map[string]any `json:"preAuthKey"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Nil(t, got.PreAuthKey["user"]) + assert.Equal(t, []any{"tag:test"}, got.PreAuthKey["aclTags"]) + }) + + t.Run("invalid tag parity", func(t *testing.T) { + h := newAPIV1Harness(t) + h.app.state.CreateUserForTest("alice") + res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey", + []byte(`{"user":"1","aclTags":["badtag"]}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("nonexistent user parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey", []byte(`{"user":"999"}`)) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid user id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey", []byte(`{"user":"abc"}`)) + assertStatus(t, res, http.StatusBadRequest) + }) + + t.Run("neither tagged nor owned parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey", []byte(`{"reusable":true}`)) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1ExpirePreAuthKey(t *testing.T) { + t.Run("parity", func(t *testing.T) { + assertParityIsolated(t, seedPreAuthKeys(), http.MethodPost, + "/api/v1/preauthkey/expire", []byte(`{"id":"1"}`)) + }) + + t.Run("nonexistent parity", func(t *testing.T) { + res := assertParityIsolated(t, seedPreAuthKeys(), http.MethodPost, + "/api/v1/preauthkey/expire", []byte(`{"id":"99999"}`)) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/preauthkey/expire", []byte(`{"id":"abc"}`)) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1DeletePreAuthKey(t *testing.T) { + t.Run("parity", func(t *testing.T) { + assertParityIsolated(t, seedPreAuthKeys(), http.MethodDelete, + "/api/v1/preauthkey?id=1", nil) + }) + + t.Run("nonexistent parity", func(t *testing.T) { + res := assertParityIsolated(t, seedPreAuthKeys(), http.MethodDelete, + "/api/v1/preauthkey?id=99999", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/preauthkey?id=abc", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1ListPreAuthKeys(t *testing.T) { + t.Run("empty returns empty array", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodGet, "/api/v1/preauthkey", nil) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{"preAuthKeys":[]}`, string(res.body)) + }) + + t.Run("empty parity", func(t *testing.T) { + h := newAPIV1Harness(t) + h.assertParity(t, http.MethodGet, "/api/v1/preauthkey", nil) + }) + + t.Run("all parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedPreAuthKeys()(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/preauthkey", nil) + }) +} diff --git a/hscontrol/apiv1_users_test.go b/hscontrol/apiv1_users_test.go new file mode 100644 index 000000000..428ba21c6 --- /dev/null +++ b/hscontrol/apiv1_users_test.go @@ -0,0 +1,123 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func seedUsers(names ...string) func(t *testing.T, app *Headscale) { + return func(t *testing.T, app *Headscale) { + t.Helper() + + for _, n := range names { + app.state.CreateUserForTest(n) + } + } +} + +func TestAPIV1CreateUser(t *testing.T) { + t.Run("huma response shape", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodPost, "/api/v1/user", []byte(`{"name":"test"}`)) + require.Equal(t, http.StatusOK, res.status) + + var got struct { + User map[string]any `json:"user"` + } + require.NoError(t, json.Unmarshal(res.body, &got)) + + assert.Equal(t, "1", got.User["id"]) + assert.Equal(t, "test", got.User["name"]) + assert.Contains(t, got.User, "createdAt") + // Zero-value fields are emitted as empty strings (EmitUnpopulated parity). + assert.Empty(t, got.User["email"]) + assert.Empty(t, got.User["displayName"]) + }) + + t.Run("parity", func(t *testing.T) { + assertParityIsolated(t, nil, http.MethodPost, "/api/v1/user", + []byte(`{"name":"test","displayName":"Test","email":"t@example.com"}`)) + }) + + t.Run("duplicate name parity", func(t *testing.T) { + res := assertParityIsolated(t, seedUsers("dup"), http.MethodPost, "/api/v1/user", + []byte(`{"name":"dup"}`)) + assertStatus(t, res, http.StatusConflict) + }) +} + +func TestAPIV1RenameUser(t *testing.T) { + t.Run("parity", func(t *testing.T) { + assertParityIsolated(t, seedUsers("alice"), http.MethodPost, + "/api/v1/user/1/rename/bob", nil) + }) + + t.Run("nonexistent parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/user/999/rename/bob", nil) + assertStatus(t, res, http.StatusNotFound) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodPost, "/api/v1/user/abc/rename/bob", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} + +func TestAPIV1DeleteUser(t *testing.T) { + t.Run("parity", func(t *testing.T) { + assertParityIsolated(t, seedUsers("alice"), http.MethodDelete, "/api/v1/user/1", nil) + }) + + t.Run("nonexistent parity", func(t *testing.T) { + h := newAPIV1Harness(t) + res := h.assertParity(t, http.MethodDelete, "/api/v1/user/999", nil) + assertStatus(t, res, http.StatusNotFound) + }) +} + +func TestAPIV1ListUsers(t *testing.T) { + t.Run("empty returns empty array", func(t *testing.T) { + h := newAPIV1Harness(t) + + res := h.callHuma(http.MethodGet, "/api/v1/user", nil) + require.Equal(t, http.StatusOK, res.status) + assert.JSONEq(t, `{"users":[]}`, string(res.body)) + }) + + t.Run("empty parity", func(t *testing.T) { + h := newAPIV1Harness(t) + h.assertParity(t, http.MethodGet, "/api/v1/user", nil) + }) + + t.Run("all parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedUsers("alice", "bob", "carol")(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/user", nil) + }) + + t.Run("filter by name parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedUsers("alice", "bob")(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/user?name=alice", nil) + }) + + t.Run("filter by id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedUsers("alice", "bob")(t, h.app) + h.assertParity(t, http.MethodGet, "/api/v1/user?id=2", nil) + }) + + t.Run("invalid id parity", func(t *testing.T) { + h := newAPIV1Harness(t) + seedUsers("alice")(t, h.app) + res := h.assertParity(t, http.MethodGet, "/api/v1/user?id=abc", nil) + assertStatus(t, res, http.StatusBadRequest) + }) +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_both_id_and_prefix_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_both_id_and_prefix_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_both_id_and_prefix_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_invalid_id_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_not_found_by_prefix_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_not_found_by_prefix_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_not_found_by_prefix_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_path_prefix_with_id_query_is_both_->_400_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_path_prefix_with_id_query_is_both_->_400_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyDelete_path_prefix_with_id_query_is_both_->_400_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_both_id_and_prefix_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_both_id_and_prefix_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_both_id_and_prefix_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_by_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_by_id_parity.json new file mode 100644 index 000000000..01e7b2dec --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_by_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 200, + "body": {} +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_by_prefix_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_by_prefix_parity.json new file mode 100644 index 000000000..01e7b2dec --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_by_prefix_parity.json @@ -0,0 +1,4 @@ +{ + "status": 200, + "body": {} +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_neither_id_nor_prefix_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_neither_id_nor_prefix_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_neither_id_nor_prefix_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_not_found_by_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_not_found_by_id_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyExpire_not_found_by_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_empty_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_empty_parity.json new file mode 100644 index 000000000..8a881a463 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_empty_parity.json @@ -0,0 +1,6 @@ +{ + "status": 200, + "body": { + "apiKeys": [] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_nil_timestamps_emitted_as_null_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_nil_timestamps_emitted_as_null_parity.json new file mode 100644 index 000000000..e96d01116 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_nil_timestamps_emitted_as_null_parity.json @@ -0,0 +1,14 @@ +{ + "status": 200, + "body": { + "apiKeys": [ + { + "createdAt": "\u003ctimestamp\u003e", + "expiration": null, + "id": "1", + "lastSeen": null, + "prefix": "\u003csecret\u003e" + } + ] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_populated_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_populated_parity.json new file mode 100644 index 000000000..1a838cf4b --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_populated_parity.json @@ -0,0 +1,21 @@ +{ + "status": 200, + "body": { + "apiKeys": [ + { + "createdAt": "\u003ctimestamp\u003e", + "expiration": null, + "id": "1", + "lastSeen": null, + "prefix": "\u003csecret\u003e" + }, + { + "createdAt": "\u003ctimestamp\u003e", + "expiration": null, + "id": "2", + "lastSeen": null, + "prefix": "\u003csecret\u003e" + } + ] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_zero_expiration_emitted_as_zero_instant_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_zero_expiration_emitted_as_zero_instant_parity.json new file mode 100644 index 000000000..e5fd9e019 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ApiKeyList_zero_expiration_emitted_as_zero_instant_parity.json @@ -0,0 +1,14 @@ +{ + "status": 200, + "body": { + "apiKeys": [ + { + "createdAt": "\u003ctimestamp\u003e", + "expiration": "\u003ctimestamp\u003e", + "id": "1", + "lastSeen": null, + "prefix": "\u003csecret\u003e" + } + ] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1AuthApprove_invalid_auth_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthApprove_invalid_auth_id_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthApprove_invalid_auth_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1AuthApprove_no_pending_session_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthApprove_no_pending_session_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthApprove_no_pending_session_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_happy_path_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_happy_path_parity.json new file mode 100644 index 000000000..628457d4d --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_happy_path_parity.json @@ -0,0 +1,34 @@ +{ + "status": 200, + "body": { + "node": { + "approvedRoutes": [], + "availableRoutes": [], + "createdAt": "\u003ctimestamp\u003e", + "discoKey": "\u003csecret\u003e", + "expiry": null, + "givenName": "regnode", + "id": "1", + "ipAddresses": [], + "lastSeen": "\u003ctimestamp\u003e", + "machineKey": "\u003csecret\u003e", + "name": "regnode", + "nodeKey": "\u003csecret\u003e", + "online": false, + "preAuthKey": null, + "registerMethod": "REGISTER_METHOD_CLI", + "subnetRoutes": [], + "tags": [], + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + } + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_invalid_auth_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_invalid_auth_id_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_invalid_auth_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_no_pending_session_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_no_pending_session_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_no_pending_session_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_unknown_user_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_unknown_user_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthRegister_unknown_user_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1AuthReject_invalid_auth_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthReject_invalid_auth_id_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthReject_invalid_auth_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1AuthReject_no_pending_session_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthReject_no_pending_session_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1AuthReject_no_pending_session_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_invalid_tag_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_invalid_tag_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_invalid_tag_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_invalid_user_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_invalid_user_id_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_invalid_user_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_neither_tagged_nor_owned_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_neither_tagged_nor_owned_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_neither_tagged_nor_owned_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_nonexistent_user_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_nonexistent_user_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1CreatePreAuthKey_nonexistent_user_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1CreateUser_duplicate_name_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1CreateUser_duplicate_name_parity.json new file mode 100644 index 000000000..0704ac4ce --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1CreateUser_duplicate_name_parity.json @@ -0,0 +1,4 @@ +{ + "status": 409, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1CreateUser_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1CreateUser_parity.json new file mode 100644 index 000000000..4349ea0ef --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1CreateUser_parity.json @@ -0,0 +1,15 @@ +{ + "status": 200, + "body": { + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "Test", + "email": "t@example.com", + "id": "1", + "name": "test", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_invalid_id_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_nonexistent_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_nonexistent_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_nonexistent_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_parity.json new file mode 100644 index 000000000..01e7b2dec --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1DeletePreAuthKey_parity.json @@ -0,0 +1,4 @@ +{ + "status": 200, + "body": {} +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1DeleteUser_nonexistent_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1DeleteUser_nonexistent_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1DeleteUser_nonexistent_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1DeleteUser_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1DeleteUser_parity.json new file mode 100644 index 000000000..01e7b2dec --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1DeleteUser_parity.json @@ -0,0 +1,4 @@ +{ + "status": 200, + "body": {} +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_invalid_id_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_nonexistent_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_nonexistent_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_nonexistent_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_parity.json new file mode 100644 index 000000000..01e7b2dec --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ExpirePreAuthKey_parity.json @@ -0,0 +1,4 @@ +{ + "status": 200, + "body": {} +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1Health_parity_with_gateway.json b/hscontrol/testdata/apiv1_golden/TestAPIV1Health_parity_with_gateway.json new file mode 100644 index 000000000..668b62ef7 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1Health_parity_with_gateway.json @@ -0,0 +1,6 @@ +{ + "status": 200, + "body": { + "databaseConnectivity": true + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ListPreAuthKeys_all_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ListPreAuthKeys_all_parity.json new file mode 100644 index 000000000..cd8a9305b --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ListPreAuthKeys_all_parity.json @@ -0,0 +1,40 @@ +{ + "status": 200, + "body": { + "preAuthKeys": [ + { + "aclTags": [], + "createdAt": "\u003ctimestamp\u003e", + "ephemeral": false, + "expiration": "\u003ctimestamp\u003e", + "id": "1", + "key": "\u003csecret\u003e", + "reusable": true, + "used": false, + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + }, + { + "aclTags": [ + "tag:test" + ], + "createdAt": "\u003ctimestamp\u003e", + "ephemeral": true, + "expiration": "\u003ctimestamp\u003e", + "id": "2", + "key": "\u003csecret\u003e", + "reusable": false, + "used": false, + "user": null + } + ] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ListPreAuthKeys_empty_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ListPreAuthKeys_empty_parity.json new file mode 100644 index 000000000..1a1dafbd4 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ListPreAuthKeys_empty_parity.json @@ -0,0 +1,6 @@ +{ + "status": 200, + "body": { + "preAuthKeys": [] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_all_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_all_parity.json new file mode 100644 index 000000000..6d789758d --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_all_parity.json @@ -0,0 +1,37 @@ +{ + "status": 200, + "body": { + "users": [ + { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + }, + { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "2", + "name": "bob", + "profilePicUrl": "", + "provider": "", + "providerId": "" + }, + { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "3", + "name": "carol", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + ] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_empty_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_empty_parity.json new file mode 100644 index 000000000..5fdc5311f --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_empty_parity.json @@ -0,0 +1,6 @@ +{ + "status": 200, + "body": { + "users": [] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_filter_by_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_filter_by_id_parity.json new file mode 100644 index 000000000..b3d1bf4ad --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_filter_by_id_parity.json @@ -0,0 +1,17 @@ +{ + "status": 200, + "body": { + "users": [ + { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "2", + "name": "bob", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + ] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_filter_by_name_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_filter_by_name_parity.json new file mode 100644 index 000000000..48b345b05 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_filter_by_name_parity.json @@ -0,0 +1,17 @@ +{ + "status": 200, + "body": { + "users": [ + { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + ] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_invalid_id_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1ListUsers_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeBackfillIPs_confirmed_empty_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeBackfillIPs_confirmed_empty_parity.json new file mode 100644 index 000000000..3b98955bc --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeBackfillIPs_confirmed_empty_parity.json @@ -0,0 +1,6 @@ +{ + "status": 200, + "body": { + "changes": [] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeBackfillIPs_unconfirmed_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeBackfillIPs_unconfirmed_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeBackfillIPs_unconfirmed_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDebugCreate_invalid_key_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDebugCreate_invalid_key_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDebugCreate_invalid_key_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDebugCreate_unknown_user_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDebugCreate_unknown_user_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDebugCreate_unknown_user_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_happy_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_happy_parity.json new file mode 100644 index 000000000..01e7b2dec --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_happy_parity.json @@ -0,0 +1,4 @@ +{ + "status": 200, + "body": {} +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_invalid_id_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_not_found_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_not_found_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeDelete_not_found_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeExpire_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeExpire_invalid_id_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeExpire_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeExpire_not_found_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeExpire_not_found_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeExpire_not_found_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_happy_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_happy_parity.json new file mode 100644 index 000000000..82318d034 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_happy_parity.json @@ -0,0 +1,53 @@ +{ + "status": 200, + "body": { + "node": { + "approvedRoutes": [], + "availableRoutes": [], + "createdAt": "\u003ctimestamp\u003e", + "discoKey": "\u003csecret\u003e", + "expiry": null, + "givenName": "node-a", + "id": "1", + "ipAddresses": [], + "lastSeen": "\u003ctimestamp\u003e", + "machineKey": "\u003csecret\u003e", + "name": "node-a", + "nodeKey": "\u003csecret\u003e", + "online": false, + "preAuthKey": { + "aclTags": [], + "createdAt": "\u003ctimestamp\u003e", + "ephemeral": false, + "expiration": null, + "id": "1", + "key": "\u003csecret\u003e", + "reusable": false, + "used": true, + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + }, + "registerMethod": "REGISTER_METHOD_AUTH_KEY", + "subnetRoutes": [], + "tags": [], + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + } + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_invalid_id_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_not_found_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_not_found_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_not_found_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_tagged_node_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_tagged_node_parity.json new file mode 100644 index 000000000..a55a4e79a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeGet_tagged_node_parity.json @@ -0,0 +1,48 @@ +{ + "status": 200, + "body": { + "node": { + "approvedRoutes": [], + "availableRoutes": [], + "createdAt": "\u003ctimestamp\u003e", + "discoKey": "\u003csecret\u003e", + "expiry": null, + "givenName": "node-b", + "id": "1", + "ipAddresses": [], + "lastSeen": "\u003ctimestamp\u003e", + "machineKey": "\u003csecret\u003e", + "name": "node-b", + "nodeKey": "\u003csecret\u003e", + "online": false, + "preAuthKey": { + "aclTags": [ + "tag:initial" + ], + "createdAt": "\u003ctimestamp\u003e", + "ephemeral": false, + "expiration": null, + "id": "1", + "key": "\u003csecret\u003e", + "reusable": false, + "used": true, + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "bob", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + }, + "registerMethod": "REGISTER_METHOD_AUTH_KEY", + "subnetRoutes": [], + "tags": [ + "tag:initial" + ], + "user": null + } + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_all_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_all_parity.json new file mode 100644 index 000000000..2dae3fb75 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_all_parity.json @@ -0,0 +1,103 @@ +{ + "status": 200, + "body": { + "nodes": [ + { + "approvedRoutes": [], + "availableRoutes": [], + "createdAt": "\u003ctimestamp\u003e", + "discoKey": "\u003csecret\u003e", + "expiry": null, + "givenName": "node-a", + "id": "1", + "ipAddresses": [], + "lastSeen": "\u003ctimestamp\u003e", + "machineKey": "\u003csecret\u003e", + "name": "node-a", + "nodeKey": "\u003csecret\u003e", + "online": false, + "preAuthKey": { + "aclTags": [], + "createdAt": "\u003ctimestamp\u003e", + "ephemeral": false, + "expiration": null, + "id": "1", + "key": "\u003csecret\u003e", + "reusable": false, + "used": true, + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + }, + "registerMethod": "REGISTER_METHOD_AUTH_KEY", + "subnetRoutes": [], + "tags": [], + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + }, + { + "approvedRoutes": [], + "availableRoutes": [], + "createdAt": "\u003ctimestamp\u003e", + "discoKey": "\u003csecret\u003e", + "expiry": null, + "givenName": "node-b", + "id": "2", + "ipAddresses": [], + "lastSeen": "\u003ctimestamp\u003e", + "machineKey": "\u003csecret\u003e", + "name": "node-b", + "nodeKey": "\u003csecret\u003e", + "online": false, + "preAuthKey": { + "aclTags": [], + "createdAt": "\u003ctimestamp\u003e", + "ephemeral": false, + "expiration": null, + "id": "2", + "key": "\u003csecret\u003e", + "reusable": false, + "used": true, + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "2", + "name": "bob", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + }, + "registerMethod": "REGISTER_METHOD_AUTH_KEY", + "subnetRoutes": [], + "tags": [], + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "2", + "name": "bob", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + } + ] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_empty_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_empty_parity.json new file mode 100644 index 000000000..1e2f04d2a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_empty_parity.json @@ -0,0 +1,6 @@ +{ + "status": 200, + "body": { + "nodes": [] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_filter_by_user_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_filter_by_user_parity.json new file mode 100644 index 000000000..867d783c8 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_filter_by_user_parity.json @@ -0,0 +1,55 @@ +{ + "status": 200, + "body": { + "nodes": [ + { + "approvedRoutes": [], + "availableRoutes": [], + "createdAt": "\u003ctimestamp\u003e", + "discoKey": "\u003csecret\u003e", + "expiry": null, + "givenName": "node-a", + "id": "1", + "ipAddresses": [], + "lastSeen": "\u003ctimestamp\u003e", + "machineKey": "\u003csecret\u003e", + "name": "node-a", + "nodeKey": "\u003csecret\u003e", + "online": false, + "preAuthKey": { + "aclTags": [], + "createdAt": "\u003ctimestamp\u003e", + "ephemeral": false, + "expiration": null, + "id": "1", + "key": "\u003csecret\u003e", + "reusable": false, + "used": true, + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + }, + "registerMethod": "REGISTER_METHOD_AUTH_KEY", + "subnetRoutes": [], + "tags": [], + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + } + ] + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_unknown_user_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_unknown_user_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeList_unknown_user_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_happy_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_happy_parity.json new file mode 100644 index 000000000..647d42c11 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_happy_parity.json @@ -0,0 +1,34 @@ +{ + "status": 200, + "body": { + "node": { + "approvedRoutes": [], + "availableRoutes": [], + "createdAt": "\u003ctimestamp\u003e", + "discoKey": "\u003csecret\u003e", + "expiry": null, + "givenName": "registered-node", + "id": "1", + "ipAddresses": [], + "lastSeen": "\u003ctimestamp\u003e", + "machineKey": "\u003csecret\u003e", + "name": "registered-node", + "nodeKey": "\u003csecret\u003e", + "online": false, + "preAuthKey": null, + "registerMethod": "REGISTER_METHOD_CLI", + "subnetRoutes": [], + "tags": [], + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "alice", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + } + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_invalid_key_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_invalid_key_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_invalid_key_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_unknown_user_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_unknown_user_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRegister_unknown_user_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRename_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRename_invalid_id_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRename_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRename_not_found_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRename_not_found_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeRename_not_found_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetApprovedRoutes_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetApprovedRoutes_invalid_id_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetApprovedRoutes_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetApprovedRoutes_not_found_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetApprovedRoutes_not_found_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetApprovedRoutes_not_found_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_empty_tags_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_empty_tags_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_empty_tags_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_invalid_id_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_not_found_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_not_found_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_not_found_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_unauthorized_tag_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_unauthorized_tag_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1NodeSetTags_unauthorized_tag_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyCheck_invalid_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyCheck_invalid_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyCheck_invalid_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyCheck_valid_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyCheck_valid_parity.json new file mode 100644 index 000000000..01e7b2dec --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyCheck_valid_parity.json @@ -0,0 +1,4 @@ +{ + "status": 200, + "body": {} +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyGet_empty_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyGet_empty_parity.json new file mode 100644 index 000000000..2e20a6ad8 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyGet_empty_parity.json @@ -0,0 +1,4 @@ +{ + "status": 500, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyGet_set_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyGet_set_parity.json new file mode 100644 index 000000000..dc080d6a8 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicyGet_set_parity.json @@ -0,0 +1,7 @@ +{ + "status": 200, + "body": { + "policy": "{\"acls\":[{\"action\":\"accept\",\"src\":[\"*\"],\"dst\":[\"*:*\"]}]}", + "updatedAt": "\u003ctimestamp\u003e" + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1PolicySet_invalid_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicySet_invalid_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicySet_invalid_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1PolicySet_valid_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicySet_valid_parity.json new file mode 100644 index 000000000..dc080d6a8 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1PolicySet_valid_parity.json @@ -0,0 +1,7 @@ +{ + "status": 200, + "body": { + "policy": "{\"acls\":[{\"action\":\"accept\",\"src\":[\"*\"],\"dst\":[\"*:*\"]}]}", + "updatedAt": "\u003ctimestamp\u003e" + } +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_invalid_id_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_invalid_id_parity.json new file mode 100644 index 000000000..876045e37 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_invalid_id_parity.json @@ -0,0 +1,4 @@ +{ + "status": 400, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_nonexistent_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_nonexistent_parity.json new file mode 100644 index 000000000..ee9c4b88a --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_nonexistent_parity.json @@ -0,0 +1,4 @@ +{ + "status": 404, + "body": null +} diff --git a/hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_parity.json b/hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_parity.json new file mode 100644 index 000000000..386034b30 --- /dev/null +++ b/hscontrol/testdata/apiv1_golden/TestAPIV1RenameUser_parity.json @@ -0,0 +1,15 @@ +{ + "status": 200, + "body": { + "user": { + "createdAt": "\u003ctimestamp\u003e", + "displayName": "", + "email": "", + "id": "1", + "name": "bob", + "profilePicUrl": "", + "provider": "", + "providerId": "" + } + } +} From ba90048cfbd4fab086f420eb514582b2f4d69536 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 19 Jun 2026 06:13:14 +0000 Subject: [PATCH 059/127] gen/client/v1: add the generated HTTP client Generate a strongly-typed Go client (oapi-codegen) from the emitted spec, committed under gen/client/v1 as package clientv1. Tests exercise it against the in-memory Huma server over both TCP and the unix socket. --- gen/client/v1/client.gen.go | 4728 ++++++++++++++++++++++++++++++++ hscontrol/apiv1_client_test.go | 50 + hscontrol/apiv1_socket_test.go | 61 + 3 files changed, 4839 insertions(+) create mode 100644 gen/client/v1/client.gen.go create mode 100644 hscontrol/apiv1_client_test.go create mode 100644 hscontrol/apiv1_socket_test.go diff --git a/gen/client/v1/client.gen.go b/gen/client/v1/client.gen.go new file mode 100644 index 000000000..f2b120a46 --- /dev/null +++ b/gen/client/v1/client.gen.go @@ -0,0 +1,4728 @@ +// Package clientv1 provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.1 DO NOT EDIT. +package clientv1 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/oapi-codegen/runtime" +) + +const ( + BearerScopes bearerContextKey = "bearer.Scopes" +) + +// Defines values for NodeRegisterMethod. +const ( + REGISTERMETHODAUTHKEY NodeRegisterMethod = "REGISTER_METHOD_AUTH_KEY" + REGISTERMETHODCLI NodeRegisterMethod = "REGISTER_METHOD_CLI" + REGISTERMETHODOIDC NodeRegisterMethod = "REGISTER_METHOD_OIDC" + REGISTERMETHODUNSPECIFIED NodeRegisterMethod = "REGISTER_METHOD_UNSPECIFIED" +) + +// Valid indicates whether the value is a known member of the NodeRegisterMethod enum. +func (e NodeRegisterMethod) Valid() bool { + switch e { + case REGISTERMETHODAUTHKEY: + return true + case REGISTERMETHODCLI: + return true + case REGISTERMETHODOIDC: + return true + case REGISTERMETHODUNSPECIFIED: + return true + default: + return false + } +} + +// ApiKey defines model for ApiKey. +type ApiKey struct { + CreatedAt *time.Time `json:"createdAt"` + Expiration *time.Time `json:"expiration"` + Id string `json:"id"` + LastSeen *time.Time `json:"lastSeen"` + Prefix string `json:"prefix"` +} + +// AuthApproveOutputBody defines model for AuthApproveOutputBody. +type AuthApproveOutputBody = map[string]interface{} + +// AuthApproveRequestBody defines model for AuthApproveRequestBody. +type AuthApproveRequestBody struct { + AuthId *string `json:"authId,omitempty"` +} + +// AuthRegisterOutputBody defines model for AuthRegisterOutputBody. +type AuthRegisterOutputBody struct { + Node Node `json:"node"` +} + +// AuthRegisterRequestBody defines model for AuthRegisterRequestBody. +type AuthRegisterRequestBody struct { + AuthId *string `json:"authId,omitempty"` + User *string `json:"user,omitempty"` +} + +// AuthRejectOutputBody defines model for AuthRejectOutputBody. +type AuthRejectOutputBody = map[string]interface{} + +// AuthRejectRequestBody defines model for AuthRejectRequestBody. +type AuthRejectRequestBody struct { + AuthId *string `json:"authId,omitempty"` +} + +// BackfillNodeIPsOutputBody defines model for BackfillNodeIPsOutputBody. +type BackfillNodeIPsOutputBody struct { + Changes []string `json:"changes"` +} + +// CheckPolicyOutputBody defines model for CheckPolicyOutputBody. +type CheckPolicyOutputBody = map[string]interface{} + +// CreateApiKeyOutputBody defines model for CreateApiKeyOutputBody. +type CreateApiKeyOutputBody struct { + ApiKey string `json:"apiKey"` +} + +// CreateApiKeyRequestBody defines model for CreateApiKeyRequestBody. +type CreateApiKeyRequestBody struct { + Expiration *time.Time `json:"expiration,omitempty"` +} + +// CreatePreAuthKeyRequestBody defines model for CreatePreAuthKeyRequestBody. +type CreatePreAuthKeyRequestBody struct { + AclTags *[]string `json:"aclTags,omitempty"` + Ephemeral *bool `json:"ephemeral,omitempty"` + Expiration *time.Time `json:"expiration,omitempty"` + Reusable *bool `json:"reusable,omitempty"` + User *string `json:"user,omitempty"` +} + +// CreateUserRequestBody defines model for CreateUserRequestBody. +type CreateUserRequestBody struct { + DisplayName *string `json:"displayName,omitempty"` + Email *string `json:"email,omitempty"` + Name *string `json:"name,omitempty"` + PictureUrl *string `json:"pictureUrl,omitempty"` +} + +// DebugCreateNodeRequestBody defines model for DebugCreateNodeRequestBody. +type DebugCreateNodeRequestBody struct { + Key *string `json:"key,omitempty"` + Name *string `json:"name,omitempty"` + Routes *[]string `json:"routes,omitempty"` + User *string `json:"user,omitempty"` +} + +// DeleteApiKeyOutputBody defines model for DeleteApiKeyOutputBody. +type DeleteApiKeyOutputBody = map[string]interface{} + +// DeleteNodeOutputBody defines model for DeleteNodeOutputBody. +type DeleteNodeOutputBody = map[string]interface{} + +// DeletePreAuthKeyOutputBody defines model for DeletePreAuthKeyOutputBody. +type DeletePreAuthKeyOutputBody = map[string]interface{} + +// DeleteUserOutputBody defines model for DeleteUserOutputBody. +type DeleteUserOutputBody = map[string]interface{} + +// ErrorDetail defines model for ErrorDetail. +type ErrorDetail struct { + // Location Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id' + Location *string `json:"location,omitempty"` + + // Message Error message text + Message *string `json:"message,omitempty"` + + // Value The value at the given location + Value interface{} `json:"value,omitempty"` +} + +// ErrorModel defines model for ErrorModel. +type ErrorModel struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail *string `json:"detail,omitempty"` + + // Errors Optional list of individual error details + Errors *[]ErrorDetail `json:"errors,omitempty"` + + // Instance A URI reference that identifies the specific occurrence of the problem. + Instance *string `json:"instance,omitempty"` + + // Status HTTP status code + Status *int64 `json:"status,omitempty"` + + // Title A short, human-readable summary of the problem type. This value should not change between occurrences of the error. + Title *string `json:"title,omitempty"` + + // Type A URI reference to human-readable documentation for the error. + Type *string `json:"type,omitempty"` +} + +// ExpireApiKeyOutputBody defines model for ExpireApiKeyOutputBody. +type ExpireApiKeyOutputBody = map[string]interface{} + +// ExpireApiKeyRequestBody defines model for ExpireApiKeyRequestBody. +type ExpireApiKeyRequestBody struct { + Id *string `json:"id,omitempty"` + Prefix *string `json:"prefix,omitempty"` +} + +// ExpireNodeRequestBody defines model for ExpireNodeRequestBody. +type ExpireNodeRequestBody struct { + DisableExpiry *bool `json:"disableExpiry,omitempty"` + Expiry *time.Time `json:"expiry,omitempty"` +} + +// ExpirePreAuthKeyOutputBody defines model for ExpirePreAuthKeyOutputBody. +type ExpirePreAuthKeyOutputBody = map[string]interface{} + +// ExpirePreAuthKeyRequestBody defines model for ExpirePreAuthKeyRequestBody. +type ExpirePreAuthKeyRequestBody struct { + Id *string `json:"id,omitempty"` +} + +// HealthResponseBody defines model for HealthResponseBody. +type HealthResponseBody struct { + DatabaseConnectivity bool `json:"databaseConnectivity"` +} + +// ListApiKeysOutputBody defines model for ListApiKeysOutputBody. +type ListApiKeysOutputBody struct { + ApiKeys []ApiKey `json:"apiKeys"` +} + +// ListNodesOutputBody defines model for ListNodesOutputBody. +type ListNodesOutputBody struct { + Nodes []Node `json:"nodes"` +} + +// ListPreAuthKeysOutputBody defines model for ListPreAuthKeysOutputBody. +type ListPreAuthKeysOutputBody struct { + PreAuthKeys []PreAuthKey `json:"preAuthKeys"` +} + +// ListUsersOutputBody defines model for ListUsersOutputBody. +type ListUsersOutputBody struct { + Users []User `json:"users"` +} + +// Node defines model for Node. +type Node struct { + ApprovedRoutes []string `json:"approvedRoutes"` + AvailableRoutes []string `json:"availableRoutes"` + CreatedAt time.Time `json:"createdAt"` + DiscoKey string `json:"discoKey"` + Expiry *time.Time `json:"expiry"` + GivenName string `json:"givenName"` + Id string `json:"id"` + IpAddresses []string `json:"ipAddresses"` + LastSeen *time.Time `json:"lastSeen"` + MachineKey string `json:"machineKey"` + Name string `json:"name"` + NodeKey string `json:"nodeKey"` + Online bool `json:"online"` + PreAuthKey NodePreAuthKey `json:"preAuthKey"` + RegisterMethod NodeRegisterMethod `json:"registerMethod"` + SubnetRoutes []string `json:"subnetRoutes"` + Tags []string `json:"tags"` + User User `json:"user"` +} + +// NodeRegisterMethod defines model for Node.RegisterMethod. +type NodeRegisterMethod string + +// NodeOutputBody defines model for NodeOutputBody. +type NodeOutputBody struct { + Node Node `json:"node"` +} + +// NodePreAuthKey defines model for NodePreAuthKey. +type NodePreAuthKey struct { + AclTags []string `json:"aclTags"` + CreatedAt *time.Time `json:"createdAt"` + Ephemeral bool `json:"ephemeral"` + Expiration *time.Time `json:"expiration"` + Id string `json:"id"` + Key string `json:"key"` + Reusable bool `json:"reusable"` + Used bool `json:"used"` + User User `json:"user"` +} + +// PolicyRequestBody defines model for PolicyRequestBody. +type PolicyRequestBody struct { + Policy *string `json:"policy,omitempty"` +} + +// PolicyResponseBody defines model for PolicyResponseBody. +type PolicyResponseBody struct { + Policy string `json:"policy"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// PreAuthKey defines model for PreAuthKey. +type PreAuthKey struct { + AclTags []string `json:"aclTags"` + CreatedAt time.Time `json:"createdAt"` + Ephemeral bool `json:"ephemeral"` + Expiration time.Time `json:"expiration"` + Id string `json:"id"` + Key string `json:"key"` + Reusable bool `json:"reusable"` + Used bool `json:"used"` + User User `json:"user"` +} + +// PreAuthKeyOutputBody defines model for PreAuthKeyOutputBody. +type PreAuthKeyOutputBody struct { + PreAuthKey PreAuthKey `json:"preAuthKey"` +} + +// SetApprovedRoutesRequestBody defines model for SetApprovedRoutesRequestBody. +type SetApprovedRoutesRequestBody struct { + Routes *[]string `json:"routes,omitempty"` +} + +// SetTagsRequestBody defines model for SetTagsRequestBody. +type SetTagsRequestBody struct { + Tags *[]string `json:"tags,omitempty"` +} + +// User defines model for User. +type User struct { + CreatedAt time.Time `json:"createdAt"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Id string `json:"id"` + Name string `json:"name"` + ProfilePicUrl string `json:"profilePicUrl"` + Provider string `json:"provider"` + ProviderId string `json:"providerId"` +} + +// UserOutputBody defines model for UserOutputBody. +type UserOutputBody struct { + User User `json:"user"` +} + +// bearerContextKey is the context key for bearer security scheme +type bearerContextKey string + +// DeleteApiKeyParams defines parameters for DeleteApiKey. +type DeleteApiKeyParams struct { + Id *string `form:"id,omitempty" json:"id,omitempty"` +} + +// ListNodesParams defines parameters for ListNodes. +type ListNodesParams struct { + User *string `form:"user,omitempty" json:"user,omitempty"` +} + +// BackfillNodeIPsParams defines parameters for BackfillNodeIPs. +type BackfillNodeIPsParams struct { + Confirmed *bool `form:"confirmed,omitempty" json:"confirmed,omitempty"` +} + +// RegisterNodeParams defines parameters for RegisterNode. +type RegisterNodeParams struct { + User *string `form:"user,omitempty" json:"user,omitempty"` + Key *string `form:"key,omitempty" json:"key,omitempty"` +} + +// DeletePreAuthKeyParams defines parameters for DeletePreAuthKey. +type DeletePreAuthKeyParams struct { + Id *string `form:"id,omitempty" json:"id,omitempty"` +} + +// ListUsersParams defines parameters for ListUsers. +type ListUsersParams struct { + Id *string `form:"id,omitempty" json:"id,omitempty"` + Name *string `form:"name,omitempty" json:"name,omitempty"` + Email *string `form:"email,omitempty" json:"email,omitempty"` +} + +// CreateApiKeyJSONRequestBody defines body for CreateApiKey for application/json ContentType. +type CreateApiKeyJSONRequestBody = CreateApiKeyRequestBody + +// ExpireApiKeyJSONRequestBody defines body for ExpireApiKey for application/json ContentType. +type ExpireApiKeyJSONRequestBody = ExpireApiKeyRequestBody + +// AuthApproveJSONRequestBody defines body for AuthApprove for application/json ContentType. +type AuthApproveJSONRequestBody = AuthApproveRequestBody + +// AuthRegisterJSONRequestBody defines body for AuthRegister for application/json ContentType. +type AuthRegisterJSONRequestBody = AuthRegisterRequestBody + +// AuthRejectJSONRequestBody defines body for AuthReject for application/json ContentType. +type AuthRejectJSONRequestBody = AuthRejectRequestBody + +// DebugCreateNodeJSONRequestBody defines body for DebugCreateNode for application/json ContentType. +type DebugCreateNodeJSONRequestBody = DebugCreateNodeRequestBody + +// SetApprovedRoutesJSONRequestBody defines body for SetApprovedRoutes for application/json ContentType. +type SetApprovedRoutesJSONRequestBody = SetApprovedRoutesRequestBody + +// ExpireNodeJSONRequestBody defines body for ExpireNode for application/json ContentType. +type ExpireNodeJSONRequestBody = ExpireNodeRequestBody + +// SetTagsJSONRequestBody defines body for SetTags for application/json ContentType. +type SetTagsJSONRequestBody = SetTagsRequestBody + +// SetPolicyJSONRequestBody defines body for SetPolicy for application/json ContentType. +type SetPolicyJSONRequestBody = PolicyRequestBody + +// CheckPolicyJSONRequestBody defines body for CheckPolicy for application/json ContentType. +type CheckPolicyJSONRequestBody = PolicyRequestBody + +// CreatePreAuthKeyJSONRequestBody defines body for CreatePreAuthKey for application/json ContentType. +type CreatePreAuthKeyJSONRequestBody = CreatePreAuthKeyRequestBody + +// ExpirePreAuthKeyJSONRequestBody defines body for ExpirePreAuthKey for application/json ContentType. +type ExpirePreAuthKeyJSONRequestBody = ExpirePreAuthKeyRequestBody + +// CreateUserJSONRequestBody defines body for CreateUser for application/json ContentType. +type CreateUserJSONRequestBody = CreateUserRequestBody + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // ListApiKeys request + ListApiKeys(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // CreateApiKeyWithBody request with any body + CreateApiKeyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CreateApiKey(ctx context.Context, body CreateApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ExpireApiKeyWithBody request with any body + ExpireApiKeyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + ExpireApiKey(ctx context.Context, body ExpireApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeleteApiKey request + DeleteApiKey(ctx context.Context, prefix string, params *DeleteApiKeyParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // AuthApproveWithBody request with any body + AuthApproveWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + AuthApprove(ctx context.Context, body AuthApproveJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // AuthRegisterWithBody request with any body + AuthRegisterWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + AuthRegister(ctx context.Context, body AuthRegisterJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // AuthRejectWithBody request with any body + AuthRejectWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + AuthReject(ctx context.Context, body AuthRejectJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DebugCreateNodeWithBody request with any body + DebugCreateNodeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + DebugCreateNode(ctx context.Context, body DebugCreateNodeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // Health request + Health(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListNodes request + ListNodes(ctx context.Context, params *ListNodesParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // BackfillNodeIPs request + BackfillNodeIPs(ctx context.Context, params *BackfillNodeIPsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // RegisterNode request + RegisterNode(ctx context.Context, params *RegisterNodeParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeleteNode request + DeleteNode(ctx context.Context, nodeId string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetNode request + GetNode(ctx context.Context, nodeId string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // SetApprovedRoutesWithBody request with any body + SetApprovedRoutesWithBody(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetApprovedRoutes(ctx context.Context, nodeId string, body SetApprovedRoutesJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ExpireNodeWithBody request with any body + ExpireNodeWithBody(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + ExpireNode(ctx context.Context, nodeId string, body ExpireNodeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // RenameNode request + RenameNode(ctx context.Context, nodeId string, newName string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // SetTagsWithBody request with any body + SetTagsWithBody(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetTags(ctx context.Context, nodeId string, body SetTagsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetPolicy request + GetPolicy(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // SetPolicyWithBody request with any body + SetPolicyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetPolicy(ctx context.Context, body SetPolicyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // CheckPolicyWithBody request with any body + CheckPolicyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CheckPolicy(ctx context.Context, body CheckPolicyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeletePreAuthKey request + DeletePreAuthKey(ctx context.Context, params *DeletePreAuthKeyParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListPreAuthKeys request + ListPreAuthKeys(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // CreatePreAuthKeyWithBody request with any body + CreatePreAuthKeyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CreatePreAuthKey(ctx context.Context, body CreatePreAuthKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ExpirePreAuthKeyWithBody request with any body + ExpirePreAuthKeyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + ExpirePreAuthKey(ctx context.Context, body ExpirePreAuthKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListUsers request + ListUsers(ctx context.Context, params *ListUsersParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // CreateUserWithBody request with any body + CreateUserWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CreateUser(ctx context.Context, body CreateUserJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeleteUser request + DeleteUser(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // RenameUser request + RenameUser(ctx context.Context, oldId string, newName string, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) ListApiKeys(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListApiKeysRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateApiKeyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateApiKeyRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateApiKey(ctx context.Context, body CreateApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateApiKeyRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ExpireApiKeyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewExpireApiKeyRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ExpireApiKey(ctx context.Context, body ExpireApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewExpireApiKeyRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeleteApiKey(ctx context.Context, prefix string, params *DeleteApiKeyParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteApiKeyRequest(c.Server, prefix, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) AuthApproveWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAuthApproveRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) AuthApprove(ctx context.Context, body AuthApproveJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAuthApproveRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) AuthRegisterWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAuthRegisterRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) AuthRegister(ctx context.Context, body AuthRegisterJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAuthRegisterRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) AuthRejectWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAuthRejectRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) AuthReject(ctx context.Context, body AuthRejectJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAuthRejectRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DebugCreateNodeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDebugCreateNodeRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DebugCreateNode(ctx context.Context, body DebugCreateNodeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDebugCreateNodeRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) Health(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewHealthRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ListNodes(ctx context.Context, params *ListNodesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListNodesRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) BackfillNodeIPs(ctx context.Context, params *BackfillNodeIPsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewBackfillNodeIPsRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) RegisterNode(ctx context.Context, params *RegisterNodeParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRegisterNodeRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeleteNode(ctx context.Context, nodeId string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteNodeRequest(c.Server, nodeId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetNode(ctx context.Context, nodeId string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetNodeRequest(c.Server, nodeId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetApprovedRoutesWithBody(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetApprovedRoutesRequestWithBody(c.Server, nodeId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetApprovedRoutes(ctx context.Context, nodeId string, body SetApprovedRoutesJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetApprovedRoutesRequest(c.Server, nodeId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ExpireNodeWithBody(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewExpireNodeRequestWithBody(c.Server, nodeId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ExpireNode(ctx context.Context, nodeId string, body ExpireNodeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewExpireNodeRequest(c.Server, nodeId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) RenameNode(ctx context.Context, nodeId string, newName string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRenameNodeRequest(c.Server, nodeId, newName) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetTagsWithBody(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetTagsRequestWithBody(c.Server, nodeId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetTags(ctx context.Context, nodeId string, body SetTagsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetTagsRequest(c.Server, nodeId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetPolicy(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetPolicyRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetPolicyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetPolicyRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetPolicy(ctx context.Context, body SetPolicyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetPolicyRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CheckPolicyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCheckPolicyRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CheckPolicy(ctx context.Context, body CheckPolicyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCheckPolicyRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeletePreAuthKey(ctx context.Context, params *DeletePreAuthKeyParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeletePreAuthKeyRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ListPreAuthKeys(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListPreAuthKeysRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreatePreAuthKeyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreatePreAuthKeyRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreatePreAuthKey(ctx context.Context, body CreatePreAuthKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreatePreAuthKeyRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ExpirePreAuthKeyWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewExpirePreAuthKeyRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ExpirePreAuthKey(ctx context.Context, body ExpirePreAuthKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewExpirePreAuthKeyRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ListUsers(ctx context.Context, params *ListUsersParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListUsersRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateUserWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateUserRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateUser(ctx context.Context, body CreateUserJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateUserRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeleteUser(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteUserRequest(c.Server, id) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) RenameUser(ctx context.Context, oldId string, newName string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRenameUserRequest(c.Server, oldId, newName) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewListApiKeysRequest generates requests for ListApiKeys +func NewListApiKeysRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/apikey") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewCreateApiKeyRequest calls the generic CreateApiKey builder with application/json body +func NewCreateApiKeyRequest(server string, body CreateApiKeyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCreateApiKeyRequestWithBody(server, "application/json", bodyReader) +} + +// NewCreateApiKeyRequestWithBody generates requests for CreateApiKey with any type of body +func NewCreateApiKeyRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/apikey") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewExpireApiKeyRequest calls the generic ExpireApiKey builder with application/json body +func NewExpireApiKeyRequest(server string, body ExpireApiKeyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewExpireApiKeyRequestWithBody(server, "application/json", bodyReader) +} + +// NewExpireApiKeyRequestWithBody generates requests for ExpireApiKey with any type of body +func NewExpireApiKeyRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/apikey/expire") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDeleteApiKeyRequest generates requests for DeleteApiKey +func NewDeleteApiKeyRequest(server string, prefix string, params *DeleteApiKeyParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "prefix", prefix, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/apikey/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Id != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "id", *params.Id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "uint64"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewAuthApproveRequest calls the generic AuthApprove builder with application/json body +func NewAuthApproveRequest(server string, body AuthApproveJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewAuthApproveRequestWithBody(server, "application/json", bodyReader) +} + +// NewAuthApproveRequestWithBody generates requests for AuthApprove with any type of body +func NewAuthApproveRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/auth/approve") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewAuthRegisterRequest calls the generic AuthRegister builder with application/json body +func NewAuthRegisterRequest(server string, body AuthRegisterJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewAuthRegisterRequestWithBody(server, "application/json", bodyReader) +} + +// NewAuthRegisterRequestWithBody generates requests for AuthRegister with any type of body +func NewAuthRegisterRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/auth/register") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewAuthRejectRequest calls the generic AuthReject builder with application/json body +func NewAuthRejectRequest(server string, body AuthRejectJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewAuthRejectRequestWithBody(server, "application/json", bodyReader) +} + +// NewAuthRejectRequestWithBody generates requests for AuthReject with any type of body +func NewAuthRejectRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/auth/reject") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDebugCreateNodeRequest calls the generic DebugCreateNode builder with application/json body +func NewDebugCreateNodeRequest(server string, body DebugCreateNodeJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewDebugCreateNodeRequestWithBody(server, "application/json", bodyReader) +} + +// NewDebugCreateNodeRequestWithBody generates requests for DebugCreateNode with any type of body +func NewDebugCreateNodeRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/debug/node") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewHealthRequest generates requests for Health +func NewHealthRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/health") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewListNodesRequest generates requests for ListNodes +func NewListNodesRequest(server string, params *ListNodesParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/node") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.User != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "user", *params.User, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewBackfillNodeIPsRequest generates requests for BackfillNodeIPs +func NewBackfillNodeIPsRequest(server string, params *BackfillNodeIPsParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/node/backfillips") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Confirmed != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "confirmed", *params.Confirmed, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewRegisterNodeRequest generates requests for RegisterNode +func NewRegisterNodeRequest(server string, params *RegisterNodeParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/node/register") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.User != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "user", *params.User, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Key != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "key", *params.Key, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewDeleteNodeRequest generates requests for DeleteNode +func NewDeleteNodeRequest(server string, nodeId string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "nodeId", nodeId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uint64"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/node/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetNodeRequest generates requests for GetNode +func NewGetNodeRequest(server string, nodeId string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "nodeId", nodeId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uint64"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/node/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewSetApprovedRoutesRequest calls the generic SetApprovedRoutes builder with application/json body +func NewSetApprovedRoutesRequest(server string, nodeId string, body SetApprovedRoutesJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetApprovedRoutesRequestWithBody(server, nodeId, "application/json", bodyReader) +} + +// NewSetApprovedRoutesRequestWithBody generates requests for SetApprovedRoutes with any type of body +func NewSetApprovedRoutesRequestWithBody(server string, nodeId string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "nodeId", nodeId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uint64"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/node/%s/approve_routes", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewExpireNodeRequest calls the generic ExpireNode builder with application/json body +func NewExpireNodeRequest(server string, nodeId string, body ExpireNodeJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewExpireNodeRequestWithBody(server, nodeId, "application/json", bodyReader) +} + +// NewExpireNodeRequestWithBody generates requests for ExpireNode with any type of body +func NewExpireNodeRequestWithBody(server string, nodeId string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "nodeId", nodeId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uint64"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/node/%s/expire", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewRenameNodeRequest generates requests for RenameNode +func NewRenameNodeRequest(server string, nodeId string, newName string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "nodeId", nodeId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uint64"}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "newName", newName, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/node/%s/rename/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewSetTagsRequest calls the generic SetTags builder with application/json body +func NewSetTagsRequest(server string, nodeId string, body SetTagsJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetTagsRequestWithBody(server, nodeId, "application/json", bodyReader) +} + +// NewSetTagsRequestWithBody generates requests for SetTags with any type of body +func NewSetTagsRequestWithBody(server string, nodeId string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "nodeId", nodeId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uint64"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/node/%s/tags", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewGetPolicyRequest generates requests for GetPolicy +func NewGetPolicyRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/policy") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewSetPolicyRequest calls the generic SetPolicy builder with application/json body +func NewSetPolicyRequest(server string, body SetPolicyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetPolicyRequestWithBody(server, "application/json", bodyReader) +} + +// NewSetPolicyRequestWithBody generates requests for SetPolicy with any type of body +func NewSetPolicyRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/policy") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewCheckPolicyRequest calls the generic CheckPolicy builder with application/json body +func NewCheckPolicyRequest(server string, body CheckPolicyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCheckPolicyRequestWithBody(server, "application/json", bodyReader) +} + +// NewCheckPolicyRequestWithBody generates requests for CheckPolicy with any type of body +func NewCheckPolicyRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/policy/check") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDeletePreAuthKeyRequest generates requests for DeletePreAuthKey +func NewDeletePreAuthKeyRequest(server string, params *DeletePreAuthKeyParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/preauthkey") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Id != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "id", *params.Id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "uint64"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewListPreAuthKeysRequest generates requests for ListPreAuthKeys +func NewListPreAuthKeysRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/preauthkey") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewCreatePreAuthKeyRequest calls the generic CreatePreAuthKey builder with application/json body +func NewCreatePreAuthKeyRequest(server string, body CreatePreAuthKeyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCreatePreAuthKeyRequestWithBody(server, "application/json", bodyReader) +} + +// NewCreatePreAuthKeyRequestWithBody generates requests for CreatePreAuthKey with any type of body +func NewCreatePreAuthKeyRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/preauthkey") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewExpirePreAuthKeyRequest calls the generic ExpirePreAuthKey builder with application/json body +func NewExpirePreAuthKeyRequest(server string, body ExpirePreAuthKeyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewExpirePreAuthKeyRequestWithBody(server, "application/json", bodyReader) +} + +// NewExpirePreAuthKeyRequestWithBody generates requests for ExpirePreAuthKey with any type of body +func NewExpirePreAuthKeyRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/preauthkey/expire") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewListUsersRequest generates requests for ListUsers +func NewListUsersRequest(server string, params *ListUsersParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/user") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Id != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "id", *params.Id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "uint64"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Name != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "name", *params.Name, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Email != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "email", *params.Email, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewCreateUserRequest calls the generic CreateUser builder with application/json body +func NewCreateUserRequest(server string, body CreateUserJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCreateUserRequestWithBody(server, "application/json", bodyReader) +} + +// NewCreateUserRequestWithBody generates requests for CreateUser with any type of body +func NewCreateUserRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/user") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDeleteUserRequest generates requests for DeleteUser +func NewDeleteUserRequest(server string, id string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uint64"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/user/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewRenameUserRequest generates requests for RenameUser +func NewRenameUserRequest(server string, oldId string, newName string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "oldId", oldId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uint64"}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "newName", newName, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/user/%s/rename/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // ListApiKeysWithResponse request + ListApiKeysWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListApiKeysResponse, error) + + // CreateApiKeyWithBodyWithResponse request with any body + CreateApiKeyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateApiKeyResponse, error) + + CreateApiKeyWithResponse(ctx context.Context, body CreateApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateApiKeyResponse, error) + + // ExpireApiKeyWithBodyWithResponse request with any body + ExpireApiKeyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ExpireApiKeyResponse, error) + + ExpireApiKeyWithResponse(ctx context.Context, body ExpireApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*ExpireApiKeyResponse, error) + + // DeleteApiKeyWithResponse request + DeleteApiKeyWithResponse(ctx context.Context, prefix string, params *DeleteApiKeyParams, reqEditors ...RequestEditorFn) (*DeleteApiKeyResponse, error) + + // AuthApproveWithBodyWithResponse request with any body + AuthApproveWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AuthApproveResponse, error) + + AuthApproveWithResponse(ctx context.Context, body AuthApproveJSONRequestBody, reqEditors ...RequestEditorFn) (*AuthApproveResponse, error) + + // AuthRegisterWithBodyWithResponse request with any body + AuthRegisterWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AuthRegisterResponse, error) + + AuthRegisterWithResponse(ctx context.Context, body AuthRegisterJSONRequestBody, reqEditors ...RequestEditorFn) (*AuthRegisterResponse, error) + + // AuthRejectWithBodyWithResponse request with any body + AuthRejectWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AuthRejectResponse, error) + + AuthRejectWithResponse(ctx context.Context, body AuthRejectJSONRequestBody, reqEditors ...RequestEditorFn) (*AuthRejectResponse, error) + + // DebugCreateNodeWithBodyWithResponse request with any body + DebugCreateNodeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DebugCreateNodeResponse, error) + + DebugCreateNodeWithResponse(ctx context.Context, body DebugCreateNodeJSONRequestBody, reqEditors ...RequestEditorFn) (*DebugCreateNodeResponse, error) + + // HealthWithResponse request + HealthWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*HealthResponse, error) + + // ListNodesWithResponse request + ListNodesWithResponse(ctx context.Context, params *ListNodesParams, reqEditors ...RequestEditorFn) (*ListNodesResponse, error) + + // BackfillNodeIPsWithResponse request + BackfillNodeIPsWithResponse(ctx context.Context, params *BackfillNodeIPsParams, reqEditors ...RequestEditorFn) (*BackfillNodeIPsResponse, error) + + // RegisterNodeWithResponse request + RegisterNodeWithResponse(ctx context.Context, params *RegisterNodeParams, reqEditors ...RequestEditorFn) (*RegisterNodeResponse, error) + + // DeleteNodeWithResponse request + DeleteNodeWithResponse(ctx context.Context, nodeId string, reqEditors ...RequestEditorFn) (*DeleteNodeResponse, error) + + // GetNodeWithResponse request + GetNodeWithResponse(ctx context.Context, nodeId string, reqEditors ...RequestEditorFn) (*GetNodeResponse, error) + + // SetApprovedRoutesWithBodyWithResponse request with any body + SetApprovedRoutesWithBodyWithResponse(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetApprovedRoutesResponse, error) + + SetApprovedRoutesWithResponse(ctx context.Context, nodeId string, body SetApprovedRoutesJSONRequestBody, reqEditors ...RequestEditorFn) (*SetApprovedRoutesResponse, error) + + // ExpireNodeWithBodyWithResponse request with any body + ExpireNodeWithBodyWithResponse(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ExpireNodeResponse, error) + + ExpireNodeWithResponse(ctx context.Context, nodeId string, body ExpireNodeJSONRequestBody, reqEditors ...RequestEditorFn) (*ExpireNodeResponse, error) + + // RenameNodeWithResponse request + RenameNodeWithResponse(ctx context.Context, nodeId string, newName string, reqEditors ...RequestEditorFn) (*RenameNodeResponse, error) + + // SetTagsWithBodyWithResponse request with any body + SetTagsWithBodyWithResponse(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetTagsResponse, error) + + SetTagsWithResponse(ctx context.Context, nodeId string, body SetTagsJSONRequestBody, reqEditors ...RequestEditorFn) (*SetTagsResponse, error) + + // GetPolicyWithResponse request + GetPolicyWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetPolicyResponse, error) + + // SetPolicyWithBodyWithResponse request with any body + SetPolicyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetPolicyResponse, error) + + SetPolicyWithResponse(ctx context.Context, body SetPolicyJSONRequestBody, reqEditors ...RequestEditorFn) (*SetPolicyResponse, error) + + // CheckPolicyWithBodyWithResponse request with any body + CheckPolicyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CheckPolicyResponse, error) + + CheckPolicyWithResponse(ctx context.Context, body CheckPolicyJSONRequestBody, reqEditors ...RequestEditorFn) (*CheckPolicyResponse, error) + + // DeletePreAuthKeyWithResponse request + DeletePreAuthKeyWithResponse(ctx context.Context, params *DeletePreAuthKeyParams, reqEditors ...RequestEditorFn) (*DeletePreAuthKeyResponse, error) + + // ListPreAuthKeysWithResponse request + ListPreAuthKeysWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListPreAuthKeysResponse, error) + + // CreatePreAuthKeyWithBodyWithResponse request with any body + CreatePreAuthKeyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreatePreAuthKeyResponse, error) + + CreatePreAuthKeyWithResponse(ctx context.Context, body CreatePreAuthKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*CreatePreAuthKeyResponse, error) + + // ExpirePreAuthKeyWithBodyWithResponse request with any body + ExpirePreAuthKeyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ExpirePreAuthKeyResponse, error) + + ExpirePreAuthKeyWithResponse(ctx context.Context, body ExpirePreAuthKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*ExpirePreAuthKeyResponse, error) + + // ListUsersWithResponse request + ListUsersWithResponse(ctx context.Context, params *ListUsersParams, reqEditors ...RequestEditorFn) (*ListUsersResponse, error) + + // CreateUserWithBodyWithResponse request with any body + CreateUserWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateUserResponse, error) + + CreateUserWithResponse(ctx context.Context, body CreateUserJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateUserResponse, error) + + // DeleteUserWithResponse request + DeleteUserWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*DeleteUserResponse, error) + + // RenameUserWithResponse request + RenameUserWithResponse(ctx context.Context, oldId string, newName string, reqEditors ...RequestEditorFn) (*RenameUserResponse, error) +} + +type ListApiKeysResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ListApiKeysOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ListApiKeysResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListApiKeysResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ListApiKeysResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type CreateApiKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *CreateApiKeyOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r CreateApiKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CreateApiKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r CreateApiKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ExpireApiKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ExpireApiKeyOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ExpireApiKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ExpireApiKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ExpireApiKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type DeleteApiKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DeleteApiKeyOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r DeleteApiKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteApiKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DeleteApiKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type AuthApproveResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *AuthApproveOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r AuthApproveResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r AuthApproveResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r AuthApproveResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type AuthRegisterResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *AuthRegisterOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r AuthRegisterResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r AuthRegisterResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r AuthRegisterResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type AuthRejectResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *AuthRejectOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r AuthRejectResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r AuthRejectResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r AuthRejectResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type DebugCreateNodeResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *NodeOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r DebugCreateNodeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DebugCreateNodeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DebugCreateNodeResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type HealthResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *HealthResponseBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r HealthResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r HealthResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r HealthResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ListNodesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ListNodesOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ListNodesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListNodesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ListNodesResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type BackfillNodeIPsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *BackfillNodeIPsOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r BackfillNodeIPsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r BackfillNodeIPsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r BackfillNodeIPsResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type RegisterNodeResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *NodeOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r RegisterNodeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r RegisterNodeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r RegisterNodeResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type DeleteNodeResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DeleteNodeOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r DeleteNodeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteNodeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DeleteNodeResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetNodeResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *NodeOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r GetNodeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetNodeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetNodeResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type SetApprovedRoutesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *NodeOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r SetApprovedRoutesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetApprovedRoutesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SetApprovedRoutesResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ExpireNodeResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *NodeOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ExpireNodeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ExpireNodeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ExpireNodeResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type RenameNodeResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *NodeOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r RenameNodeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r RenameNodeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r RenameNodeResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type SetTagsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *NodeOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r SetTagsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetTagsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SetTagsResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetPolicyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *PolicyResponseBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r GetPolicyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetPolicyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetPolicyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type SetPolicyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *PolicyResponseBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r SetPolicyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetPolicyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SetPolicyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type CheckPolicyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *CheckPolicyOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r CheckPolicyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CheckPolicyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r CheckPolicyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type DeletePreAuthKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DeletePreAuthKeyOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r DeletePreAuthKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeletePreAuthKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DeletePreAuthKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ListPreAuthKeysResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ListPreAuthKeysOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ListPreAuthKeysResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListPreAuthKeysResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ListPreAuthKeysResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type CreatePreAuthKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *PreAuthKeyOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r CreatePreAuthKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CreatePreAuthKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r CreatePreAuthKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ExpirePreAuthKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ExpirePreAuthKeyOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ExpirePreAuthKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ExpirePreAuthKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ExpirePreAuthKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ListUsersResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ListUsersOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ListUsersResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListUsersResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ListUsersResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type CreateUserResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *UserOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r CreateUserResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CreateUserResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r CreateUserResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type DeleteUserResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DeleteUserOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r DeleteUserResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteUserResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DeleteUserResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type RenameUserResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *UserOutputBody + ApplicationproblemJSONDefault *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r RenameUserResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r RenameUserResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r RenameUserResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +// ListApiKeysWithResponse request returning *ListApiKeysResponse +func (c *ClientWithResponses) ListApiKeysWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListApiKeysResponse, error) { + rsp, err := c.ListApiKeys(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseListApiKeysResponse(rsp) +} + +// CreateApiKeyWithBodyWithResponse request with arbitrary body returning *CreateApiKeyResponse +func (c *ClientWithResponses) CreateApiKeyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateApiKeyResponse, error) { + rsp, err := c.CreateApiKeyWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateApiKeyResponse(rsp) +} + +func (c *ClientWithResponses) CreateApiKeyWithResponse(ctx context.Context, body CreateApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateApiKeyResponse, error) { + rsp, err := c.CreateApiKey(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateApiKeyResponse(rsp) +} + +// ExpireApiKeyWithBodyWithResponse request with arbitrary body returning *ExpireApiKeyResponse +func (c *ClientWithResponses) ExpireApiKeyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ExpireApiKeyResponse, error) { + rsp, err := c.ExpireApiKeyWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseExpireApiKeyResponse(rsp) +} + +func (c *ClientWithResponses) ExpireApiKeyWithResponse(ctx context.Context, body ExpireApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*ExpireApiKeyResponse, error) { + rsp, err := c.ExpireApiKey(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseExpireApiKeyResponse(rsp) +} + +// DeleteApiKeyWithResponse request returning *DeleteApiKeyResponse +func (c *ClientWithResponses) DeleteApiKeyWithResponse(ctx context.Context, prefix string, params *DeleteApiKeyParams, reqEditors ...RequestEditorFn) (*DeleteApiKeyResponse, error) { + rsp, err := c.DeleteApiKey(ctx, prefix, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteApiKeyResponse(rsp) +} + +// AuthApproveWithBodyWithResponse request with arbitrary body returning *AuthApproveResponse +func (c *ClientWithResponses) AuthApproveWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AuthApproveResponse, error) { + rsp, err := c.AuthApproveWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAuthApproveResponse(rsp) +} + +func (c *ClientWithResponses) AuthApproveWithResponse(ctx context.Context, body AuthApproveJSONRequestBody, reqEditors ...RequestEditorFn) (*AuthApproveResponse, error) { + rsp, err := c.AuthApprove(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAuthApproveResponse(rsp) +} + +// AuthRegisterWithBodyWithResponse request with arbitrary body returning *AuthRegisterResponse +func (c *ClientWithResponses) AuthRegisterWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AuthRegisterResponse, error) { + rsp, err := c.AuthRegisterWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAuthRegisterResponse(rsp) +} + +func (c *ClientWithResponses) AuthRegisterWithResponse(ctx context.Context, body AuthRegisterJSONRequestBody, reqEditors ...RequestEditorFn) (*AuthRegisterResponse, error) { + rsp, err := c.AuthRegister(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAuthRegisterResponse(rsp) +} + +// AuthRejectWithBodyWithResponse request with arbitrary body returning *AuthRejectResponse +func (c *ClientWithResponses) AuthRejectWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AuthRejectResponse, error) { + rsp, err := c.AuthRejectWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAuthRejectResponse(rsp) +} + +func (c *ClientWithResponses) AuthRejectWithResponse(ctx context.Context, body AuthRejectJSONRequestBody, reqEditors ...RequestEditorFn) (*AuthRejectResponse, error) { + rsp, err := c.AuthReject(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAuthRejectResponse(rsp) +} + +// DebugCreateNodeWithBodyWithResponse request with arbitrary body returning *DebugCreateNodeResponse +func (c *ClientWithResponses) DebugCreateNodeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DebugCreateNodeResponse, error) { + rsp, err := c.DebugCreateNodeWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseDebugCreateNodeResponse(rsp) +} + +func (c *ClientWithResponses) DebugCreateNodeWithResponse(ctx context.Context, body DebugCreateNodeJSONRequestBody, reqEditors ...RequestEditorFn) (*DebugCreateNodeResponse, error) { + rsp, err := c.DebugCreateNode(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseDebugCreateNodeResponse(rsp) +} + +// HealthWithResponse request returning *HealthResponse +func (c *ClientWithResponses) HealthWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*HealthResponse, error) { + rsp, err := c.Health(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseHealthResponse(rsp) +} + +// ListNodesWithResponse request returning *ListNodesResponse +func (c *ClientWithResponses) ListNodesWithResponse(ctx context.Context, params *ListNodesParams, reqEditors ...RequestEditorFn) (*ListNodesResponse, error) { + rsp, err := c.ListNodes(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListNodesResponse(rsp) +} + +// BackfillNodeIPsWithResponse request returning *BackfillNodeIPsResponse +func (c *ClientWithResponses) BackfillNodeIPsWithResponse(ctx context.Context, params *BackfillNodeIPsParams, reqEditors ...RequestEditorFn) (*BackfillNodeIPsResponse, error) { + rsp, err := c.BackfillNodeIPs(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseBackfillNodeIPsResponse(rsp) +} + +// RegisterNodeWithResponse request returning *RegisterNodeResponse +func (c *ClientWithResponses) RegisterNodeWithResponse(ctx context.Context, params *RegisterNodeParams, reqEditors ...RequestEditorFn) (*RegisterNodeResponse, error) { + rsp, err := c.RegisterNode(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseRegisterNodeResponse(rsp) +} + +// DeleteNodeWithResponse request returning *DeleteNodeResponse +func (c *ClientWithResponses) DeleteNodeWithResponse(ctx context.Context, nodeId string, reqEditors ...RequestEditorFn) (*DeleteNodeResponse, error) { + rsp, err := c.DeleteNode(ctx, nodeId, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteNodeResponse(rsp) +} + +// GetNodeWithResponse request returning *GetNodeResponse +func (c *ClientWithResponses) GetNodeWithResponse(ctx context.Context, nodeId string, reqEditors ...RequestEditorFn) (*GetNodeResponse, error) { + rsp, err := c.GetNode(ctx, nodeId, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetNodeResponse(rsp) +} + +// SetApprovedRoutesWithBodyWithResponse request with arbitrary body returning *SetApprovedRoutesResponse +func (c *ClientWithResponses) SetApprovedRoutesWithBodyWithResponse(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetApprovedRoutesResponse, error) { + rsp, err := c.SetApprovedRoutesWithBody(ctx, nodeId, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetApprovedRoutesResponse(rsp) +} + +func (c *ClientWithResponses) SetApprovedRoutesWithResponse(ctx context.Context, nodeId string, body SetApprovedRoutesJSONRequestBody, reqEditors ...RequestEditorFn) (*SetApprovedRoutesResponse, error) { + rsp, err := c.SetApprovedRoutes(ctx, nodeId, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetApprovedRoutesResponse(rsp) +} + +// ExpireNodeWithBodyWithResponse request with arbitrary body returning *ExpireNodeResponse +func (c *ClientWithResponses) ExpireNodeWithBodyWithResponse(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ExpireNodeResponse, error) { + rsp, err := c.ExpireNodeWithBody(ctx, nodeId, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseExpireNodeResponse(rsp) +} + +func (c *ClientWithResponses) ExpireNodeWithResponse(ctx context.Context, nodeId string, body ExpireNodeJSONRequestBody, reqEditors ...RequestEditorFn) (*ExpireNodeResponse, error) { + rsp, err := c.ExpireNode(ctx, nodeId, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseExpireNodeResponse(rsp) +} + +// RenameNodeWithResponse request returning *RenameNodeResponse +func (c *ClientWithResponses) RenameNodeWithResponse(ctx context.Context, nodeId string, newName string, reqEditors ...RequestEditorFn) (*RenameNodeResponse, error) { + rsp, err := c.RenameNode(ctx, nodeId, newName, reqEditors...) + if err != nil { + return nil, err + } + return ParseRenameNodeResponse(rsp) +} + +// SetTagsWithBodyWithResponse request with arbitrary body returning *SetTagsResponse +func (c *ClientWithResponses) SetTagsWithBodyWithResponse(ctx context.Context, nodeId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetTagsResponse, error) { + rsp, err := c.SetTagsWithBody(ctx, nodeId, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetTagsResponse(rsp) +} + +func (c *ClientWithResponses) SetTagsWithResponse(ctx context.Context, nodeId string, body SetTagsJSONRequestBody, reqEditors ...RequestEditorFn) (*SetTagsResponse, error) { + rsp, err := c.SetTags(ctx, nodeId, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetTagsResponse(rsp) +} + +// GetPolicyWithResponse request returning *GetPolicyResponse +func (c *ClientWithResponses) GetPolicyWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetPolicyResponse, error) { + rsp, err := c.GetPolicy(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetPolicyResponse(rsp) +} + +// SetPolicyWithBodyWithResponse request with arbitrary body returning *SetPolicyResponse +func (c *ClientWithResponses) SetPolicyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetPolicyResponse, error) { + rsp, err := c.SetPolicyWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetPolicyResponse(rsp) +} + +func (c *ClientWithResponses) SetPolicyWithResponse(ctx context.Context, body SetPolicyJSONRequestBody, reqEditors ...RequestEditorFn) (*SetPolicyResponse, error) { + rsp, err := c.SetPolicy(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetPolicyResponse(rsp) +} + +// CheckPolicyWithBodyWithResponse request with arbitrary body returning *CheckPolicyResponse +func (c *ClientWithResponses) CheckPolicyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CheckPolicyResponse, error) { + rsp, err := c.CheckPolicyWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCheckPolicyResponse(rsp) +} + +func (c *ClientWithResponses) CheckPolicyWithResponse(ctx context.Context, body CheckPolicyJSONRequestBody, reqEditors ...RequestEditorFn) (*CheckPolicyResponse, error) { + rsp, err := c.CheckPolicy(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCheckPolicyResponse(rsp) +} + +// DeletePreAuthKeyWithResponse request returning *DeletePreAuthKeyResponse +func (c *ClientWithResponses) DeletePreAuthKeyWithResponse(ctx context.Context, params *DeletePreAuthKeyParams, reqEditors ...RequestEditorFn) (*DeletePreAuthKeyResponse, error) { + rsp, err := c.DeletePreAuthKey(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeletePreAuthKeyResponse(rsp) +} + +// ListPreAuthKeysWithResponse request returning *ListPreAuthKeysResponse +func (c *ClientWithResponses) ListPreAuthKeysWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListPreAuthKeysResponse, error) { + rsp, err := c.ListPreAuthKeys(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseListPreAuthKeysResponse(rsp) +} + +// CreatePreAuthKeyWithBodyWithResponse request with arbitrary body returning *CreatePreAuthKeyResponse +func (c *ClientWithResponses) CreatePreAuthKeyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreatePreAuthKeyResponse, error) { + rsp, err := c.CreatePreAuthKeyWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreatePreAuthKeyResponse(rsp) +} + +func (c *ClientWithResponses) CreatePreAuthKeyWithResponse(ctx context.Context, body CreatePreAuthKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*CreatePreAuthKeyResponse, error) { + rsp, err := c.CreatePreAuthKey(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreatePreAuthKeyResponse(rsp) +} + +// ExpirePreAuthKeyWithBodyWithResponse request with arbitrary body returning *ExpirePreAuthKeyResponse +func (c *ClientWithResponses) ExpirePreAuthKeyWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ExpirePreAuthKeyResponse, error) { + rsp, err := c.ExpirePreAuthKeyWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseExpirePreAuthKeyResponse(rsp) +} + +func (c *ClientWithResponses) ExpirePreAuthKeyWithResponse(ctx context.Context, body ExpirePreAuthKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*ExpirePreAuthKeyResponse, error) { + rsp, err := c.ExpirePreAuthKey(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseExpirePreAuthKeyResponse(rsp) +} + +// ListUsersWithResponse request returning *ListUsersResponse +func (c *ClientWithResponses) ListUsersWithResponse(ctx context.Context, params *ListUsersParams, reqEditors ...RequestEditorFn) (*ListUsersResponse, error) { + rsp, err := c.ListUsers(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListUsersResponse(rsp) +} + +// CreateUserWithBodyWithResponse request with arbitrary body returning *CreateUserResponse +func (c *ClientWithResponses) CreateUserWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateUserResponse, error) { + rsp, err := c.CreateUserWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateUserResponse(rsp) +} + +func (c *ClientWithResponses) CreateUserWithResponse(ctx context.Context, body CreateUserJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateUserResponse, error) { + rsp, err := c.CreateUser(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateUserResponse(rsp) +} + +// DeleteUserWithResponse request returning *DeleteUserResponse +func (c *ClientWithResponses) DeleteUserWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*DeleteUserResponse, error) { + rsp, err := c.DeleteUser(ctx, id, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteUserResponse(rsp) +} + +// RenameUserWithResponse request returning *RenameUserResponse +func (c *ClientWithResponses) RenameUserWithResponse(ctx context.Context, oldId string, newName string, reqEditors ...RequestEditorFn) (*RenameUserResponse, error) { + rsp, err := c.RenameUser(ctx, oldId, newName, reqEditors...) + if err != nil { + return nil, err + } + return ParseRenameUserResponse(rsp) +} + +// ParseListApiKeysResponse parses an HTTP response from a ListApiKeysWithResponse call +func ParseListApiKeysResponse(rsp *http.Response) (*ListApiKeysResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListApiKeysResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListApiKeysOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseCreateApiKeyResponse parses an HTTP response from a CreateApiKeyWithResponse call +func ParseCreateApiKeyResponse(rsp *http.Response) (*CreateApiKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CreateApiKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest CreateApiKeyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseExpireApiKeyResponse parses an HTTP response from a ExpireApiKeyWithResponse call +func ParseExpireApiKeyResponse(rsp *http.Response) (*ExpireApiKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ExpireApiKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ExpireApiKeyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseDeleteApiKeyResponse parses an HTTP response from a DeleteApiKeyWithResponse call +func ParseDeleteApiKeyResponse(rsp *http.Response) (*DeleteApiKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteApiKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DeleteApiKeyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseAuthApproveResponse parses an HTTP response from a AuthApproveWithResponse call +func ParseAuthApproveResponse(rsp *http.Response) (*AuthApproveResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &AuthApproveResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest AuthApproveOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseAuthRegisterResponse parses an HTTP response from a AuthRegisterWithResponse call +func ParseAuthRegisterResponse(rsp *http.Response) (*AuthRegisterResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &AuthRegisterResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest AuthRegisterOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseAuthRejectResponse parses an HTTP response from a AuthRejectWithResponse call +func ParseAuthRejectResponse(rsp *http.Response) (*AuthRejectResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &AuthRejectResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest AuthRejectOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseDebugCreateNodeResponse parses an HTTP response from a DebugCreateNodeWithResponse call +func ParseDebugCreateNodeResponse(rsp *http.Response) (*DebugCreateNodeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DebugCreateNodeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest NodeOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseHealthResponse parses an HTTP response from a HealthWithResponse call +func ParseHealthResponse(rsp *http.Response) (*HealthResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &HealthResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest HealthResponseBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseListNodesResponse parses an HTTP response from a ListNodesWithResponse call +func ParseListNodesResponse(rsp *http.Response) (*ListNodesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListNodesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListNodesOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseBackfillNodeIPsResponse parses an HTTP response from a BackfillNodeIPsWithResponse call +func ParseBackfillNodeIPsResponse(rsp *http.Response) (*BackfillNodeIPsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &BackfillNodeIPsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest BackfillNodeIPsOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseRegisterNodeResponse parses an HTTP response from a RegisterNodeWithResponse call +func ParseRegisterNodeResponse(rsp *http.Response) (*RegisterNodeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &RegisterNodeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest NodeOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseDeleteNodeResponse parses an HTTP response from a DeleteNodeWithResponse call +func ParseDeleteNodeResponse(rsp *http.Response) (*DeleteNodeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteNodeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DeleteNodeOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseGetNodeResponse parses an HTTP response from a GetNodeWithResponse call +func ParseGetNodeResponse(rsp *http.Response) (*GetNodeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetNodeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest NodeOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseSetApprovedRoutesResponse parses an HTTP response from a SetApprovedRoutesWithResponse call +func ParseSetApprovedRoutesResponse(rsp *http.Response) (*SetApprovedRoutesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetApprovedRoutesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest NodeOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseExpireNodeResponse parses an HTTP response from a ExpireNodeWithResponse call +func ParseExpireNodeResponse(rsp *http.Response) (*ExpireNodeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ExpireNodeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest NodeOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseRenameNodeResponse parses an HTTP response from a RenameNodeWithResponse call +func ParseRenameNodeResponse(rsp *http.Response) (*RenameNodeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &RenameNodeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest NodeOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseSetTagsResponse parses an HTTP response from a SetTagsWithResponse call +func ParseSetTagsResponse(rsp *http.Response) (*SetTagsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetTagsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest NodeOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseGetPolicyResponse parses an HTTP response from a GetPolicyWithResponse call +func ParseGetPolicyResponse(rsp *http.Response) (*GetPolicyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetPolicyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest PolicyResponseBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseSetPolicyResponse parses an HTTP response from a SetPolicyWithResponse call +func ParseSetPolicyResponse(rsp *http.Response) (*SetPolicyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetPolicyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest PolicyResponseBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseCheckPolicyResponse parses an HTTP response from a CheckPolicyWithResponse call +func ParseCheckPolicyResponse(rsp *http.Response) (*CheckPolicyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CheckPolicyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest CheckPolicyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseDeletePreAuthKeyResponse parses an HTTP response from a DeletePreAuthKeyWithResponse call +func ParseDeletePreAuthKeyResponse(rsp *http.Response) (*DeletePreAuthKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeletePreAuthKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DeletePreAuthKeyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseListPreAuthKeysResponse parses an HTTP response from a ListPreAuthKeysWithResponse call +func ParseListPreAuthKeysResponse(rsp *http.Response) (*ListPreAuthKeysResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListPreAuthKeysResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListPreAuthKeysOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseCreatePreAuthKeyResponse parses an HTTP response from a CreatePreAuthKeyWithResponse call +func ParseCreatePreAuthKeyResponse(rsp *http.Response) (*CreatePreAuthKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CreatePreAuthKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest PreAuthKeyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseExpirePreAuthKeyResponse parses an HTTP response from a ExpirePreAuthKeyWithResponse call +func ParseExpirePreAuthKeyResponse(rsp *http.Response) (*ExpirePreAuthKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ExpirePreAuthKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ExpirePreAuthKeyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseListUsersResponse parses an HTTP response from a ListUsersWithResponse call +func ParseListUsersResponse(rsp *http.Response) (*ListUsersResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListUsersResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListUsersOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseCreateUserResponse parses an HTTP response from a CreateUserWithResponse call +func ParseCreateUserResponse(rsp *http.Response) (*CreateUserResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CreateUserResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest UserOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseDeleteUserResponse parses an HTTP response from a DeleteUserWithResponse call +func ParseDeleteUserResponse(rsp *http.Response) (*DeleteUserResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteUserResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DeleteUserOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseRenameUserResponse parses an HTTP response from a RenameUserWithResponse call +func ParseRenameUserResponse(rsp *http.Response) (*RenameUserResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &RenameUserResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest UserOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} diff --git a/hscontrol/apiv1_client_test.go b/hscontrol/apiv1_client_test.go new file mode 100644 index 000000000..536d6177d --- /dev/null +++ b/hscontrol/apiv1_client_test.go @@ -0,0 +1,50 @@ +package hscontrol + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + clientv1 "github.com/juanfont/headscale/gen/client/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAPIV1GeneratedClient smoke-tests the generated client against the Huma +// service over a real HTTP server, exercising the typed request/response path +// end to end. +func TestAPIV1GeneratedClient(t *testing.T) { + app := createTestApp(t) + srv := httptest.NewServer(newHumaTestHandler(app)) + t.Cleanup(srv.Close) + + client, err := clientv1.NewClientWithResponses(srv.URL) + require.NoError(t, err) + + ctx := context.Background() + + health, err := client.HealthWithResponse(ctx) + require.NoError(t, err) + require.Equal(t, http.StatusOK, health.StatusCode()) + require.NotNil(t, health.JSON200) + assert.True(t, health.JSON200.DatabaseConnectivity) + + name := "alice" + created, err := client.CreateUserWithResponse(ctx, clientv1.CreateUserJSONRequestBody{ + Name: &name, + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, created.StatusCode()) + require.NotNil(t, created.JSON200) + require.NotNil(t, created.JSON200.User) + assert.Equal(t, "alice", created.JSON200.User.Name) + assert.Equal(t, "1", created.JSON200.User.Id) + + listed, err := client.ListUsersWithResponse(ctx, &clientv1.ListUsersParams{}) + require.NoError(t, err) + require.Equal(t, http.StatusOK, listed.StatusCode()) + require.NotNil(t, listed.JSON200) + require.Len(t, listed.JSON200.Users, 1) + assert.Equal(t, "alice", listed.JSON200.Users[0].Name) +} diff --git a/hscontrol/apiv1_socket_test.go b/hscontrol/apiv1_socket_test.go new file mode 100644 index 000000000..0b6f53ec3 --- /dev/null +++ b/hscontrol/apiv1_socket_test.go @@ -0,0 +1,61 @@ +package hscontrol + +import ( + "context" + "net" + "net/http" + "path/filepath" + "testing" + + clientv1 "github.com/juanfont/headscale/gen/client/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAPIV1SocketClient proves the CLI's local transport: the Huma handler over +// a unix socket reached by an http.Client that dials it, the same wiring as the +// server's socket listener and the CLI's newSocketClient, on local trust. +func TestAPIV1SocketClient(t *testing.T) { + app := createTestApp(t) + + socketPath := filepath.Join(t.TempDir(), "headscale.sock") + + lis, err := new(net.ListenConfig).Listen(context.Background(), "unix", socketPath) + require.NoError(t, err) + + srv := &http.Server{Handler: newHumaTestHandler(app)} //nolint:gosec + go func() { _ = srv.Serve(lis) }() + + t.Cleanup(func() { _ = srv.Close() }) + + httpClient := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + + return d.DialContext(ctx, "unix", socketPath) + }, + }, + } + + client, err := clientv1.NewClientWithResponses( + "http://local", + clientv1.WithHTTPClient(httpClient), + ) + require.NoError(t, err) + + ctx := context.Background() + + health, err := client.HealthWithResponse(ctx) + require.NoError(t, err) + require.Equal(t, http.StatusOK, health.StatusCode()) + require.NotNil(t, health.JSON200) + assert.True(t, health.JSON200.DatabaseConnectivity) + + name := "socket-user" + created, err := client.CreateUserWithResponse(ctx, clientv1.CreateUserJSONRequestBody{Name: &name}) + require.NoError(t, err) + require.Equal(t, http.StatusOK, created.StatusCode()) + require.NotNil(t, created.JSON200) + assert.Equal(t, "socket-user", created.JSON200.User.Name) +} From 8efa5ad1fe362c4915c150766184d0eb907932bd Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 19 Jun 2026 06:13:50 +0000 Subject: [PATCH 060/127] cli: migrate the CLI and integration tests to the v1 HTTP API Replace the gRPC client with the generated HTTP client across every command: locally over the unix socket without auth (matching the previous local gRPC socket), remotely over TLS with a Bearer API key. Output rendering and integration tests move to the HTTP client types; the transport changes, the assertions do not. --- .github/workflows/test-integration.yaml | 2 +- cmd/headscale/cli/api_key.go | 124 +++++++--- cmd/headscale/cli/auth.go | 85 +++---- cmd/headscale/cli/debug.go | 25 +- cmd/headscale/cli/health.go | 13 +- cmd/headscale/cli/nodes.go | 284 +++++++++++------------ cmd/headscale/cli/policy.go | 59 +++-- cmd/headscale/cli/preauthkeys.go | 110 +++++---- cmd/headscale/cli/root_test.go | 6 +- cmd/headscale/cli/users.go | 117 ++++++---- cmd/headscale/cli/utils.go | 265 +++++++++++++--------- cmd/headscale/cli/utils_socket_test.go | 72 ++++++ hscontrol/util/net.go | 4 +- integration/acl_test.go | 88 +++---- integration/api_auth_test.go | 97 ++++---- integration/auth_key_test.go | 85 ++++--- integration/auth_oidc_test.go | 274 +++++++++++----------- integration/auth_web_flow_test.go | 12 +- integration/cli_apikeys_test.go | 83 ++++--- integration/cli_nodes_test.go | 222 +++++++++--------- integration/cli_policy_test.go | 2 +- integration/cli_preauthkeys_test.go | 77 ++++--- integration/cli_server_test.go | 6 +- integration/cli_test.go | 20 +- integration/cli_users_test.go | 59 +++-- integration/control.go | 22 +- integration/general_test.go | 106 ++++----- integration/grant_cap_test.go | 28 +-- integration/helpers.go | 33 ++- integration/hsic/hsic.go | 53 +++-- integration/route_test.go | 290 ++++++++++++------------ integration/scenario.go | 20 +- integration/scenario_test.go | 2 +- integration/tags_test.go | 268 +++++++++++----------- integration/tsric_test.go | 12 +- 35 files changed, 1654 insertions(+), 1371 deletions(-) create mode 100644 cmd/headscale/cli/utils_socket_test.go diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index 4d23e5890..bfa13e340 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -244,7 +244,7 @@ jobs: - TestACLDynamicUnknownUserRemoval - TestAPIAuthenticationBypass - TestAPIAuthenticationBypassCurl - - TestGRPCAuthenticationBypass + - TestRemoteCLIAuthenticationBypass - TestCLIWithConfigAuthenticationBypass - TestAuthKeyLogoutAndReloginSameUser - TestAuthKeyLogoutAndReloginNewUser diff --git a/cmd/headscale/cli/api_key.go b/cmd/headscale/cli/api_key.go index 5c6848142..82559235a 100644 --- a/cmd/headscale/cli/api_key.go +++ b/cmd/headscale/cli/api_key.go @@ -3,9 +3,10 @@ package cli import ( "context" "fmt" + "net/http" "strconv" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol/util" "github.com/spf13/cobra" ) @@ -43,26 +44,36 @@ var listAPIKeys = &cobra.Command{ Use: cmdList, Short: "List the Api keys for headscale", Aliases: []string{"ls", cmdShow}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - response, err := client.ListApiKeys(ctx, &v1.ListApiKeysRequest{}) + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { + resp, err := client.ListApiKeysWithResponse(ctx) if err != nil { return fmt.Errorf("listing api keys: %w", err) } - return printListOutput(cmd, response.GetApiKeys(), func() error { - rows := make([][]string, 0, len(response.GetApiKeys())) - for _, key := range response.GetApiKeys() { - expiration := "-" + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } - if key.GetExpiration() != nil { - expiration = ColourTime(key.GetExpiration().AsTime()) + apiKeys := resp.JSON200.ApiKeys + + return printListOutput(cmd, apiKeys, func() error { + rows := make([][]string, 0, len(apiKeys)) + for _, key := range apiKeys { + expiration := "-" + if key.Expiration != nil { + expiration = ColourTime(*key.Expiration) + } + + var created string + if key.CreatedAt != nil { + created = key.CreatedAt.Format(HeadscaleDateTimeFormat) } rows = append(rows, []string{ - strconv.FormatUint(key.GetId(), util.Base10), - key.GetPrefix(), + key.Id, + key.Prefix, expiration, - key.GetCreatedAt().AsTime().Format(HeadscaleDateTimeFormat), + created, }) } @@ -79,20 +90,24 @@ Creates a new Api key, the Api key is only visible on creation and cannot be retrieved again. If you lose a key, create a new one and revoke (expire) the old one.`, Aliases: []string{"c", cmdNew}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - expiration, err := expirationFromFlag(cmd) + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { + expiryTime, err := expirationFromFlag(cmd) if err != nil { return err } - response, err := client.CreateApiKey(ctx, &v1.CreateApiKeyRequest{ - Expiration: expiration, + resp, err := client.CreateApiKeyWithResponse(ctx, clientv1.CreateApiKeyJSONRequestBody{ + Expiration: &expiryTime, }) if err != nil { return fmt.Errorf("creating api key: %w", err) } - return printOutput(cmd, response.GetApiKey(), response.GetApiKey()) + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200.ApiKey, resp.JSON200.ApiKey) }), } @@ -116,21 +131,33 @@ var expireAPIKeyCmd = &cobra.Command{ Use: cmdExpire, Short: "Expire an ApiKey", Aliases: []string{"revoke", aliasExp, "e"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { id, prefix, err := apiKeyIDOrPrefix(cmd) if err != nil { return err } - response, err := client.ExpireApiKey(ctx, &v1.ExpireApiKeyRequest{ - Id: id, - Prefix: prefix, - }) + body := clientv1.ExpireApiKeyJSONRequestBody{} + + if id != 0 { + idStr := strconv.FormatUint(id, util.Base10) + body.Id = &idStr + } + + if prefix != "" { + body.Prefix = &prefix + } + + resp, err := client.ExpireApiKeyWithResponse(ctx, body) if err != nil { return fmt.Errorf("expiring api key: %w", err) } - return printOutput(cmd, response, "Key expired") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200, "Key expired") }), } @@ -138,20 +165,59 @@ var deleteAPIKeyCmd = &cobra.Command{ Use: cmdDelete, Short: "Delete an ApiKey", Aliases: []string{"remove", aliasDel}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { id, prefix, err := apiKeyIDOrPrefix(cmd) if err != nil { return err } - response, err := client.DeleteApiKey(ctx, &v1.DeleteApiKeyRequest{ - Id: id, - Prefix: prefix, - }) + // The DELETE route addresses the key by its prefix in the path. When the + // user deletes by --id we resolve the id to its (masked) prefix first, + // since the path segment is required and a query-only id cannot be routed. + if prefix == "" { + prefix, err = apiKeyPrefixForID(ctx, client, id) + if err != nil { + return err + } + } + + resp, err := client.DeleteApiKeyWithResponse(ctx, prefix, &clientv1.DeleteApiKeyParams{}) if err != nil { return fmt.Errorf("deleting api key: %w", err) } - return printOutput(cmd, response, "Key deleted") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200, "Key deleted") }), } + +// apiKeyPrefixForID resolves an API key id to its display prefix by listing the +// keys. The DELETE endpoint addresses keys by prefix in the URL path, so a +// delete by --id needs the prefix; the returned masked prefix is accepted by +// the server's lookup. +func apiKeyPrefixForID( + ctx context.Context, + client *clientv1.ClientWithResponses, + id uint64, +) (string, error) { + resp, err := client.ListApiKeysWithResponse(ctx) + if err != nil { + return "", fmt.Errorf("listing api keys: %w", err) + } + + if resp.StatusCode() != http.StatusOK { + return "", apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + idStr := strconv.FormatUint(id, util.Base10) + for _, key := range resp.JSON200.ApiKeys { + if key.Id == idStr { + return key.Prefix, nil + } + } + + return "", fmt.Errorf("%w: api key %d not found", errMissingParameter, id) +} diff --git a/cmd/headscale/cli/auth.go b/cmd/headscale/cli/auth.go index 85a17a3c9..30abe797a 100644 --- a/cmd/headscale/cli/auth.go +++ b/cmd/headscale/cli/auth.go @@ -3,8 +3,9 @@ package cli import ( "context" "fmt" + "net/http" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/spf13/cobra" ) @@ -33,62 +34,66 @@ var authCmd = &cobra.Command{ var authRegisterCmd = &cobra.Command{ Use: "register", Short: "Register a node to your network", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { user, _ := cmd.Flags().GetString("user") authID, _ := cmd.Flags().GetString("auth-id") - request := &v1.AuthRegisterRequest{ - AuthId: authID, - User: user, - } - - response, err := client.AuthRegister(ctx, request) + resp, err := client.AuthRegisterWithResponse(ctx, clientv1.AuthRegisterJSONRequestBody{ + AuthId: &authID, + User: &user, + }) if err != nil { return fmt.Errorf("registering node: %w", err) } - return printOutput( - cmd, - response.GetNode(), - fmt.Sprintf("Node %s registered", response.GetNode().GetGivenName()), - ) - }), -} - -// authDecisionRunE builds a RunE for an auth decision command (approve or -// reject) that reads the auth-id flag, invokes the given gRPC call, and prints -// the response. errVerb is used in the error message; okMsg is printed on -// success. -func authDecisionRunE[Resp any]( - errVerb, okMsg string, - call func(ctx context.Context, client v1.HeadscaleServiceClient, authID string) (Resp, error), -) func(*cobra.Command, []string) error { - return grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - authID, _ := cmd.Flags().GetString("auth-id") - - response, err := call(ctx, client, authID) - if err != nil { - return fmt.Errorf("%s auth request: %w", errVerb, err) + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) } - return printOutput(cmd, response, okMsg) - }) + node := resp.JSON200.Node + + return printOutput( + cmd, + node, + fmt.Sprintf("Node %s registered", node.GivenName), + ) + }), } var authApproveCmd = &cobra.Command{ Use: "approve", Short: "Approve a pending authentication request", - RunE: authDecisionRunE("approving", "Auth request approved", - func(ctx context.Context, client v1.HeadscaleServiceClient, authID string) (*v1.AuthApproveResponse, error) { - return client.AuthApprove(ctx, &v1.AuthApproveRequest{AuthId: authID}) - }), + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { + authID, _ := cmd.Flags().GetString("auth-id") + + resp, err := client.AuthApproveWithResponse(ctx, clientv1.AuthApproveJSONRequestBody{AuthId: &authID}) + if err != nil { + return fmt.Errorf("approving auth request: %w", err) + } + + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200, "Auth request approved") + }), } var authRejectCmd = &cobra.Command{ Use: "reject", Short: "Reject a pending authentication request", - RunE: authDecisionRunE("rejecting", "Auth request rejected", - func(ctx context.Context, client v1.HeadscaleServiceClient, authID string) (*v1.AuthRejectResponse, error) { - return client.AuthReject(ctx, &v1.AuthRejectRequest{AuthId: authID}) - }), + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { + authID, _ := cmd.Flags().GetString("auth-id") + + resp, err := client.AuthRejectWithResponse(ctx, clientv1.AuthRejectJSONRequestBody{AuthId: &authID}) + if err != nil { + return fmt.Errorf("rejecting auth request: %w", err) + } + + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200, "Auth request rejected") + }), } diff --git a/cmd/headscale/cli/debug.go b/cmd/headscale/cli/debug.go index 1f934f0bb..64262c00d 100644 --- a/cmd/headscale/cli/debug.go +++ b/cmd/headscale/cli/debug.go @@ -3,8 +3,9 @@ package cli import ( "context" "fmt" + "net/http" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol/types" "github.com/spf13/cobra" ) @@ -32,7 +33,7 @@ var debugCmd = &cobra.Command{ var createNodeCmd = &cobra.Command{ Use: "create-node", Short: "Create a node that can be registered with `auth register <>` command", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { user, _ := cmd.Flags().GetString("user") name, _ := cmd.Flags().GetString("name") registrationID, _ := cmd.Flags().GetString("key") @@ -44,18 +45,20 @@ var createNodeCmd = &cobra.Command{ routes, _ := cmd.Flags().GetStringSlice("route") - request := &v1.DebugCreateNodeRequest{ - Key: registrationID, - Name: name, - User: user, - Routes: routes, - } - - response, err := client.DebugCreateNode(ctx, request) + resp, err := client.DebugCreateNodeWithResponse(ctx, clientv1.DebugCreateNodeJSONRequestBody{ + Key: ®istrationID, + Name: &name, + User: &user, + Routes: &routes, + }) if err != nil { return fmt.Errorf("creating node: %w", err) } - return printOutput(cmd, response.GetNode(), "Node created") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200.Node, "Node created") }), } diff --git a/cmd/headscale/cli/health.go b/cmd/headscale/cli/health.go index b3b4f4307..6fccb0ac2 100644 --- a/cmd/headscale/cli/health.go +++ b/cmd/headscale/cli/health.go @@ -3,8 +3,9 @@ package cli import ( "context" "fmt" + "net/http" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/spf13/cobra" ) @@ -16,12 +17,16 @@ var healthCmd = &cobra.Command{ Use: "health", Short: "Check the health of the Headscale server", Long: "Check the health of the Headscale server. This command will return an exit code of 0 if the server is healthy, or 1 if it is not.", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - response, err := client.Health(ctx, &v1.HealthRequest{}) + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { + resp, err := client.HealthWithResponse(ctx) if err != nil { return fmt.Errorf("checking health: %w", err) } - return printOutput(cmd, response, "") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200, "") }), } diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go index bc0810488..671a68ccd 100644 --- a/cmd/headscale/cli/nodes.go +++ b/cmd/headscale/cli/nodes.go @@ -3,17 +3,17 @@ package cli import ( "context" "fmt" + "net/http" "net/netip" "strconv" "strings" "time" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol/util" "github.com/pterm/pterm" "github.com/samber/lo" "github.com/spf13/cobra" - "google.golang.org/protobuf/types/known/timestamppb" "tailscale.com/types/key" ) @@ -67,24 +67,30 @@ var registerNodeCmd = &cobra.Command{ Use: "register", Short: "Registers a node to your network", Deprecated: "use 'headscale auth register --auth-id --user ' instead", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { user, _ := cmd.Flags().GetString("user") registrationID, _ := cmd.Flags().GetString("key") - request := &v1.RegisterNodeRequest{ - Key: registrationID, - User: user, + params := &clientv1.RegisterNodeParams{ + User: &user, + Key: ®istrationID, } - response, err := client.RegisterNode(ctx, request) + resp, err := client.RegisterNodeWithResponse(ctx, params) if err != nil { return fmt.Errorf("registering node: %w", err) } + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + node := resp.JSON200.Node + return printOutput( cmd, - response.GetNode(), - fmt.Sprintf("Node %s registered", response.GetNode().GetGivenName()), + node, + fmt.Sprintf("Node %s registered", node.GivenName), ) }), } @@ -93,16 +99,27 @@ var listNodesCmd = &cobra.Command{ Use: cmdList, Short: "List nodes", Aliases: []string{"ls", cmdShow}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { user, _ := cmd.Flags().GetString("user") - response, err := client.ListNodes(ctx, &v1.ListNodesRequest{User: user}) + params := &clientv1.ListNodesParams{} + if user != "" { + params.User = &user + } + + resp, err := client.ListNodesWithResponse(ctx, params) if err != nil { return fmt.Errorf("listing nodes: %w", err) } - return printListOutput(cmd, response.GetNodes(), func() error { - tableData, err := nodesToPtables(response.GetNodes()) + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + nodes := resp.JSON200.Nodes + + return printListOutput(cmd, nodes, func() error { + tableData, err := nodesToPtables(nodes) if err != nil { return fmt.Errorf("converting to table: %w", err) } @@ -116,27 +133,32 @@ var listNodeRoutesCmd = &cobra.Command{ Use: "list-routes", Short: "List routes available on nodes", Aliases: []string{"lsr", "routes"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { identifier, _ := cmd.Flags().GetUint64("identifier") - response, err := client.ListNodes(ctx, &v1.ListNodesRequest{}) + resp, err := client.ListNodesWithResponse(ctx, &clientv1.ListNodesParams{}) if err != nil { return fmt.Errorf("listing nodes: %w", err) } - nodes := response.GetNodes() + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + nodes := resp.JSON200.Nodes if identifier != 0 { - for _, node := range response.GetNodes() { - if node.GetId() == identifier { - nodes = []*v1.Node{node} + idStr := strconv.FormatUint(identifier, util.Base10) + for _, node := range nodes { + if node.Id == idStr { + nodes = []clientv1.Node{node} break } } } - nodes = lo.Filter(nodes, func(n *v1.Node, _ int) bool { - return len(n.GetSubnetRoutes()) > 0 || len(n.GetApprovedRoutes()) > 0 || len(n.GetAvailableRoutes()) > 0 + nodes = lo.Filter(nodes, func(n clientv1.Node, _ int) bool { + return len(n.SubnetRoutes) > 0 || len(n.ApprovedRoutes) > 0 || len(n.AvailableRoutes) > 0 }) return printListOutput(cmd, nodes, func() error { @@ -152,23 +174,27 @@ var expireNodeCmd = &cobra.Command{ Use --disable to disable key expiry (node will never expire).`, Aliases: []string{"logout", aliasExp, "e"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { identifier, _ := cmd.Flags().GetUint64("identifier") disableExpiry, _ := cmd.Flags().GetBool("disable") + nodeID := strconv.FormatUint(identifier, util.Base10) // Handle disable expiry - node will never expire. if disableExpiry { - request := &v1.ExpireNodeRequest{ - NodeId: identifier, - DisableExpiry: true, - } + disable := true - response, err := client.ExpireNode(ctx, request) + resp, err := client.ExpireNodeWithResponse(ctx, nodeID, clientv1.ExpireNodeJSONRequestBody{ + DisableExpiry: &disable, + }) if err != nil { return fmt.Errorf("disabling node expiry: %w", err) } - return printOutput(cmd, response.GetNode(), "Node expiry disabled") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200.Node, "Node expiry disabled") } expiry, _ := cmd.Flags().GetString("expiry") @@ -186,28 +212,31 @@ Use --disable to disable key expiry (node will never expire).`, } } - request := &v1.ExpireNodeRequest{ - NodeId: identifier, - Expiry: timestamppb.New(expiryTime), - } - - response, err := client.ExpireNode(ctx, request) + resp, err := client.ExpireNodeWithResponse(ctx, nodeID, clientv1.ExpireNodeJSONRequestBody{ + Expiry: &expiryTime, + }) if err != nil { return fmt.Errorf("expiring node: %w", err) } - if now.Equal(expiryTime) || now.After(expiryTime) { - return printOutput(cmd, response.GetNode(), "Node expired") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) } - return printOutput(cmd, response.GetNode(), "Node expiration updated") + node := resp.JSON200.Node + + if now.Equal(expiryTime) || now.After(expiryTime) { + return printOutput(cmd, node, "Node expired") + } + + return printOutput(cmd, node, "Node expiration updated") }), } var renameNodeCmd = &cobra.Command{ Use: "rename NEW_NAME", Short: "Renames a node in your network", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { identifier, _ := cmd.Flags().GetUint64("identifier") newName := "" @@ -215,17 +244,16 @@ var renameNodeCmd = &cobra.Command{ newName = args[0] } - request := &v1.RenameNodeRequest{ - NodeId: identifier, - NewName: newName, - } - - response, err := client.RenameNode(ctx, request) + resp, err := client.RenameNodeWithResponse(ctx, strconv.FormatUint(identifier, util.Base10), newName) if err != nil { return fmt.Errorf("renaming node: %w", err) } - return printOutput(cmd, response.GetNode(), "Node renamed") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200.Node, "Node renamed") }), } @@ -233,34 +261,35 @@ var deleteNodeCmd = &cobra.Command{ Use: cmdDelete, Short: "Delete a node", Aliases: []string{aliasDel}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { identifier, _ := cmd.Flags().GetUint64("identifier") + nodeID := strconv.FormatUint(identifier, util.Base10) - getRequest := &v1.GetNodeRequest{ - NodeId: identifier, - } - - getResponse, err := client.GetNode(ctx, getRequest) + getResponse, err := client.GetNodeWithResponse(ctx, nodeID) if err != nil { return fmt.Errorf("getting node: %w", err) } - deleteRequest := &v1.DeleteNodeRequest{ - NodeId: identifier, + if getResponse.StatusCode() != http.StatusOK { + return apiError(getResponse.StatusCode(), getResponse.ApplicationproblemJSONDefault) } if !confirmAction(cmd, fmt.Sprintf( "Do you want to remove the node %s?", - getResponse.GetNode().GetName(), + getResponse.JSON200.Node.Name, )) { return printOutput(cmd, map[string]string{colResult: "Node not deleted"}, "Node not deleted") } - _, err = client.DeleteNode(ctx, deleteRequest) + deleteResponse, err := client.DeleteNodeWithResponse(ctx, nodeID) if err != nil { return fmt.Errorf("deleting node: %w", err) } + if deleteResponse.StatusCode() != http.StatusOK { + return apiError(deleteResponse.StatusCode(), deleteResponse.ApplicationproblemJSONDefault) + } + return printOutput( cmd, map[string]string{colResult: "Node deleted"}, @@ -289,23 +318,26 @@ be assigned to nodes.`, return nil } - ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig() - if err != nil { - return fmt.Errorf("connecting to headscale: %w", err) - } - defer cancel() - defer conn.Close() + return withClient(func(ctx context.Context, client *clientv1.ClientWithResponses) error { + confirmed := true - changes, err := client.BackfillNodeIPs(ctx, &v1.BackfillNodeIPsRequest{Confirmed: true}) - if err != nil { - return fmt.Errorf("backfilling IPs: %w", err) - } + resp, err := client.BackfillNodeIPsWithResponse(ctx, &clientv1.BackfillNodeIPsParams{ + Confirmed: &confirmed, + }) + if err != nil { + return fmt.Errorf("backfilling IPs: %w", err) + } - return printOutput(cmd, changes, "Node IPs backfilled successfully") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200, "Node IPs backfilled successfully") + }) }, } -func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) { +func nodesToPtables(nodes []clientv1.Node) (pterm.TableData, error) { tableHeader := []string{ "ID", "Hostname", @@ -325,75 +357,49 @@ func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) { tableData[0] = tableHeader for _, node := range nodes { - var ephemeral bool - if node.GetPreAuthKey() != nil && node.GetPreAuthKey().GetEphemeral() { - ephemeral = true + // An absent pre-auth key decodes into a zero NodePreAuthKey, so guard + // on Id before reading its flags. + ephemeral := node.PreAuthKey.Id != "" && node.PreAuthKey.Ephemeral + + var lastSeenTime string + if node.LastSeen != nil { + lastSeenTime = node.LastSeen.Format(HeadscaleDateTimeFormat) } - var ( - lastSeen time.Time - lastSeenTime string - ) - - if node.GetLastSeen() != nil { - lastSeen = node.GetLastSeen().AsTime() - lastSeenTime = lastSeen.Format(HeadscaleDateTimeFormat) - } - - var ( - expiry time.Time - expiryTime string - ) - - if node.GetExpiry() != nil { - expiry = node.GetExpiry().AsTime() - expiryTime = expiry.Format(HeadscaleDateTimeFormat) - } else { - expiryTime = "N/A" + expiryTime := "N/A" + if node.Expiry != nil { + expiryTime = node.Expiry.Format(HeadscaleDateTimeFormat) } var machineKey key.MachinePublic - err := machineKey.UnmarshalText( - []byte(node.GetMachineKey()), - ) + err := machineKey.UnmarshalText([]byte(node.MachineKey)) if err != nil { machineKey = key.MachinePublic{} } var nodeKey key.NodePublic - err = nodeKey.UnmarshalText( - []byte(node.GetNodeKey()), - ) + err = nodeKey.UnmarshalText([]byte(node.NodeKey)) if err != nil { return nil, err } - var online string - if node.GetOnline() { + online := pterm.LightRed("offline") + if node.Online { online = pterm.LightGreen("online") - } else { - online = pterm.LightRed("offline") } - var expired string - if node.GetExpiry() != nil && node.GetExpiry().AsTime().Before(time.Now()) { + expired := pterm.LightGreen("no") + if node.Expiry != nil && node.Expiry.Before(time.Now()) { expired = pterm.LightRed("yes") - } else { - expired = pterm.LightGreen("no") } - tags := strings.Join(node.GetTags(), "\n") - - var user string - if node.GetUser() != nil { - user = node.GetUser().GetName() - } + tags := strings.Join(node.Tags, "\n") var ipBuilder strings.Builder - for _, addr := range node.GetIpAddresses() { + for _, addr := range node.IpAddresses { ip, err := netip.ParseAddr(addr) if err == nil { if ipBuilder.Len() > 0 { @@ -407,12 +413,12 @@ func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) { ipAddresses := ipBuilder.String() nodeData := []string{ - strconv.FormatUint(node.GetId(), util.Base10), - node.GetName(), - node.GetGivenName(), + node.Id, + node.Name, + node.GivenName, machineKey.ShortString(), nodeKey.ShortString(), - user, + node.User.Name, tags, ipAddresses, strconv.FormatBool(ephemeral), @@ -431,7 +437,7 @@ func nodesToPtables(nodes []*v1.Node) (pterm.TableData, error) { } func nodeRoutesToPtables( - nodes []*v1.Node, + nodes []clientv1.Node, ) pterm.TableData { tableHeader := []string{ "ID", @@ -445,11 +451,11 @@ func nodeRoutesToPtables( for _, node := range nodes { nodeData := []string{ - strconv.FormatUint(node.GetId(), util.Base10), - node.GetGivenName(), - strings.Join(node.GetApprovedRoutes(), "\n"), - strings.Join(node.GetAvailableRoutes(), "\n"), - strings.Join(node.GetSubnetRoutes(), "\n"), + node.Id, + node.GivenName, + strings.Join(node.ApprovedRoutes, "\n"), + strings.Join(node.AvailableRoutes, "\n"), + strings.Join(node.SubnetRoutes, "\n"), } tableData = append( tableData, @@ -464,43 +470,43 @@ var tagCmd = &cobra.Command{ Use: "tag", Short: "Manage the tags of a node", Aliases: []string{"tags", "t"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { identifier, _ := cmd.Flags().GetUint64("identifier") tagsToSet, _ := cmd.Flags().GetStringSlice("tags") - // Sending tags to node - request := &v1.SetTagsRequest{ - NodeId: identifier, - Tags: tagsToSet, - } - - resp, err := client.SetTags(ctx, request) + resp, err := client.SetTagsWithResponse(ctx, strconv.FormatUint(identifier, util.Base10), clientv1.SetTagsJSONRequestBody{ + Tags: &tagsToSet, + }) if err != nil { return fmt.Errorf("setting tags: %w", err) } - return printOutput(cmd, resp.GetNode(), "Node updated") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200.Node, "Node updated") }), } var approveRoutesCmd = &cobra.Command{ Use: "approve-routes", Short: "Manage the approved routes of a node", - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { identifier, _ := cmd.Flags().GetUint64("identifier") routes, _ := cmd.Flags().GetStringSlice("routes") - // Sending routes to node - request := &v1.SetApprovedRoutesRequest{ - NodeId: identifier, - Routes: routes, - } - - resp, err := client.SetApprovedRoutes(ctx, request) + resp, err := client.SetApprovedRoutesWithResponse(ctx, strconv.FormatUint(identifier, util.Base10), clientv1.SetApprovedRoutesJSONRequestBody{ + Routes: &routes, + }) if err != nil { return fmt.Errorf("setting approved routes: %w", err) } - return printOutput(cmd, resp.GetNode(), "Node updated") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200.Node, "Node updated") }), } diff --git a/cmd/headscale/cli/policy.go b/cmd/headscale/cli/policy.go index 094960542..ae69cc8a9 100644 --- a/cmd/headscale/cli/policy.go +++ b/cmd/headscale/cli/policy.go @@ -4,9 +4,10 @@ import ( "context" "errors" "fmt" + "net/http" "os" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol/db" "github.com/juanfont/headscale/hscontrol/policy" "github.com/juanfont/headscale/hscontrol/types" @@ -15,14 +16,13 @@ import ( ) const ( - bypassFlag = "bypass-grpc-and-access-database-directly" //nolint:gosec // not a credential + bypassFlag = "bypass-server-and-access-database-directly" //nolint:gosec // not a credential ) var errAborted = errors.New("command aborted by user") -// bypassDatabase loads the server config and opens the database directly, -// bypassing the gRPC server. The caller is responsible for closing the -// returned database handle. +// bypassDatabase opens the database directly, bypassing the running server. +// The caller must close the returned handle. func bypassDatabase() (*db.HSDatabase, error) { cfg, err := types.LoadServerConfig() if err != nil { @@ -50,16 +50,16 @@ func openBypassDB(cmd *cobra.Command) (*db.HSDatabase, error) { func init() { rootCmd.AddCommand(policyCmd) - getPolicy.Flags().BoolP(bypassFlag, "", false, "Uses the headscale config to directly access the database, bypassing gRPC and does not require the server to be running") + getPolicy.Flags().BoolP(bypassFlag, "", false, "Uses the headscale config to directly access the database, bypassing the API and does not require the server to be running") policyCmd.AddCommand(getPolicy) setPolicy.Flags().StringP("file", "f", "", "Path to a policy file in HuJSON format") - setPolicy.Flags().BoolP(bypassFlag, "", false, "Uses the headscale config to directly access the database, bypassing gRPC and does not require the server to be running") + setPolicy.Flags().BoolP(bypassFlag, "", false, "Uses the headscale config to directly access the database, bypassing the API and does not require the server to be running") mustMarkRequired(setPolicy, "file") policyCmd.AddCommand(setPolicy) checkPolicy.Flags().StringP("file", "f", "", "Path to a policy file in HuJSON format") - checkPolicy.Flags().BoolP(bypassFlag, "", false, "Open the database directly (no gRPC, no running server) to resolve user references and to evaluate the policy's tests and sshTests blocks. Required when those checks are needed.") + checkPolicy.Flags().BoolP(bypassFlag, "", false, "Open the database directly (no running server required) to resolve user references and to evaluate the policy's tests and sshTests blocks. Required when those checks are needed.") mustMarkRequired(checkPolicy, "file") policyCmd.AddCommand(checkPolicy) } @@ -90,13 +90,17 @@ var getPolicy = &cobra.Command{ policyData = pol.Data } else { - err := withGRPC(func(ctx context.Context, client v1.HeadscaleServiceClient) error { - response, err := client.GetPolicy(ctx, &v1.GetPolicyRequest{}) + err := withClient(func(ctx context.Context, client *clientv1.ClientWithResponses) error { + resp, err := client.GetPolicyWithResponse(ctx) if err != nil { return fmt.Errorf("loading ACL policy: %w", err) } - policyData = response.GetPolicy() + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + policyData = resp.JSON200.Policy return nil }) @@ -150,14 +154,20 @@ var setPolicy = &cobra.Command{ return fmt.Errorf("setting ACL policy: %w", err) } } else { - request := &v1.SetPolicyRequest{Policy: string(policyBytes)} + policyStr := string(policyBytes) - err := withGRPC(func(ctx context.Context, client v1.HeadscaleServiceClient) error { - _, err := client.SetPolicy(ctx, request) + err := withClient(func(ctx context.Context, client *clientv1.ClientWithResponses) error { + resp, err := client.SetPolicyWithResponse(ctx, clientv1.SetPolicyJSONRequestBody{ + Policy: &policyStr, + }) if err != nil { return fmt.Errorf("setting ACL policy: %w", err) } + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + return nil }) if err != nil { @@ -176,8 +186,8 @@ var checkPolicy = &cobra.Command{ Short: "Check the Policy file for errors", Long: ` Check validates the policy against the server's live users and nodes, - running any "tests" or "sshTests" block. By default the command is a - thin frontend for a gRPC call to a running headscale; pass --` + bypassFlag + ` to + running any "tests" or "sshTests" block. By default the command calls a + running headscale over its API; pass --` + bypassFlag + ` to open the database directly when headscale is not running.`, RunE: func(cmd *cobra.Command, args []string) error { policyPath, _ := cmd.Flags().GetString("file") @@ -223,10 +233,21 @@ var checkPolicy = &cobra.Command{ return nil } - err = withGRPC(func(ctx context.Context, client v1.HeadscaleServiceClient) error { - _, err := client.CheckPolicy(ctx, &v1.CheckPolicyRequest{Policy: string(policyBytes)}) + policyStr := string(policyBytes) - return err + err = withClient(func(ctx context.Context, client *clientv1.ClientWithResponses) error { + resp, err := client.CheckPolicyWithResponse(ctx, clientv1.CheckPolicyJSONRequestBody{ + Policy: &policyStr, + }) + if err != nil { + return err + } + + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return nil }) if err != nil { return err diff --git a/cmd/headscale/cli/preauthkeys.go b/cmd/headscale/cli/preauthkeys.go index 06e2f07fc..f6d4ad2b0 100644 --- a/cmd/headscale/cli/preauthkeys.go +++ b/cmd/headscale/cli/preauthkeys.go @@ -3,10 +3,11 @@ package cli import ( "context" "fmt" + "net/http" "strconv" "strings" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol/util" "github.com/spf13/cobra" ) @@ -44,37 +45,40 @@ var listPreAuthKeys = &cobra.Command{ Use: cmdList, Short: "List all preauthkeys", Aliases: []string{"ls", cmdShow}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - response, err := client.ListPreAuthKeys(ctx, &v1.ListPreAuthKeysRequest{}) + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { + resp, err := client.ListPreAuthKeysWithResponse(ctx) if err != nil { return fmt.Errorf("listing preauthkeys: %w", err) } - return printListOutput(cmd, response.GetPreAuthKeys(), func() error { - rows := make([][]string, 0, len(response.GetPreAuthKeys())) - for _, key := range response.GetPreAuthKeys() { - expiration := "-" - if key.GetExpiration() != nil { - expiration = ColourTime(key.GetExpiration().AsTime()) - } + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } - var owner string - if len(key.GetAclTags()) > 0 { - owner = strings.Join(key.GetAclTags(), "\n") - } else if key.GetUser() != nil { - owner = key.GetUser().GetName() - } else { - owner = "-" + preAuthKeys := resp.JSON200.PreAuthKeys + + return printListOutput(cmd, preAuthKeys, func() error { + rows := make([][]string, 0, len(preAuthKeys)) + for _, key := range preAuthKeys { + expiration := ColourTime(key.Expiration) + + owner := "-" + + switch { + case len(key.AclTags) > 0: + owner = strings.Join(key.AclTags, "\n") + case key.User.Id != "": + owner = key.User.Name } rows = append(rows, []string{ - strconv.FormatUint(key.GetId(), util.Base10), - key.GetKey(), - strconv.FormatBool(key.GetReusable()), - strconv.FormatBool(key.GetEphemeral()), - strconv.FormatBool(key.GetUsed()), + key.Id, + key.Key, + strconv.FormatBool(key.Reusable), + strconv.FormatBool(key.Ephemeral), + strconv.FormatBool(key.Used), expiration, - key.GetCreatedAt().AsTime().Format(HeadscaleDateTimeFormat), + key.CreatedAt.Format(HeadscaleDateTimeFormat), owner, }) } @@ -97,31 +101,39 @@ var createPreAuthKeyCmd = &cobra.Command{ Use: "create", Short: "Creates a new preauthkey", Aliases: []string{"c", cmdNew}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { user, _ := cmd.Flags().GetUint64("user") reusable, _ := cmd.Flags().GetBool("reusable") ephemeral, _ := cmd.Flags().GetBool("ephemeral") tags, _ := cmd.Flags().GetStringSlice("tags") - expiration, err := expirationFromFlag(cmd) + expiryTime, err := expirationFromFlag(cmd) if err != nil { return err } - request := &v1.CreatePreAuthKeyRequest{ - User: user, - Reusable: reusable, - Ephemeral: ephemeral, - AclTags: tags, - Expiration: expiration, + userStr := strconv.FormatUint(user, util.Base10) + + request := clientv1.CreatePreAuthKeyJSONRequestBody{ + User: &userStr, + Reusable: &reusable, + Ephemeral: &ephemeral, + AclTags: &tags, + Expiration: &expiryTime, } - response, err := client.CreatePreAuthKey(ctx, request) + resp, err := client.CreatePreAuthKeyWithResponse(ctx, request) if err != nil { return fmt.Errorf("creating preauthkey: %w", err) } - return printOutput(cmd, response.GetPreAuthKey(), response.GetPreAuthKey().GetKey()) + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + preAuthKey := resp.JSON200.PreAuthKey + + return printOutput(cmd, preAuthKey, preAuthKey.Key) }), } @@ -139,22 +151,26 @@ var expirePreAuthKeyCmd = &cobra.Command{ Use: cmdExpire, Short: "Expire a preauthkey", Aliases: []string{"revoke", aliasExp, "e"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { id, err := preAuthKeyID(cmd) if err != nil { return err } - request := &v1.ExpirePreAuthKeyRequest{ - Id: id, - } + idStr := strconv.FormatUint(id, util.Base10) - response, err := client.ExpirePreAuthKey(ctx, request) + resp, err := client.ExpirePreAuthKeyWithResponse(ctx, clientv1.ExpirePreAuthKeyJSONRequestBody{ + Id: &idStr, + }) if err != nil { return fmt.Errorf("expiring preauthkey: %w", err) } - return printOutput(cmd, response, "Key expired") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200, "Key expired") }), } @@ -162,21 +178,25 @@ var deletePreAuthKeyCmd = &cobra.Command{ Use: cmdDelete, Short: "Delete a preauthkey", Aliases: []string{aliasDel, "rm", "d"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { id, err := preAuthKeyID(cmd) if err != nil { return err } - request := &v1.DeletePreAuthKeyRequest{ - Id: id, - } + idStr := strconv.FormatUint(id, util.Base10) - response, err := client.DeletePreAuthKey(ctx, request) + resp, err := client.DeletePreAuthKeyWithResponse(ctx, &clientv1.DeletePreAuthKeyParams{ + Id: &idStr, + }) if err != nil { return fmt.Errorf("deleting preauthkey: %w", err) } - return printOutput(cmd, response, "Key deleted") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200, "Key deleted") }), } diff --git a/cmd/headscale/cli/root_test.go b/cmd/headscale/cli/root_test.go index 68d1ae523..fb1fefd11 100644 --- a/cmd/headscale/cli/root_test.go +++ b/cmd/headscale/cli/root_test.go @@ -213,7 +213,8 @@ func TestFilterPreReleasesIfStable(t *testing.T) { t.Run(tt.name, func(t *testing.T) { result := filterPreReleasesIfStable(func() string { return tt.currentVersion })(tt.tag) if result != tt.expectedFilter { - t.Errorf("%s: got %v, want %v\nDescription: %s\nCurrent version: %s, Tag: %s", + t.Errorf( + "%s: got %v, want %v\nDescription: %s\nCurrent version: %s, Tag: %s", tt.name, result, tt.expectedFilter, @@ -293,7 +294,8 @@ func TestIsPreReleaseVersion(t *testing.T) { t.Run(tt.name, func(t *testing.T) { result := isPreReleaseVersion(tt.version) if result != tt.expected { - t.Errorf("%s: got %v, want %v\nDescription: %s\nVersion: %s", + t.Errorf( + "%s: got %v, want %v\nDescription: %s\nVersion: %s", tt.name, result, tt.expected, diff --git a/cmd/headscale/cli/users.go b/cmd/headscale/cli/users.go index a74d64e85..71be49cf4 100644 --- a/cmd/headscale/cli/users.go +++ b/cmd/headscale/cli/users.go @@ -4,10 +4,11 @@ import ( "context" "errors" "fmt" + "net/http" "net/url" "strconv" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/hscontrol/util/zlog/zf" "github.com/rs/zerolog/log" @@ -45,27 +46,39 @@ func usernameAndIDFromFlag(cmd *cobra.Command) (uint64, string, error) { // returning the raw flag id and the matched user. func resolveSingleUser( ctx context.Context, - client v1.HeadscaleServiceClient, + client *clientv1.ClientWithResponses, cmd *cobra.Command, -) (uint64, *v1.User, error) { +) (uint64, *clientv1.User, error) { id, username, err := usernameAndIDFromFlag(cmd) if err != nil { return 0, nil, err } - users, err := client.ListUsers(ctx, &v1.ListUsersRequest{ - Name: username, - Id: id, - }) + params := &clientv1.ListUsersParams{} + if username != "" { + params.Name = &username + } + + if id != 0 { + idStr := strconv.FormatUint(id, util.Base10) + params.Id = &idStr + } + + resp, err := client.ListUsersWithResponse(ctx, params) if err != nil { return 0, nil, fmt.Errorf("listing users: %w", err) } - if len(users.GetUsers()) != 1 { + if resp.StatusCode() != http.StatusOK { + return 0, nil, apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + users := resp.JSON200.Users + if len(users) != 1 { return 0, nil, errMultipleUsersMatch } - return id, users.GetUsers()[0], nil + return id, &users[0], nil } func init() { @@ -102,19 +115,19 @@ var createUserCmd = &cobra.Command{ return nil }, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { userName := args[0] - log.Trace().Interface(zf.Client, client).Msg("obtained gRPC client") + log.Trace().Interface(zf.Client, client).Msg("obtained API client") - request := &v1.CreateUserRequest{Name: userName} + request := clientv1.CreateUserJSONRequestBody{Name: &userName} if displayName, _ := cmd.Flags().GetString("display-name"); displayName != "" { - request.DisplayName = displayName + request.DisplayName = &displayName } if email, _ := cmd.Flags().GetString("email"); email != "" { - request.Email = email + request.Email = &email } if pictureURL, _ := cmd.Flags().GetString("picture-url"); pictureURL != "" { @@ -122,17 +135,21 @@ var createUserCmd = &cobra.Command{ return fmt.Errorf("invalid picture URL: %w", err) } - request.PictureUrl = pictureURL + request.PictureUrl = &pictureURL } log.Trace().Interface(zf.Request, request).Msg("sending CreateUser request") - response, err := client.CreateUser(ctx, request) + resp, err := client.CreateUserWithResponse(ctx, request) if err != nil { return fmt.Errorf("creating user: %w", err) } - return printOutput(cmd, response.GetUser(), "User created") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200.User, "User created") }), } @@ -140,27 +157,29 @@ var destroyUserCmd = &cobra.Command{ Use: "destroy --identifier ID or --name NAME", Short: "Destroys a user", Aliases: []string{cmdDelete}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { _, user, err := resolveSingleUser(ctx, client, cmd) if err != nil { return err } if !confirmAction(cmd, fmt.Sprintf( - "Do you want to remove the user %q (%d) and any associated preauthkeys?", - user.GetName(), user.GetId(), + "Do you want to remove the user %q (%s) and any associated preauthkeys?", + user.Name, user.Id, )) { return printOutput(cmd, map[string]string{colResult: "User not destroyed"}, "User not destroyed") } - deleteRequest := &v1.DeleteUserRequest{Id: user.GetId()} - - response, err := client.DeleteUser(ctx, deleteRequest) + resp, err := client.DeleteUserWithResponse(ctx, user.Id) if err != nil { return fmt.Errorf("destroying user: %w", err) } - return printOutput(cmd, response, "User destroyed") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200, "User destroyed") }), } @@ -168,8 +187,8 @@ var listUsersCmd = &cobra.Command{ Use: cmdList, Short: "List all the users", Aliases: []string{"ls", cmdShow}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { - request := &v1.ListUsersRequest{} + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { + params := &clientv1.ListUsersParams{} id, _ := cmd.Flags().GetInt64("identifier") username, _ := cmd.Flags().GetString("name") @@ -178,29 +197,36 @@ var listUsersCmd = &cobra.Command{ // filter by one param at most switch { case id > 0: - request.Id = uint64(id) + idStr := strconv.FormatInt(id, util.Base10) + params.Id = &idStr case username != "": - request.Name = username + params.Name = &username case email != "": - request.Email = email + params.Email = &email } - response, err := client.ListUsers(ctx, request) + resp, err := client.ListUsersWithResponse(ctx, params) if err != nil { return fmt.Errorf("listing users: %w", err) } - return printListOutput(cmd, response.GetUsers(), func() error { - rows := make([][]string, 0, len(response.GetUsers())) - for _, user := range response.GetUsers() { + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + users := resp.JSON200.Users + + return printListOutput(cmd, users, func() error { + rows := make([][]string, 0, len(users)) + for _, user := range users { rows = append( rows, []string{ - strconv.FormatUint(user.GetId(), util.Base10), - user.GetDisplayName(), - user.GetName(), - user.GetEmail(), - user.GetCreatedAt().AsTime().Format(HeadscaleDateTimeFormat), + user.Id, + user.DisplayName, + user.Name, + user.Email, + user.CreatedAt.Format(HeadscaleDateTimeFormat), }, ) } @@ -214,7 +240,7 @@ var renameUserCmd = &cobra.Command{ Use: "rename", Short: "Renames a user", Aliases: []string{"mv"}, - RunE: grpcRunE(func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error { + RunE: clientRunE(func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error { id, _, err := resolveSingleUser(ctx, client, cmd) if err != nil { return err @@ -222,16 +248,15 @@ var renameUserCmd = &cobra.Command{ newName, _ := cmd.Flags().GetString("new-name") - renameReq := &v1.RenameUserRequest{ - OldId: id, - NewName: newName, - } - - response, err := client.RenameUser(ctx, renameReq) + resp, err := client.RenameUserWithResponse(ctx, strconv.FormatUint(id, util.Base10), newName) if err != nil { return fmt.Errorf("renaming user: %w", err) } - return printOutput(cmd, response.GetUser(), "User renamed") + if resp.StatusCode() != http.StatusOK { + return apiError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + } + + return printOutput(cmd, resp.JSON200.User, "User renamed") }), } diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go index 1187de425..8b8c67f7a 100644 --- a/cmd/headscale/cli/utils.go +++ b/cmd/headscale/cli/utils.go @@ -6,11 +6,15 @@ import ( "encoding/json" "errors" "fmt" + "net" + "net/http" "os" "slices" + "strings" "time" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + "github.com/cenkalti/backoff/v5" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" @@ -19,10 +23,6 @@ import ( "github.com/pterm/pterm" "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/protobuf/types/known/timestamppb" "gopkg.in/yaml.v3" ) @@ -38,11 +38,45 @@ const ( var ( errAPIKeyNotSet = errors.New("HEADSCALE_CLI_API_KEY environment variable needs to be set") errMissingParameter = errors.New("missing parameters") + errResponseStatus = errors.New("unexpected response status") ) -// mustMarkRequired marks the named flags as required on cmd, panicking -// if any name does not match a registered flag. This is only called -// from init() where a failure indicates a programming error. +// apiError turns a non-2xx response into an error, surfacing the server's +// RFC7807 problem detail. detail holds the operation context and errors[] the +// wrapped cause (e.g. "name is too long"); both are joined so the server's +// message text is not lost. +func apiError(statusCode int, problem *clientv1.ErrorModel) error { + if problem == nil { + return fmt.Errorf("%w: %d %s", errResponseStatus, statusCode, http.StatusText(statusCode)) + } + + parts := make([]string, 0, 2) + + if problem.Detail != nil && *problem.Detail != "" { + parts = append(parts, *problem.Detail) + } + + if problem.Errors != nil { + for _, e := range *problem.Errors { + if e.Message != nil && *e.Message != "" { + parts = append(parts, *e.Message) + } + } + } + + if len(parts) == 0 && problem.Title != nil && *problem.Title != "" { + parts = append(parts, *problem.Title) + } + + if len(parts) == 0 { + return fmt.Errorf("%w: %d %s", errResponseStatus, statusCode, http.StatusText(statusCode)) + } + + return fmt.Errorf("%w: %s", errResponseStatus, strings.Join(parts, ": ")) +} + +// mustMarkRequired marks the named flags as required, panicking on an unknown +// flag. Only called from init(), where a failure is a programming error. func mustMarkRequired(cmd *cobra.Command, names ...string) { for _, n := range names { err := cmd.MarkFlagRequired(n) @@ -69,45 +103,47 @@ func newHeadscaleServerWithConfig() (*hscontrol.Headscale, error) { return app, nil } -// grpcRunE wraps a cobra [cobra.Command.RunE] func, injecting a ready -// gRPC client and context. Connection lifecycle is managed by the -// wrapper — callers never see the underlying conn or cancel func. -func grpcRunE( - fn func(ctx context.Context, client v1.HeadscaleServiceClient, cmd *cobra.Command, args []string) error, +// clientRunE wraps a [cobra.Command.RunE] func, injecting a ready API client +// and a context whose timeout/cancel the wrapper owns. +func clientRunE( + fn func(ctx context.Context, client *clientv1.ClientWithResponses, cmd *cobra.Command, args []string) error, ) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { - ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig() + ctx, client, cancel, err := newHeadscaleCLIWithConfig() if err != nil { return fmt.Errorf("connecting to headscale: %w", err) } defer cancel() - defer conn.Close() return fn(ctx, client, cmd, args) } } -// withGRPC opens a gRPC client, runs fn with it, and tears the -// connection down afterwards. It is the building block for commands -// that branch on a flag before deciding to talk to the server, where -// grpcRunE's whole-RunE wrapping does not fit. -func withGRPC( - fn func(ctx context.Context, client v1.HeadscaleServiceClient) error, +// withClient runs fn with an API client. For commands that branch on a flag +// before talking to the server, where clientRunE's whole-RunE wrapping does +// not fit. +func withClient( + fn func(ctx context.Context, client *clientv1.ClientWithResponses) error, ) error { - ctx, client, conn, cancel, err := newHeadscaleCLIWithConfig() + ctx, client, cancel, err := newHeadscaleCLIWithConfig() if err != nil { return fmt.Errorf("connecting to headscale: %w", err) } defer cancel() - defer conn.Close() return fn(ctx, client) } -func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *grpc.ClientConn, context.CancelFunc, error) { +// newHeadscaleCLIWithConfig builds an HTTP client for the Headscale v1 API. +// +// When cfg.CLI.Address is unset the CLI is assumed to run on the server host +// and talks to the unix socket over HTTP without authentication (local trust). +// Otherwise it talks to the remote TCP address over HTTPS and injects the +// configured API key as a bearer token. +func newHeadscaleCLIWithConfig() (context.Context, *clientv1.ClientWithResponses, context.CancelFunc, error) { cfg, err := types.LoadCLIConfig() if err != nil { - return nil, nil, nil, nil, fmt.Errorf("loading configuration: %w", err) + return nil, nil, nil, fmt.Errorf("loading configuration: %w", err) } log.Debug(). @@ -116,10 +152,6 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g ctx, cancel := context.WithTimeout(context.Background(), cfg.CLI.Timeout) - grpcOptions := []grpc.DialOption{ - grpc.WithBlock(), //nolint:staticcheck // SA1019: deprecated but supported in 1.x - } - address := cfg.CLI.Address // If the address is not set, we assume that we are on the server hosting [hscontrol]. @@ -128,80 +160,111 @@ func newHeadscaleCLIWithConfig() (context.Context, v1.HeadscaleServiceClient, *g Str("socket", cfg.UnixSocket). Msgf("HEADSCALE_CLI_ADDRESS environment is not set, connecting to unix socket.") - address = cfg.UnixSocket - - // Try to give the user better feedback if we cannot write to the headscale - // socket. Note: [os.OpenFile] on a Unix domain socket returns ENXIO on - // Linux which is expected — only permission errors are actionable here. - // The actual gRPC connection uses [net.Dial] which handles sockets properly. - socket, err := os.OpenFile(cfg.UnixSocket, os.O_WRONLY, SocketWritePermissions) //nolint + client, err := newSocketClient(cfg.UnixSocket) if err != nil { - if os.IsPermission(err) { - cancel() - - return nil, nil, nil, nil, fmt.Errorf( - "unable to read/write to headscale socket %q, do you have the correct permissions? %w", - cfg.UnixSocket, - err, - ) - } - } else { - socket.Close() - } - - grpcOptions = append( - grpcOptions, - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithContextDialer(util.GrpcSocketDialer), - ) - } else { - // If we are not connecting to a local server, require an API key for authentication - apiKey := cfg.CLI.APIKey - if apiKey == "" { cancel() - return nil, nil, nil, nil, errAPIKeyNotSet + return nil, nil, nil, err } - grpcOptions = append( - grpcOptions, - grpc.WithPerRPCCredentials(tokenAuth{ - token: apiKey, - }), - ) + log.Trace().Caller().Str(zf.Address, cfg.UnixSocket).Msg("connecting via unix socket") - if cfg.CLI.Insecure { - tlsConfig := &tls.Config{ - // turn of gosec as we are intentionally setting - // insecure. - //nolint:gosec - InsecureSkipVerify: true, - } - - grpcOptions = append( - grpcOptions, - grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), - ) - } else { - grpcOptions = append( - grpcOptions, - grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, "")), - ) - } + return ctx, client, cancel, nil } - log.Trace().Caller().Str(zf.Address, address).Msg("connecting via gRPC") + // Remote connections require an API key for authentication. + apiKey := cfg.CLI.APIKey + if apiKey == "" { + cancel() - conn, err := grpc.DialContext(ctx, address, grpcOptions...) //nolint:staticcheck // SA1019: deprecated but supported in 1.x + return nil, nil, nil, errAPIKeyNotSet + } + + client, err := newRemoteClient(address, apiKey, cfg.CLI.Insecure) if err != nil { cancel() - return nil, nil, nil, nil, fmt.Errorf("connecting to %s: %w", address, err) + return nil, nil, nil, err } - client := v1.NewHeadscaleServiceClient(conn) + log.Trace().Caller().Str(zf.Address, address).Msg("connecting via HTTPS") - return ctx, client, conn, cancel, nil + return ctx, client, cancel, nil +} + +// newSocketClient builds an API client that dials the local unix socket. The +// base-URL host is irrelevant; the custom dialer routes every request to the +// socket. +func newSocketClient(socketPath string) (*clientv1.ClientWithResponses, error) { + // Probe for a clearer permission error up front. [os.OpenFile] on a unix + // socket returns ENXIO on Linux (expected); only permission errors are + // actionable. The real connection goes through [net.Dial]. + socket, err := os.OpenFile(socketPath, os.O_WRONLY, SocketWritePermissions) //nolint + if err != nil { + if os.IsPermission(err) { + return nil, fmt.Errorf( + "unable to read/write to headscale socket %q, do you have the correct permissions? %w", + socketPath, + err, + ) + } + } else { + socket.Close() + } + + httpClient := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return dialHeadscaleSocket(ctx, socketPath) + }, + }, + } + + return clientv1.NewClientWithResponses( + "http://local", + clientv1.WithHTTPClient(httpClient), + ) +} + +// dialHeadscaleSocket connects to the unix socket, retrying until it appears or +// ctx (the CLI timeout) expires. The socket is created late in startup (after +// noise key, database, migrations), so a command run right after the server +// starts can race its creation; retrying preserves the old gRPC client's +// blocking-dial tolerance rather than failing on a not-yet-present socket. +func dialHeadscaleSocket(ctx context.Context, socketPath string) (net.Conn, error) { + b := backoff.NewExponentialBackOff() + b.InitialInterval = 50 * time.Millisecond + b.MaxInterval = 1 * time.Second + + return backoff.Retry(ctx, func() (net.Conn, error) { + return util.SocketDialer(ctx, socketPath) + }, backoff.WithBackOff(b)) +} + +// newRemoteClient builds an API client for a remote Headscale over HTTPS, +// honouring insecure (skip TLS verification) and injecting the API key as a +// bearer token on every request. +func newRemoteClient(address, apiKey string, insecure bool) (*clientv1.ClientWithResponses, error) { + transport := &http.Transport{} + if insecure { + transport.TLSClientConfig = &tls.Config{ + // turn off gosec as we are intentionally setting insecure. + //nolint:gosec + InsecureSkipVerify: true, + } + } + + httpClient := &http.Client{Transport: transport} + + return clientv1.NewClientWithResponses( + "https://"+address, + clientv1.WithHTTPClient(httpClient), + clientv1.WithRequestEditorFn(func(_ context.Context, req *http.Request) error { + req.Header.Set("Authorization", "Bearer "+apiKey) + + return nil + }), + ) } // formatOutput serialises result into the requested format. For the @@ -250,16 +313,16 @@ func printOutput(cmd *cobra.Command, result any, override string) error { } // expirationFromFlag parses the --expiration flag as a Prometheus-style -// duration (e.g. "90d", "1h") and returns an absolute timestamp. -func expirationFromFlag(cmd *cobra.Command) (*timestamppb.Timestamp, error) { +// duration (e.g. "90d", "1h") and returns an absolute time. +func expirationFromFlag(cmd *cobra.Command) (time.Time, error) { durationStr, _ := cmd.Flags().GetString("expiration") duration, err := model.ParseDuration(durationStr) if err != nil { - return nil, fmt.Errorf("parsing duration: %w", err) + return time.Time{}, fmt.Errorf("parsing duration: %w", err) } - return timestamppb.New(time.Now().UTC().Add(time.Duration(duration))), nil + return time.Now().UTC().Add(time.Duration(duration)), nil } // confirmAction returns true when the user confirms a prompt, or when @@ -323,21 +386,3 @@ func hasMachineOutputFlag() bool { return arg == outputFormatJSON || arg == outputFormatJSONLine || arg == outputFormatYAML }) } - -type tokenAuth struct { - token string -} - -// Return value is mapped to request headers. -func (t tokenAuth) GetRequestMetadata( - ctx context.Context, - in ...string, -) (map[string]string, error) { - return map[string]string{ - "authorization": "Bearer " + t.token, - }, nil -} - -func (tokenAuth) RequireTransportSecurity() bool { - return true -} diff --git a/cmd/headscale/cli/utils_socket_test.go b/cmd/headscale/cli/utils_socket_test.go new file mode 100644 index 000000000..cc9b22ed6 --- /dev/null +++ b/cmd/headscale/cli/utils_socket_test.go @@ -0,0 +1,72 @@ +package cli + +import ( + "context" + "net" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDialHeadscaleSocketRetriesUntilPresent proves the CLI socket dialer +// tolerates a not-yet-created socket (the server-still-starting race) by +// retrying until it appears, rather than failing immediately like a bare dial. +func TestDialHeadscaleSocketRetriesUntilPresent(t *testing.T) { + sock := filepath.Join(t.TempDir(), "headscale.sock") + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + type result struct { + conn net.Conn + err error + } + + done := make(chan result, 1) + + go func() { + conn, err := dialHeadscaleSocket(ctx, sock) + done <- result{conn, err} + }() + + // Listen only after the dialer has begun, so its backoff must retry the + // absent socket and connect once it exists. + var lc net.ListenConfig + + ln, err := lc.Listen(ctx, "unix", sock) + require.NoError(t, err) + + defer ln.Close() + + go func() { + if conn, _ := ln.Accept(); conn != nil { + conn.Close() + } + }() + + res := <-done + require.NoError(t, res.err) + require.NotNil(t, res.conn) + + res.conn.Close() +} + +// TestDialHeadscaleSocketRespectsDeadline proves the retry is bounded by the +// context: when the socket never appears, the dialer returns an error around the +// deadline instead of hanging. +func TestDialHeadscaleSocketRespectsDeadline(t *testing.T) { + sock := filepath.Join(t.TempDir(), "absent.sock") + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + start := time.Now() + + conn, err := dialHeadscaleSocket(ctx, sock) + require.Error(t, err) + assert.Nil(t, conn) + assert.Less(t, time.Since(start), 5*time.Second, "should stop near the deadline, not hang") +} diff --git a/hscontrol/util/net.go b/hscontrol/util/net.go index b159f765d..c3c15cefb 100644 --- a/hscontrol/util/net.go +++ b/hscontrol/util/net.go @@ -10,7 +10,9 @@ import ( "tailscale.com/net/tsaddr" ) -func GrpcSocketDialer(ctx context.Context, addr string) (net.Conn, error) { +// SocketDialer dials a local unix-domain socket, letting the HTTP CLI client +// reach the headscale API over its unix socket. +func SocketDialer(ctx context.Context, addr string) (net.Conn, error) { var d net.Dialer return d.DialContext(ctx, "unix", addr) diff --git a/integration/acl_test.go b/integration/acl_test.go index 542735667..6410a2214 100644 --- a/integration/acl_test.go +++ b/integration/acl_test.go @@ -10,7 +10,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/integration/hsic" @@ -1232,10 +1232,10 @@ func TestACLAutogroupTagged(t *testing.T) { require.NoError(t, err) // Create two pre-auth keys per user: one tagged, one untagged - taggedAuthKey, err := scenario.CreatePreAuthKeyWithTags(user.GetId(), true, false, []string{"tag:test"}) + taggedAuthKey, err := scenario.CreatePreAuthKeyWithTags(mustParseID(user.Id), true, false, []string{"tag:test"}) require.NoError(t, err) - untaggedAuthKey, err := scenario.CreatePreAuthKey(user.GetId(), true, false) + untaggedAuthKey, err := scenario.CreatePreAuthKey(mustParseID(user.Id), true, false) require.NoError(t, err) // Create nodes with proper naming @@ -1247,13 +1247,13 @@ func TestACLAutogroupTagged(t *testing.T) { if i == 0 { // First node is tagged - use tagged PreAuthKey - authKey = taggedAuthKey.GetKey() + authKey = taggedAuthKey.Key version = "head" t.Logf("Creating tagged node for %s", userStr) } else { // Second node is untagged - use untagged PreAuthKey - authKey = untaggedAuthKey.GetKey() + authKey = untaggedAuthKey.Key version = "unstable" t.Logf("Creating untagged node for %s", userStr) @@ -1554,7 +1554,7 @@ func TestACLAutogroupSelf(t *testing.T) { require.NoError(t, err) // Create a tagged PreAuthKey for the router node (tags-as-identity model) - authKey, err := scenario.CreatePreAuthKeyWithTags(routerUser.GetId(), true, false, []string{"tag:router-node"}) + authKey, err := scenario.CreatePreAuthKeyWithTags(mustParseID(routerUser.Id), true, false, []string{"tag:router-node"}) require.NoError(t, err) // Create router node (tags come from the PreAuthKey). @@ -1577,7 +1577,7 @@ func TestACLAutogroupSelf(t *testing.T) { err = routerClient.WaitForNeedsLogin(integrationutil.PeerSyncTimeout()) require.NoError(t, err) - err = routerClient.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = routerClient.Login(headscale.GetEndpoint(), authKey.Key) require.NoError(t, err) err = routerClient.WaitForRunning(integrationutil.PeerSyncTimeout()) @@ -2008,7 +2008,7 @@ func TestACLPolicyPropagationOverTime(t *testing.T) { // Get the node list and find the newest node (highest ID) var ( - nodeList []*v1.Node + nodeList []*clientv1.Node nodeToDeleteID uint64 ) @@ -2019,8 +2019,8 @@ func TestACLPolicyPropagationOverTime(t *testing.T) { // Find the node with the highest ID (the newest one) for _, node := range nodeList { - if node.GetId() > nodeToDeleteID { - nodeToDeleteID = node.GetId() + if mustParseID(node.Id) > nodeToDeleteID { + nodeToDeleteID = mustParseID(node.Id) } } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "iteration %d: Phase 2b - listing nodes before deletion", iteration) @@ -2205,7 +2205,7 @@ func TestACLTagPropagation(t *testing.T) { nodes, err := headscale.ListNodes("user1") require.NoError(t, err) - return user2Clients[0], user1Clients[0], nodes[0].GetId() + return user2Clients[0], user1Clients[0], mustParseID(nodes[0].Id) }, initialAccess: false, // user2 cannot access user1 (no tag) tagChange: []string{"tag:shared"}, // add tag:shared @@ -2256,7 +2256,7 @@ func TestACLTagPropagation(t *testing.T) { // Create user1's node WITH tag:shared via PreAuthKey taggedKey, err := scenario.CreatePreAuthKeyWithTags( - userMap["user1"].GetId(), false, false, []string{"tag:shared"}, + mustParseID(userMap["user1"].Id), false, false, []string{"tag:shared"}, ) require.NoError(t, err) @@ -2269,11 +2269,11 @@ func TestACLTagPropagation(t *testing.T) { tsic.WithNetfilter("off"), ) require.NoError(t, err) - err = user1Node.Login(headscale.GetEndpoint(), taggedKey.GetKey()) + err = user1Node.Login(headscale.GetEndpoint(), taggedKey.Key) require.NoError(t, err) // Create user2's node (untagged) - untaggedKey, err := scenario.CreatePreAuthKey(userMap["user2"].GetId(), false, false) + untaggedKey, err := scenario.CreatePreAuthKey(mustParseID(userMap["user2"].Id), false, false) require.NoError(t, err) user2Node, err := scenario.CreateTailscaleNode( @@ -2285,7 +2285,7 @@ func TestACLTagPropagation(t *testing.T) { tsic.WithNetfilter("off"), ) require.NoError(t, err) - err = user2Node.Login(headscale.GetEndpoint(), untaggedKey.GetKey()) + err = user2Node.Login(headscale.GetEndpoint(), untaggedKey.Key) require.NoError(t, err) err = scenario.WaitForTailscaleSync() @@ -2295,10 +2295,10 @@ func TestACLTagPropagation(t *testing.T) { allNodes, err := headscale.ListNodes() require.NoError(t, err) - tagged := findNode(allNodes, func(n *v1.Node) bool { return len(n.GetTags()) > 0 }) + tagged := findNode(allNodes, func(n *clientv1.Node) bool { return len(n.Tags) > 0 }) require.NotNil(t, tagged, "expected a tagged node") - return user2Node, user1Node, tagged.GetId() + return user2Node, user1Node, mustParseID(tagged.Id) }, initialAccess: true, // user2 can access user1 (has tag:shared) tagChange: []string{"tag:other"}, // replace with tag:other @@ -2349,7 +2349,7 @@ func TestACLTagPropagation(t *testing.T) { // Create user1's node with tag:team-a (user2 has NO ACL for this) taggedKey, err := scenario.CreatePreAuthKeyWithTags( - userMap["user1"].GetId(), false, false, []string{"tag:team-a"}, + mustParseID(userMap["user1"].Id), false, false, []string{"tag:team-a"}, ) require.NoError(t, err) @@ -2362,11 +2362,11 @@ func TestACLTagPropagation(t *testing.T) { tsic.WithNetfilter("off"), ) require.NoError(t, err) - err = user1Node.Login(headscale.GetEndpoint(), taggedKey.GetKey()) + err = user1Node.Login(headscale.GetEndpoint(), taggedKey.Key) require.NoError(t, err) // Create user2's node - untaggedKey, err := scenario.CreatePreAuthKey(userMap["user2"].GetId(), false, false) + untaggedKey, err := scenario.CreatePreAuthKey(mustParseID(userMap["user2"].Id), false, false) require.NoError(t, err) user2Node, err := scenario.CreateTailscaleNode( @@ -2378,7 +2378,7 @@ func TestACLTagPropagation(t *testing.T) { tsic.WithNetfilter("off"), ) require.NoError(t, err) - err = user2Node.Login(headscale.GetEndpoint(), untaggedKey.GetKey()) + err = user2Node.Login(headscale.GetEndpoint(), untaggedKey.Key) require.NoError(t, err) err = scenario.WaitForTailscaleSync() @@ -2388,10 +2388,10 @@ func TestACLTagPropagation(t *testing.T) { allNodes, err := headscale.ListNodes() require.NoError(t, err) - tagged := findNode(allNodes, func(n *v1.Node) bool { return len(n.GetTags()) > 0 }) + tagged := findNode(allNodes, func(n *clientv1.Node) bool { return len(n.Tags) > 0 }) require.NotNil(t, tagged, "expected a tagged node") - return user2Node, user1Node, tagged.GetId() + return user2Node, user1Node, mustParseID(tagged.Id) }, initialAccess: false, // user2 cannot access (tag:team-a not in ACL) tagChange: []string{"tag:team-b"}, // change to tag:team-b @@ -2442,7 +2442,7 @@ func TestACLTagPropagation(t *testing.T) { // Create user1's node with BOTH tags taggedKey, err := scenario.CreatePreAuthKeyWithTags( - userMap["user1"].GetId(), false, false, []string{"tag:web", "tag:internal"}, + mustParseID(userMap["user1"].Id), false, false, []string{"tag:web", "tag:internal"}, ) require.NoError(t, err) @@ -2455,11 +2455,11 @@ func TestACLTagPropagation(t *testing.T) { tsic.WithNetfilter("off"), ) require.NoError(t, err) - err = user1Node.Login(headscale.GetEndpoint(), taggedKey.GetKey()) + err = user1Node.Login(headscale.GetEndpoint(), taggedKey.Key) require.NoError(t, err) // Create user2's node - untaggedKey, err := scenario.CreatePreAuthKey(userMap["user2"].GetId(), false, false) + untaggedKey, err := scenario.CreatePreAuthKey(mustParseID(userMap["user2"].Id), false, false) require.NoError(t, err) user2Node, err := scenario.CreateTailscaleNode( @@ -2471,7 +2471,7 @@ func TestACLTagPropagation(t *testing.T) { tsic.WithNetfilter("off"), ) require.NoError(t, err) - err = user2Node.Login(headscale.GetEndpoint(), untaggedKey.GetKey()) + err = user2Node.Login(headscale.GetEndpoint(), untaggedKey.Key) require.NoError(t, err) err = scenario.WaitForTailscaleSync() @@ -2481,10 +2481,10 @@ func TestACLTagPropagation(t *testing.T) { allNodes, err := headscale.ListNodes() require.NoError(t, err) - tagged := findNode(allNodes, func(n *v1.Node) bool { return len(n.GetTags()) > 0 }) + tagged := findNode(allNodes, func(n *clientv1.Node) bool { return len(n.Tags) > 0 }) require.NotNil(t, tagged, "expected a tagged node") - return user2Node, user1Node, tagged.GetId() + return user2Node, user1Node, mustParseID(tagged.Id) }, initialAccess: true, // user2 can access (has tag:web) tagChange: []string{"tag:internal"}, // remove tag:web, keep tag:internal @@ -2535,7 +2535,7 @@ func TestACLTagPropagation(t *testing.T) { nodes, err := headscale.ListNodes("user1") require.NoError(t, err) - return user2Clients[0], user1Clients[0], nodes[0].GetId() + return user2Clients[0], user1Clients[0], mustParseID(nodes[0].Id) }, initialAccess: false, // user2 cannot access user1 (no tag yet) tagChange: []string{"tag:server"}, // assign tag:server @@ -2616,11 +2616,11 @@ func TestACLTagPropagation(t *testing.T) { allNodes, err := headscale.ListNodes() assert.NoError(c, err) - node := findNode(allNodes, func(n *v1.Node) bool { return n.GetId() == targetNodeID }) + node := findNode(allNodes, func(n *clientv1.Node) bool { return mustParseID(n.Id) == targetNodeID }) assert.NotNil(c, node, "Node should still exist") if node != nil { - assert.ElementsMatch(c, tt.tagChange, node.GetTags(), "Tags should be updated") + assert.ElementsMatch(c, tt.tagChange, node.Tags, "Tags should be updated") } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "verifying tag change applied") @@ -2759,7 +2759,7 @@ func TestACLTagPropagationPortSpecific(t *testing.T) { // Create user1's node WITH tag:webserver taggedKey, err := scenario.CreatePreAuthKeyWithTags( - userMap["user1"].GetId(), false, false, []string{"tag:webserver"}, + mustParseID(userMap["user1"].Id), false, false, []string{"tag:webserver"}, ) require.NoError(t, err) @@ -2773,11 +2773,11 @@ func TestACLTagPropagationPortSpecific(t *testing.T) { ) require.NoError(t, err) - err = user1Node.Login(headscale.GetEndpoint(), taggedKey.GetKey()) + err = user1Node.Login(headscale.GetEndpoint(), taggedKey.Key) require.NoError(t, err) // Create user2's node - untaggedKey, err := scenario.CreatePreAuthKey(userMap["user2"].GetId(), false, false) + untaggedKey, err := scenario.CreatePreAuthKey(mustParseID(userMap["user2"].Id), false, false) require.NoError(t, err) user2Node, err := scenario.CreateTailscaleNode( @@ -2789,7 +2789,7 @@ func TestACLTagPropagationPortSpecific(t *testing.T) { ) require.NoError(t, err) - err = user2Node.Login(headscale.GetEndpoint(), untaggedKey.GetKey()) + err = user2Node.Login(headscale.GetEndpoint(), untaggedKey.Key) require.NoError(t, err) err = scenario.WaitForTailscaleSync() @@ -2799,10 +2799,10 @@ func TestACLTagPropagationPortSpecific(t *testing.T) { allNodes, err := headscale.ListNodes() require.NoError(t, err) - tagged := findNode(allNodes, func(n *v1.Node) bool { return len(n.GetTags()) > 0 }) + tagged := findNode(allNodes, func(n *clientv1.Node) bool { return len(n.Tags) > 0 }) require.NotNil(t, tagged, "expected a tagged node") - targetNodeID := tagged.GetId() + targetNodeID := mustParseID(tagged.Id) targetFQDN, err := user1Node.FQDN() require.NoError(t, err) @@ -2828,11 +2828,11 @@ func TestACLTagPropagationPortSpecific(t *testing.T) { allNodes, err := headscale.ListNodes() assert.NoError(c, err) //nolint:testifylint // CollectT requires assert - node := findNode(allNodes, func(n *v1.Node) bool { return n.GetId() == targetNodeID }) + node := findNode(allNodes, func(n *clientv1.Node) bool { return mustParseID(n.Id) == targetNodeID }) assert.NotNil(c, node, "Node should still exist") if node != nil { - assert.ElementsMatch(c, []string{"tag:sshonly"}, node.GetTags(), "Tags should be updated to sshonly") + assert.ElementsMatch(c, []string{"tag:sshonly"}, node.Tags, "Tags should be updated to sshonly") } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "verifying tag change applied") @@ -3070,7 +3070,7 @@ func TestACLGroupAfterUserDeletion(t *testing.T) { nodes, err := headscale.ListNodes("user3") require.NoError(t, err) require.Len(t, nodes, 1, "user3 should have exactly one node") - user3NodeID := nodes[0].GetId() + user3NodeID := mustParseID(nodes[0].Id) // Delete user3's node first (required before deleting the user) err = headscale.DeleteNode(user3NodeID) @@ -3081,7 +3081,7 @@ func TestACLGroupAfterUserDeletion(t *testing.T) { require.NoError(t, err, "user3 should exist") // Now delete user3 (after their nodes are deleted) - err = headscale.DeleteUser(user3.GetId()) + err = headscale.DeleteUser(mustParseID(user3.Id)) require.NoError(t, err) // Verify user3 is deleted @@ -3249,13 +3249,13 @@ func TestACLGroupDeletionExactReproduction(t *testing.T) { nodes, err := headscale.ListNodes(userToDelete) require.NoError(t, err) require.Len(t, nodes, 1) - err = headscale.DeleteNode(nodes[0].GetId()) + err = headscale.DeleteNode(mustParseID(nodes[0].Id)) require.NoError(t, err) userToDeleteObj, err := GetUserByName(headscale, userToDelete) require.NoError(t, err, "user to delete should exist") - err = headscale.DeleteUser(userToDeleteObj.GetId()) + err = headscale.DeleteUser(mustParseID(userToDeleteObj.Id)) require.NoError(t, err) t.Log("Step 2: DONE - user2 deleted, ACL still has user2@ reference") diff --git a/integration/api_auth_test.go b/integration/api_auth_test.go index 8dfa6fd5d..7195159d7 100644 --- a/integration/api_auth_test.go +++ b/integration/api_auth_test.go @@ -11,13 +11,12 @@ import ( "testing" "time" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/integration/hsic" "github.com/juanfont/headscale/integration/integrationutil" "github.com/juanfont/headscale/integration/tsic" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "google.golang.org/protobuf/encoding/protojson" ) // TestAPIAuthenticationBypass tests that the API authentication middleware @@ -215,19 +214,19 @@ func TestAPIAuthenticationBypass(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 status code with valid API key") - // Should be able to parse as protobuf JSON - var response v1.ListUsersResponse + // Should be able to parse as JSON + var response clientv1.ListUsersOutputBody - err = protojson.Unmarshal(body, &response) - require.NoError(t, err, "Response should be valid protobuf JSON with valid API key") + err = json.Unmarshal(body, &response) + require.NoError(t, err, "Response should be valid JSON with valid API key") // Should contain our test users - users := response.GetUsers() + users := response.Users assert.Len(t, users, 3, "Should have 3 users") userNames := make([]string, len(users)) for i, u := range users { - userNames[i] = u.GetName() + userNames[i] = u.Name } assert.Contains(t, userNames, "user1") @@ -406,12 +405,12 @@ func TestAPIAuthenticationBypassCurl(t *testing.T) { "Curl with valid API key should return 200") // Should contain user data - var response v1.ListUsersResponse + var response clientv1.ListUsersOutputBody - err = protojson.Unmarshal([]byte(responseBody), &response) - require.NoError(t, err, "Response should be valid protobuf JSON") + err = json.Unmarshal([]byte(responseBody), &response) + require.NoError(t, err, "Response should be valid JSON") - users := response.GetUsers() + users := response.Users assert.Len(t, users, 2, "Should have 2 users") }) } @@ -420,7 +419,7 @@ func TestAPIAuthenticationBypassCurl(t *testing.T) { // properly blocks unauthorized requests. // This test verifies that the gRPC API does not have the same bypass issue // as the HTTP API middleware. -func TestGRPCAuthenticationBypass(t *testing.T) { +func TestRemoteCLIAuthenticationBypass(t *testing.T) { IntegrationSkip(t) spec := ScenarioSpec{ @@ -432,14 +431,9 @@ func TestGRPCAuthenticationBypass(t *testing.T) { require.NoError(t, err) defer scenario.ShutdownAssertNoPanics(t) - // We need TLS for remote gRPC connections err = scenario.CreateHeadscaleEnv( []tsic.Option{}, - hsic.WithTestName("grpcauthtest"), - hsic.WithConfigEnv(map[string]string{ - // Enable gRPC on the standard port - "HEADSCALE_GRPC_LISTEN_ADDR": "0.0.0.0:50443", - }), + hsic.WithTestName("remotecliauth"), ) require.NoError(t, err) @@ -460,43 +454,44 @@ func TestGRPCAuthenticationBypass(t *testing.T) { validAPIKey := strings.TrimSpace(apiKeyOutput) - // Get the gRPC endpoint - // For gRPC, we need to use the hostname and port 50443 - grpcAddress := headscale.GetHostname() + ":50443" + // Get the remote HTTP API endpoint as host:port; the CLI dials it over TLS + // with --insecure to skip verification of the test certificate. + remoteAddr := strings.TrimPrefix(strings.TrimPrefix(headscale.GetEndpoint(), "https://"), "http://") - t.Run("gRPC_NoAPIKey", func(t *testing.T) { + t.Run("Remote_NoAPIKey", func(t *testing.T) { // Test 1: Try to use CLI without API key (should fail) // When HEADSCALE_CLI_ADDRESS is set but HEADSCALE_CLI_API_KEY is not set, // the CLI should fail immediately _, err := headscale.Execute( []string{ "sh", "-c", - fmt.Sprintf("HEADSCALE_CLI_ADDRESS=%s HEADSCALE_CLI_INSECURE=true headscale users list --output json 2>&1", grpcAddress), + fmt.Sprintf("HEADSCALE_CLI_ADDRESS=%s HEADSCALE_CLI_INSECURE=true headscale users list --output json 2>&1", remoteAddr), }, ) // Should fail - CLI exits when API key is missing assert.Error(t, err, - "gRPC connection without API key should fail") + "remote connection without API key should fail") }) - t.Run("gRPC_InvalidAPIKey", func(t *testing.T) { + t.Run("Remote_InvalidAPIKey", func(t *testing.T) { // Test 2: Try to use CLI with invalid API key (should fail with auth error) output, err := headscale.Execute( []string{ "sh", "-c", - fmt.Sprintf("HEADSCALE_CLI_ADDRESS=%s HEADSCALE_CLI_API_KEY=invalid-key-12345 HEADSCALE_CLI_INSECURE=true headscale users list --output json 2>&1", grpcAddress), + fmt.Sprintf("HEADSCALE_CLI_ADDRESS=%s HEADSCALE_CLI_API_KEY=invalid-key-12345 HEADSCALE_CLI_INSECURE=true headscale users list --output json 2>&1", remoteAddr), }, ) // Should fail with authentication error require.Error(t, err, - "gRPC connection with invalid API key should fail") + "remote connection with invalid API key should fail") // Should contain authentication error message outputStr := strings.ToLower(output) assert.True(t, - strings.Contains(outputStr, "unauthenticated") || + strings.Contains(outputStr, "unauthorized") || + strings.Contains(outputStr, "unauthenticated") || strings.Contains(outputStr, "invalid token") || strings.Contains(outputStr, "validating token") || strings.Contains(outputStr, "authentication"), @@ -504,27 +499,27 @@ func TestGRPCAuthenticationBypass(t *testing.T) { // Should NOT leak user data assert.NotContains(t, output, "grpcuser1", - "SECURITY ISSUE: gRPC should not leak user data with invalid auth") + "SECURITY ISSUE: remote API should not leak user data with invalid auth") assert.NotContains(t, output, "grpcuser2", - "SECURITY ISSUE: gRPC should not leak user data with invalid auth") + "SECURITY ISSUE: remote API should not leak user data with invalid auth") }) - t.Run("gRPC_ValidAPIKey", func(t *testing.T) { + t.Run("Remote_ValidAPIKey", func(t *testing.T) { // Test 3: Use CLI with valid API key (should succeed) output, err := headscale.Execute( []string{ "sh", "-c", - fmt.Sprintf("HEADSCALE_CLI_ADDRESS=%s HEADSCALE_CLI_API_KEY=%s HEADSCALE_CLI_INSECURE=true headscale users list --output json", grpcAddress, validAPIKey), + fmt.Sprintf("HEADSCALE_CLI_ADDRESS=%s HEADSCALE_CLI_API_KEY=%s HEADSCALE_CLI_INSECURE=true headscale users list --output json", remoteAddr, validAPIKey), }, ) // Should succeed require.NoError(t, err, - "gRPC connection with valid API key should succeed, output: %s", output) + "remote connection with valid API key should succeed, output: %s", output) - // CLI outputs the users array directly, not wrapped in [v1.ListUsersResponse] - // Parse as JSON array (CLI uses [json.Marshal], not protojson) - var users []*v1.User + // CLI outputs the users array directly, not wrapped in a response object + // Parse as JSON array (CLI uses [json.Marshal]) + var users []*clientv1.User err = json.Unmarshal([]byte(output), &users) require.NoError(t, err, "Response should be valid JSON array") @@ -532,7 +527,7 @@ func TestGRPCAuthenticationBypass(t *testing.T) { userNames := make([]string, len(users)) for i, u := range users { - userNames[i] = u.GetName() + userNames[i] = u.Name } assert.Contains(t, userNames, "grpcuser1") @@ -544,7 +539,7 @@ func TestGRPCAuthenticationBypass(t *testing.T) { // with --config flag does not have authentication bypass issues when // connecting to a remote server. // Note: When using --config with local unix socket, no auth is needed. -// This test focuses on remote gRPC connections which require API keys. +// This test focuses on remote HTTP connections which require API keys. func TestCLIWithConfigAuthenticationBypass(t *testing.T) { IntegrationSkip(t) @@ -560,9 +555,6 @@ func TestCLIWithConfigAuthenticationBypass(t *testing.T) { err = scenario.CreateHeadscaleEnv( []tsic.Option{}, hsic.WithTestName("cliconfigauth"), - hsic.WithConfigEnv(map[string]string{ - "HEADSCALE_GRPC_LISTEN_ADDR": "0.0.0.0:50443", - }), ) require.NoError(t, err) @@ -583,7 +575,9 @@ func TestCLIWithConfigAuthenticationBypass(t *testing.T) { validAPIKey := strings.TrimSpace(apiKeyOutput) - grpcAddress := headscale.GetHostname() + ":50443" + // Remote HTTP API endpoint as host:port; the CLI dials it over TLS with + // insecure verification skipped for the test certificate. + remoteAddr := strings.TrimPrefix(strings.TrimPrefix(headscale.GetEndpoint(), "https://"), "http://") // Create a config file for testing configWithoutKey := fmt.Sprintf(` @@ -591,7 +585,7 @@ cli: address: %s timeout: 5s insecure: true -`, grpcAddress) +`, remoteAddr) configWithInvalidKey := fmt.Sprintf(` cli: @@ -599,7 +593,7 @@ cli: api_key: invalid-key-12345 timeout: 5s insecure: true -`, grpcAddress) +`, remoteAddr) configWithValidKey := fmt.Sprintf(` cli: @@ -607,7 +601,7 @@ cli: api_key: %s timeout: 5s insecure: true -`, grpcAddress, validAPIKey) +`, remoteAddr, validAPIKey) t.Run("CLI_Config_NoAPIKey", func(t *testing.T) { // Create config file without API key @@ -649,7 +643,8 @@ cli: // Should indicate authentication failure outputStr := strings.ToLower(output) assert.True(t, - strings.Contains(outputStr, "unauthenticated") || + strings.Contains(outputStr, "unauthorized") || + strings.Contains(outputStr, "unauthenticated") || strings.Contains(outputStr, "invalid token") || strings.Contains(outputStr, "validating token") || strings.Contains(outputStr, "authentication"), @@ -681,9 +676,9 @@ cli: require.NoError(t, err, "CLI with valid API key should succeed") - // CLI outputs the users array directly, not wrapped in [v1.ListUsersResponse] - // Parse as JSON array (CLI uses [json.Marshal], not protojson) - var users []*v1.User + // CLI outputs the users array directly, not wrapped in a response object + // Parse as JSON array (CLI uses [json.Marshal]) + var users []*clientv1.User err = json.Unmarshal([]byte(output), &users) require.NoError(t, err, "Response should be valid JSON array") @@ -691,7 +686,7 @@ cli: userNames := make([]string, len(users)) for i, u := range users { - userNames[i] = u.GetName() + userNames[i] = u.Name } assert.Contains(t, userNames, "cliuser1") diff --git a/integration/auth_key_test.go b/integration/auth_key_test.go index 7aa928c59..82db8b819 100644 --- a/integration/auth_key_test.go +++ b/integration/auth_key_test.go @@ -4,11 +4,10 @@ import ( "fmt" "net/netip" "slices" - "strconv" "testing" "time" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/integration/hsic" @@ -74,7 +73,7 @@ func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) { } var ( - listNodes []*v1.Node + listNodes []*clientv1.Node nodeCountBeforeLogout int ) @@ -135,12 +134,12 @@ func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) { require.NoError(t, err) for _, userName := range spec.Users { - key, err := scenario.CreatePreAuthKey(userMap[userName].GetId(), true, false) + key, err := scenario.CreatePreAuthKey(mustParseID(userMap[userName].Id), true, false) if err != nil { t.Fatalf("failed to create pre-auth key for user %s: %s", userName, err) } - err = scenario.RunTailscaleUp(userName, headscale.GetEndpoint(), key.GetKey()) + err = scenario.RunTailscaleUp(userName, headscale.GetEndpoint(), key.Key) if err != nil { t.Fatalf("failed to run tailscale up for user %s: %s", userName, err) } @@ -256,7 +255,7 @@ func TestAuthKeyLogoutAndReloginNewUser(t *testing.T) { requireAllClientsNetInfoAndDERP(t, headscale, expectedNodes, "all clients should have NetInfo and DERP after initial login", 3*time.Minute) var ( - listNodes []*v1.Node + listNodes []*clientv1.Node nodeCountBeforeLogout int ) @@ -290,7 +289,7 @@ func TestAuthKeyLogoutAndReloginNewUser(t *testing.T) { require.NoError(t, err) // Create a new authkey for user1, to be used for all clients - key, err := scenario.CreatePreAuthKey(userMap["user1"].GetId(), true, false) + key, err := scenario.CreatePreAuthKey(mustParseID(userMap["user1"].Id), true, false) if err != nil { t.Fatalf("failed to create pre-auth key for user1: %s", err) } @@ -298,13 +297,13 @@ func TestAuthKeyLogoutAndReloginNewUser(t *testing.T) { // Log in all clients as user1, iterating over the spec only returns the // clients, not the usernames. for _, userName := range spec.Users { - err = scenario.RunTailscaleUp(userName, headscale.GetEndpoint(), key.GetKey()) + err = scenario.RunTailscaleUp(userName, headscale.GetEndpoint(), key.Key) if err != nil { t.Fatalf("failed to run tailscale up for user %s: %s", userName, err) } } - var user1Nodes []*v1.Node + var user1Nodes []*clientv1.Node t.Logf("Validating user1 node count after relogin at %s", time.Now().Format(TimestampFormat)) assert.EventuallyWithT(t, func(ct *assert.CollectT) { @@ -318,7 +317,7 @@ func TestAuthKeyLogoutAndReloginNewUser(t *testing.T) { // Collect expected node IDs for user1 after relogin expectedUser1Nodes := make([]types.NodeID, 0, len(user1Nodes)) for _, node := range user1Nodes { - expectedUser1Nodes = append(expectedUser1Nodes, types.NodeID(node.GetId())) + expectedUser1Nodes = append(expectedUser1Nodes, types.NodeID(mustParseID(node.Id))) } // Validate connection state after relogin as user1 @@ -328,7 +327,7 @@ func TestAuthKeyLogoutAndReloginNewUser(t *testing.T) { // Validate that user2 still has their original nodes after user1's re-authentication // When nodes re-authenticate with a different user's pre-auth key, NEW nodes are created // for the new user. The original nodes remain with the original user. - var user2Nodes []*v1.Node + var user2Nodes []*clientv1.Node t.Logf("Validating user2 node persistence after user1 relogin at %s", time.Now().Format(TimestampFormat)) assert.EventuallyWithT(t, func(ct *assert.CollectT) { @@ -402,7 +401,7 @@ func TestAuthKeyLogoutAndReloginSameUserExpiredKey(t *testing.T) { requireAllClientsNetInfoAndDERP(t, headscale, expectedNodes, "all clients should have NetInfo and DERP after initial login", 3*time.Minute) var ( - listNodes []*v1.Node + listNodes []*clientv1.Node nodeCountBeforeLogout int ) @@ -446,7 +445,7 @@ func TestAuthKeyLogoutAndReloginSameUserExpiredKey(t *testing.T) { require.NoError(t, err) for _, userName := range spec.Users { - key, err := scenario.CreatePreAuthKey(userMap[userName].GetId(), true, false) + key, err := scenario.CreatePreAuthKey(mustParseID(userMap[userName].Id), true, false) if err != nil { t.Fatalf("failed to create pre-auth key for user %s: %s", userName, err) } @@ -458,12 +457,12 @@ func TestAuthKeyLogoutAndReloginSameUserExpiredKey(t *testing.T) { "preauthkeys", "expire", "--id", - strconv.FormatUint(key.GetId(), 10), + key.Id, }) require.NoError(t, err) require.NoError(t, err) - err = scenario.RunTailscaleUp(userName, headscale.GetEndpoint(), key.GetKey()) + err = scenario.RunTailscaleUp(userName, headscale.GetEndpoint(), key.Key) assert.ErrorContains(t, err, "authkey expired") } }) @@ -498,14 +497,14 @@ func TestAuthKeyDeleteKey(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap["user1"].GetId() + userID := mustParseID(userMap["user1"].Id) // Create a pre-auth key - we keep the full key string before it gets redacted authKey, err := scenario.CreatePreAuthKey(userID, false, false) require.NoError(t, err) - authKeyString := authKey.GetKey() - authKeyID := authKey.GetId() + authKeyString := authKey.Key + authKeyID := mustParseID(authKey.Id) t.Logf("Created pre-auth key ID %d: %s", authKeyID, authKeyString) // Create a tailscale client and log it in with the auth key @@ -519,7 +518,7 @@ func TestAuthKeyDeleteKey(t *testing.T) { require.NoError(t, err) // Wait for the node to be registered - var user1Nodes []*v1.Node + var user1Nodes []*clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { var err error @@ -529,8 +528,8 @@ func TestAuthKeyDeleteKey(t *testing.T) { assert.Len(c, user1Nodes, 1) }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "waiting for node to be registered") - nodeID := user1Nodes[0].GetId() - nodeName := user1Nodes[0].GetName() + nodeID := mustParseID(user1Nodes[0].Id) + nodeName := user1Nodes[0].Name t.Logf("Node %d (%s) created successfully with auth_key_id=%d", nodeID, nodeName, authKeyID) // Verify node is online @@ -640,7 +639,7 @@ func TestAuthKeyLogoutAndReloginRoutesPreserved(t *testing.T) { // Step 1: Verify initial route is advertised, approved, and SERVING t.Logf("Step 1: Verifying initial route is advertised, approved, and SERVING at %s", time.Now().Format(TimestampFormat)) - var initialNode *v1.Node + var initialNode *clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err := headscale.ListNodes() @@ -650,21 +649,21 @@ func TestAuthKeyLogoutAndReloginRoutesPreserved(t *testing.T) { if len(nodes) == 1 { initialNode = nodes[0] // Check: 1 announced, 1 approved, 1 serving (subnet route) - assert.Lenf(c, initialNode.GetAvailableRoutes(), 1, - "Node should have 1 available route, got %v", initialNode.GetAvailableRoutes()) - assert.Lenf(c, initialNode.GetApprovedRoutes(), 1, - "Node should have 1 approved route, got %v", initialNode.GetApprovedRoutes()) - assert.Lenf(c, initialNode.GetSubnetRoutes(), 1, - "Node should have 1 serving (subnet) route, got %v - THIS IS THE BUG if empty", initialNode.GetSubnetRoutes()) - assert.Contains(c, initialNode.GetSubnetRoutes(), advertiseRoute, + assert.Lenf(c, initialNode.AvailableRoutes, 1, + "Node should have 1 available route, got %v", initialNode.AvailableRoutes) + assert.Lenf(c, initialNode.ApprovedRoutes, 1, + "Node should have 1 approved route, got %v", initialNode.ApprovedRoutes) + assert.Lenf(c, initialNode.SubnetRoutes, 1, + "Node should have 1 serving (subnet) route, got %v - THIS IS THE BUG if empty", initialNode.SubnetRoutes) + assert.Contains(c, initialNode.SubnetRoutes, advertiseRoute, "Subnet routes should contain %s", advertiseRoute) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "initial route should be serving") require.NotNil(t, initialNode, "Initial node should be found") - initialNodeID := initialNode.GetId() - t.Logf("Initial node ID: %d, Available: %v, Approved: %v, Serving: %v", - initialNodeID, initialNode.GetAvailableRoutes(), initialNode.GetApprovedRoutes(), initialNode.GetSubnetRoutes()) + initialNodeID := initialNode.Id + t.Logf("Initial node ID: %s, Available: %v, Approved: %v, Serving: %v", + initialNodeID, initialNode.AvailableRoutes, initialNode.ApprovedRoutes, initialNode.SubnetRoutes) // Step 2: Logout t.Logf("Step 2: Logging out at %s", time.Now().Format(TimestampFormat)) @@ -694,12 +693,12 @@ func TestAuthKeyLogoutAndReloginRoutesPreserved(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - key, err := scenario.CreatePreAuthKey(userMap[user].GetId(), true, false) + key, err := scenario.CreatePreAuthKey(mustParseID(userMap[user].Id), true, false) require.NoError(t, err) // Re-login - the container already has extraLoginArgs with --advertise-routes // from the initial setup, so routes will be advertised on re-login - err = scenario.RunTailscaleUp(user, headscale.GetEndpoint(), key.GetKey()) + err = scenario.RunTailscaleUp(user, headscale.GetEndpoint(), key.Key) require.NoError(t, err) // Wait for client to be running @@ -722,23 +721,23 @@ func TestAuthKeyLogoutAndReloginRoutesPreserved(t *testing.T) { if len(nodes) == 1 { node := nodes[0] t.Logf("After relogin - Available: %v, Approved: %v, Serving: %v", - node.GetAvailableRoutes(), node.GetApprovedRoutes(), node.GetSubnetRoutes()) + node.AvailableRoutes, node.ApprovedRoutes, node.SubnetRoutes) // This is where issue #2896 manifests: // - Available shows the route (from [tailcfg.Hostinfo.RoutableIPs]) // - Approved shows the route (from [tailcfg.Node.ApprovedRoutes]) // - BUT Serving ([tailcfg.Node.SubnetRoutes]/[ipnstate.PeerStatus.PrimaryRoutes]) is EMPTY! - assert.Lenf(c, node.GetAvailableRoutes(), 1, - "Node should have 1 available route after relogin, got %v", node.GetAvailableRoutes()) - assert.Lenf(c, node.GetApprovedRoutes(), 1, - "Node should have 1 approved route after relogin, got %v", node.GetApprovedRoutes()) - assert.Lenf(c, node.GetSubnetRoutes(), 1, - "BUG #2896: Node should have 1 SERVING route after relogin, got %v", node.GetSubnetRoutes()) - assert.Contains(c, node.GetSubnetRoutes(), advertiseRoute, + assert.Lenf(c, node.AvailableRoutes, 1, + "Node should have 1 available route after relogin, got %v", node.AvailableRoutes) + assert.Lenf(c, node.ApprovedRoutes, 1, + "Node should have 1 approved route after relogin, got %v", node.ApprovedRoutes) + assert.Lenf(c, node.SubnetRoutes, 1, + "BUG #2896: Node should have 1 SERVING route after relogin, got %v", node.SubnetRoutes) + assert.Contains(c, node.SubnetRoutes, advertiseRoute, "BUG #2896: Subnet routes should contain %s after relogin", advertiseRoute) // Also verify node ID was preserved (same node, not new registration) - assert.Equal(c, initialNodeID, node.GetId(), + assert.Equal(c, initialNodeID, node.Id, "Node ID should be preserved after same-user relogin") } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, diff --git a/integration/auth_oidc_test.go b/integration/auth_oidc_test.go index 96bd980e5..7b6b1e7c7 100644 --- a/integration/auth_oidc_test.go +++ b/integration/auth_oidc_test.go @@ -11,7 +11,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/integration/hsic" @@ -86,26 +86,26 @@ func TestOIDCAuthenticationPingAll(t *testing.T) { listUsers, err := headscale.ListUsers() require.NoError(t, err) - want := []*v1.User{ + want := []*clientv1.User{ { - Id: 1, + Id: "1", Name: "user1", Email: "user1@test.no", }, { - Id: 2, + Id: "2", Name: "user1", Email: "user1@headscale.net", Provider: "oidc", ProviderId: scenario.mockOIDC.Issuer() + "/user1", }, { - Id: 3, + Id: "3", Name: "user2", Email: "user2@test.no", }, { - Id: 4, + Id: "4", Name: "user2", Email: "", // Unverified Provider: "oidc", @@ -114,10 +114,10 @@ func TestOIDCAuthenticationPingAll(t *testing.T) { } sort.Slice(listUsers, func(i, j int) bool { - return listUsers[i].GetId() < listUsers[j].GetId() + return mustParseID(listUsers[i].Id) < mustParseID(listUsers[j].Id) }) - if diff := cmp.Diff(want, listUsers, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" { + if diff := cmp.Diff(want, listUsers, cmpopts.IgnoreUnexported(clientv1.User{}), cmpopts.IgnoreFields(clientv1.User{}, "CreatedAt")); diff != "" { t.Fatalf("unexpected users: %s", diff) } } @@ -245,34 +245,34 @@ func TestOIDC024UserCreation(t *testing.T) { emailVerified bool cliUsers []string oidcUsers []string - want func(iss string) []*v1.User + want func(iss string) []*clientv1.User }{ { name: "no-migration-verified-email", emailVerified: true, cliUsers: []string{"user1", "user2"}, oidcUsers: []string{"user1", "user2"}, - want: func(iss string) []*v1.User { - return []*v1.User{ + want: func(iss string) []*clientv1.User { + return []*clientv1.User{ { - Id: 1, + Id: "1", Name: "user1", Email: "user1@test.no", }, { - Id: 2, + Id: "2", Name: "user1", Email: "user1@headscale.net", Provider: "oidc", ProviderId: iss + "/user1", }, { - Id: 3, + Id: "3", Name: "user2", Email: "user2@test.no", }, { - Id: 4, + Id: "4", Name: "user2", Email: "user2@headscale.net", Provider: "oidc", @@ -286,26 +286,26 @@ func TestOIDC024UserCreation(t *testing.T) { emailVerified: false, cliUsers: []string{"user1", "user2"}, oidcUsers: []string{"user1", "user2"}, - want: func(iss string) []*v1.User { - return []*v1.User{ + want: func(iss string) []*clientv1.User { + return []*clientv1.User{ { - Id: 1, + Id: "1", Name: "user1", Email: "user1@test.no", }, { - Id: 2, + Id: "2", Name: "user1", Provider: "oidc", ProviderId: iss + "/user1", }, { - Id: 3, + Id: "3", Name: "user2", Email: "user2@test.no", }, { - Id: 4, + Id: "4", Name: "user2", Provider: "oidc", ProviderId: iss + "/user2", @@ -318,26 +318,26 @@ func TestOIDC024UserCreation(t *testing.T) { emailVerified: false, cliUsers: []string{"user1.headscale.net", "user2.headscale.net"}, oidcUsers: []string{"user1", "user2"}, - want: func(iss string) []*v1.User { - return []*v1.User{ + want: func(iss string) []*clientv1.User { + return []*clientv1.User{ { - Id: 1, + Id: "1", Name: "user1.headscale.net", Email: "user1.headscale.net@test.no", }, { - Id: 2, + Id: "2", Name: "user1", Provider: "oidc", ProviderId: iss + "/user1", }, { - Id: 3, + Id: "3", Name: "user2.headscale.net", Email: "user2.headscale.net@test.no", }, { - Id: 4, + Id: "4", Name: "user2", Provider: "oidc", ProviderId: iss + "/user2", @@ -393,10 +393,10 @@ func TestOIDC024UserCreation(t *testing.T) { require.NoError(t, err) sort.Slice(listUsers, func(i, j int) bool { - return listUsers[i].GetId() < listUsers[j].GetId() + return mustParseID(listUsers[i].Id) < mustParseID(listUsers[j].Id) }) - if diff := cmp.Diff(want, listUsers, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" { + if diff := cmp.Diff(want, listUsers, cmpopts.IgnoreUnexported(clientv1.User{}), cmpopts.IgnoreFields(clientv1.User{}, "CreatedAt")); diff != "" { t.Errorf("unexpected users: %s", diff) } }) @@ -509,9 +509,9 @@ func TestOIDCReloginSameNodeNewUser(t *testing.T) { assert.NoError(ct, err, "Failed to list users during initial validation") assert.Len(ct, listUsers, 1, "Expected exactly 1 user after first login, got %d", len(listUsers)) - wantUsers := []*v1.User{ + wantUsers := []*clientv1.User{ { - Id: 1, + Id: "1", Name: "user1", Email: "user1@headscale.net", Provider: "oidc", @@ -520,17 +520,17 @@ func TestOIDCReloginSameNodeNewUser(t *testing.T) { } sort.Slice(listUsers, func(i, j int) bool { - return listUsers[i].GetId() < listUsers[j].GetId() + return mustParseID(listUsers[i].Id) < mustParseID(listUsers[j].Id) }) - if diff := cmp.Diff(wantUsers, listUsers, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" { + if diff := cmp.Diff(wantUsers, listUsers, cmpopts.IgnoreUnexported(clientv1.User{}), cmpopts.IgnoreFields(clientv1.User{}, "CreatedAt")); diff != "" { ct.Errorf("User validation failed after first login - unexpected users: %s", diff) } }, integrationutil.StatusReadyTimeout, 1*time.Second, "validating user1 creation after initial OIDC login") t.Logf("Validating initial node creation at %s", time.Now().Format(TimestampFormat)) - var listNodes []*v1.Node + var listNodes []*clientv1.Node assert.EventuallyWithT(t, func(ct *assert.CollectT) { var err error @@ -593,16 +593,16 @@ func TestOIDCReloginSameNodeNewUser(t *testing.T) { assert.NoError(ct, err, "Failed to list users after user2 login") assert.Len(ct, listUsers, 2, "Expected exactly 2 users after user2 login, got %d users", len(listUsers)) - wantUsers := []*v1.User{ + wantUsers := []*clientv1.User{ { - Id: 1, + Id: "1", Name: "user1", Email: "user1@headscale.net", Provider: "oidc", ProviderId: scenario.mockOIDC.Issuer() + "/user1", }, { - Id: 2, + Id: "2", Name: "user2", Email: "user2@headscale.net", Provider: "oidc", @@ -611,15 +611,15 @@ func TestOIDCReloginSameNodeNewUser(t *testing.T) { } sort.Slice(listUsers, func(i, j int) bool { - return listUsers[i].GetId() < listUsers[j].GetId() + return mustParseID(listUsers[i].Id) < mustParseID(listUsers[j].Id) }) - if diff := cmp.Diff(wantUsers, listUsers, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" { + if diff := cmp.Diff(wantUsers, listUsers, cmpopts.IgnoreUnexported(clientv1.User{}), cmpopts.IgnoreFields(clientv1.User{}, "CreatedAt")); diff != "" { ct.Errorf("User validation failed after user2 login - expected both user1 and user2: %s", diff) } }, integrationutil.StatusReadyTimeout, 1*time.Second, "validating both user1 and user2 exist after second OIDC login") - var listNodesAfterNewUserLogin []*v1.Node + var listNodesAfterNewUserLogin []*clientv1.Node // First, wait for the new node to be created t.Logf("Waiting for user2 node creation at %s", time.Now().Format(TimestampFormat)) assert.EventuallyWithT(t, func(ct *assert.CollectT) { @@ -640,9 +640,9 @@ func TestOIDCReloginSameNodeNewUser(t *testing.T) { if len(listNodesAfterNewUserLogin) >= 2 { // Machine key is the same as the "machine" has not changed, // but Node key is not as it is a new node - assert.Equal(ct, listNodes[0].GetMachineKey(), listNodesAfterNewUserLogin[0].GetMachineKey(), "Machine key should be preserved from original node") - assert.Equal(ct, listNodesAfterNewUserLogin[0].GetMachineKey(), listNodesAfterNewUserLogin[1].GetMachineKey(), "Both nodes should share the same machine key") - assert.NotEqual(ct, listNodesAfterNewUserLogin[0].GetNodeKey(), listNodesAfterNewUserLogin[1].GetNodeKey(), "Node keys should be different between user1 and user2 nodes") + assert.Equal(ct, listNodes[0].MachineKey, listNodesAfterNewUserLogin[0].MachineKey, "Machine key should be preserved from original node") + assert.Equal(ct, listNodesAfterNewUserLogin[0].MachineKey, listNodesAfterNewUserLogin[1].MachineKey, "Both nodes should share the same machine key") + assert.NotEqual(ct, listNodesAfterNewUserLogin[0].NodeKey, listNodesAfterNewUserLogin[1].NodeKey, "Node keys should be different between user1 and user2 nodes") } }, integrationutil.PolicyPropagationTimeout, 2*time.Second, "waiting for node count stabilization at exactly 2 nodes after user2 login") @@ -650,9 +650,9 @@ func TestOIDCReloginSameNodeNewUser(t *testing.T) { var activeUser2NodeID types.NodeID for _, node := range listNodesAfterNewUserLogin { - if node.GetUser().GetId() == 2 { // user2 - activeUser2NodeID = types.NodeID(node.GetId()) - t.Logf("Active user2 node: %d (User: %s)", node.GetId(), node.GetUser().GetName()) + if node.User.Id == "2" { // user2 + activeUser2NodeID = types.NodeID(mustParseID(node.Id)) + t.Logf("Active user2 node: %d (User: %s)", mustParseID(node.Id), node.User.Name) break } @@ -685,9 +685,9 @@ func TestOIDCReloginSameNodeNewUser(t *testing.T) { // Validate node stability - ensure no phantom nodes for i, node := range currentNodes { - assert.NotNil(ct, node.GetUser(), "Node %d should have a valid user before logout", i) - assert.NotEmpty(ct, node.GetMachineKey(), "Node %d should have a valid machine key before logout", i) - t.Logf("Pre-logout node %d: User=%s, MachineKey=%s", i, node.GetUser().GetName(), node.GetMachineKey()[:16]+"...") + assert.NotEmpty(ct, node.User.Id, "Node %d should have a valid user before logout", i) + assert.NotEmpty(ct, node.MachineKey, "Node %d should have a valid machine key before logout", i) + t.Logf("Pre-logout node %d: User=%s, MachineKey=%s", i, node.User.Name, node.MachineKey[:16]+"...") } }, integrationutil.HAConvergeTimeout, 2*time.Second, "validating stable node count and integrity before user2 logout") @@ -730,9 +730,9 @@ func TestOIDCReloginSameNodeNewUser(t *testing.T) { // Ensure both nodes are still valid (not cleaned up incorrectly) for i, node := range currentNodes { - assert.NotNil(ct, node.GetUser(), "Node %d should still have a valid user after user2 logout", i) - assert.NotEmpty(ct, node.GetMachineKey(), "Node %d should still have a valid machine key after user2 logout", i) - t.Logf("Post-logout node %d: User=%s, MachineKey=%s", i, node.GetUser().GetName(), node.GetMachineKey()[:16]+"...") + assert.NotEmpty(ct, node.User.Id, "Node %d should still have a valid user after user2 logout", i) + assert.NotEmpty(ct, node.MachineKey, "Node %d should still have a valid machine key after user2 logout", i) + t.Logf("Post-logout node %d: User=%s, MachineKey=%s", i, node.User.Name, node.MachineKey[:16]+"...") } }, integrationutil.HAConvergeTimeout, 2*time.Second, "validating node persistence and integrity after user2 logout") @@ -761,16 +761,16 @@ func TestOIDCReloginSameNodeNewUser(t *testing.T) { assert.NoError(ct, err, "Failed to list users during final validation") assert.Len(ct, listUsers, 2, "Should still have exactly 2 users after user1 relogin, got %d", len(listUsers)) - wantUsers := []*v1.User{ + wantUsers := []*clientv1.User{ { - Id: 1, + Id: "1", Name: "user1", Email: "user1@headscale.net", Provider: "oidc", ProviderId: scenario.mockOIDC.Issuer() + "/user1", }, { - Id: 2, + Id: "2", Name: "user2", Email: "user2@headscale.net", Provider: "oidc", @@ -779,15 +779,15 @@ func TestOIDCReloginSameNodeNewUser(t *testing.T) { } sort.Slice(listUsers, func(i, j int) bool { - return listUsers[i].GetId() < listUsers[j].GetId() + return mustParseID(listUsers[i].Id) < mustParseID(listUsers[j].Id) }) - if diff := cmp.Diff(wantUsers, listUsers, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" { + if diff := cmp.Diff(wantUsers, listUsers, cmpopts.IgnoreUnexported(clientv1.User{}), cmpopts.IgnoreFields(clientv1.User{}, "CreatedAt")); diff != "" { ct.Errorf("Final user validation failed - both users should persist after relogin cycle: %s", diff) } }, integrationutil.StatusReadyTimeout, 1*time.Second, "validating user persistence after complete relogin cycle (user1->user2->user1)") - var listNodesAfterLoggingBackIn []*v1.Node + var listNodesAfterLoggingBackIn []*clientv1.Node // Wait for login to complete and nodes to stabilize t.Logf("Final node validation: checking node stability after user1 relogin at %s", time.Now().Format(TimestampFormat)) assert.EventuallyWithT(t, func(ct *assert.CollectT) { @@ -806,24 +806,24 @@ func TestOIDCReloginSameNodeNewUser(t *testing.T) { // Validate that the machine we had when we logged in the first time, has the same // machine key, but a different ID than the newly logged in version of the same // machine. - assert.Equal(ct, listNodes[0].GetMachineKey(), listNodesAfterNewUserLogin[0].GetMachineKey(), "Original user1 machine key should match user1 node after user switch") - assert.Equal(ct, listNodes[0].GetNodeKey(), listNodesAfterNewUserLogin[0].GetNodeKey(), "Original user1 node key should match user1 node after user switch") - assert.Equal(ct, listNodes[0].GetId(), listNodesAfterNewUserLogin[0].GetId(), "Original user1 node ID should match user1 node after user switch") - assert.Equal(ct, listNodes[0].GetMachineKey(), listNodesAfterNewUserLogin[1].GetMachineKey(), "User1 and user2 nodes should share the same machine key") - assert.NotEqual(ct, listNodes[0].GetId(), listNodesAfterNewUserLogin[1].GetId(), "User1 and user2 nodes should have different node IDs") - assert.NotEqual(ct, listNodes[0].GetUser().GetId(), listNodesAfterNewUserLogin[1].GetUser().GetId(), "User1 and user2 nodes should belong to different users") + assert.Equal(ct, listNodes[0].MachineKey, listNodesAfterNewUserLogin[0].MachineKey, "Original user1 machine key should match user1 node after user switch") + assert.Equal(ct, listNodes[0].NodeKey, listNodesAfterNewUserLogin[0].NodeKey, "Original user1 node key should match user1 node after user switch") + assert.Equal(ct, listNodes[0].Id, listNodesAfterNewUserLogin[0].Id, "Original user1 node ID should match user1 node after user switch") + assert.Equal(ct, listNodes[0].MachineKey, listNodesAfterNewUserLogin[1].MachineKey, "User1 and user2 nodes should share the same machine key") + assert.NotEqual(ct, listNodes[0].Id, listNodesAfterNewUserLogin[1].Id, "User1 and user2 nodes should have different node IDs") + assert.NotEqual(ct, listNodes[0].User.Id, listNodesAfterNewUserLogin[1].User.Id, "User1 and user2 nodes should belong to different users") // Even tho we are logging in again with the same user, the previous key has been expired // and a new one has been generated. The node entry in the database should be the same // as the user + machinekey still matches. - assert.Equal(ct, listNodes[0].GetMachineKey(), listNodesAfterLoggingBackIn[0].GetMachineKey(), "Machine key should remain consistent after user1 relogin") - assert.NotEqual(ct, listNodes[0].GetNodeKey(), listNodesAfterLoggingBackIn[0].GetNodeKey(), "Node key should be regenerated after user1 relogin") - assert.Equal(ct, listNodes[0].GetId(), listNodesAfterLoggingBackIn[0].GetId(), "Node ID should be preserved for user1 after relogin") + assert.Equal(ct, listNodes[0].MachineKey, listNodesAfterLoggingBackIn[0].MachineKey, "Machine key should remain consistent after user1 relogin") + assert.NotEqual(ct, listNodes[0].NodeKey, listNodesAfterLoggingBackIn[0].NodeKey, "Node key should be regenerated after user1 relogin") + assert.Equal(ct, listNodes[0].Id, listNodesAfterLoggingBackIn[0].Id, "Node ID should be preserved for user1 after relogin") // The "logged back in" machine should have the same machinekey but a different nodekey // than the version logged in with a different user. - assert.Equal(ct, listNodesAfterLoggingBackIn[0].GetMachineKey(), listNodesAfterLoggingBackIn[1].GetMachineKey(), "Both final nodes should share the same machine key") - assert.NotEqual(ct, listNodesAfterLoggingBackIn[0].GetNodeKey(), listNodesAfterLoggingBackIn[1].GetNodeKey(), "Final nodes should have different node keys for different users") + assert.Equal(ct, listNodesAfterLoggingBackIn[0].MachineKey, listNodesAfterLoggingBackIn[1].MachineKey, "Both final nodes should share the same machine key") + assert.NotEqual(ct, listNodesAfterLoggingBackIn[0].NodeKey, listNodesAfterLoggingBackIn[1].NodeKey, "Final nodes should have different node keys for different users") t.Logf("Final validation complete - node counts and key relationships verified at %s", time.Now().Format(TimestampFormat)) }, integrationutil.HAConvergeTimeout, 2*time.Second, "validating final node state after complete user1->user2->user1 relogin cycle with detailed key validation") @@ -832,9 +832,9 @@ func TestOIDCReloginSameNodeNewUser(t *testing.T) { var activeUser1NodeID types.NodeID for _, node := range listNodesAfterLoggingBackIn { - if node.GetUser().GetId() == 1 { // user1 - activeUser1NodeID = types.NodeID(node.GetId()) - t.Logf("Active user1 node after relogin: %d (User: %s)", node.GetId(), node.GetUser().GetName()) + if node.User.Id == "1" { // user1 + activeUser1NodeID = types.NodeID(mustParseID(node.Id)) + t.Logf("Active user1 node after relogin: %d (User: %s)", mustParseID(node.Id), node.User.Name) break } @@ -942,9 +942,9 @@ func TestOIDCFollowUpUrl(t *testing.T) { require.NoError(t, err) assert.Len(t, listUsers, 1) - wantUsers := []*v1.User{ + wantUsers := []*clientv1.User{ { - Id: 1, + Id: "1", Name: "user1", Email: "user1@headscale.net", Provider: "oidc", @@ -954,15 +954,15 @@ func TestOIDCFollowUpUrl(t *testing.T) { sort.Slice( listUsers, func(i, j int) bool { - return listUsers[i].GetId() < listUsers[j].GetId() + return mustParseID(listUsers[i].Id) < mustParseID(listUsers[j].Id) }, ) if diff := cmp.Diff( wantUsers, listUsers, - cmpopts.IgnoreUnexported(v1.User{}), - cmpopts.IgnoreFields(v1.User{}, "CreatedAt"), + cmpopts.IgnoreUnexported(clientv1.User{}), + cmpopts.IgnoreFields(clientv1.User{}, "CreatedAt"), ); diff != "" { t.Fatalf("unexpected users: %s", diff) } @@ -1051,9 +1051,9 @@ func TestOIDCMultipleOpenedLoginUrls(t *testing.T) { require.NoError(t, err) assert.Len(t, listUsers, 1) - wantUsers := []*v1.User{ + wantUsers := []*clientv1.User{ { - Id: 1, + Id: "1", Name: "user1", Email: "user1@headscale.net", Provider: "oidc", @@ -1063,15 +1063,15 @@ func TestOIDCMultipleOpenedLoginUrls(t *testing.T) { sort.Slice( listUsers, func(i, j int) bool { - return listUsers[i].GetId() < listUsers[j].GetId() + return mustParseID(listUsers[i].Id) < mustParseID(listUsers[j].Id) }, ) if diff := cmp.Diff( wantUsers, listUsers, - cmpopts.IgnoreUnexported(v1.User{}), - cmpopts.IgnoreFields(v1.User{}, "CreatedAt"), + cmpopts.IgnoreUnexported(clientv1.User{}), + cmpopts.IgnoreFields(clientv1.User{}, "CreatedAt"), ); diff != "" { t.Fatalf("unexpected users: %s", diff) } @@ -1159,9 +1159,9 @@ func TestOIDCReloginSameNodeSameUser(t *testing.T) { assert.NoError(ct, err, "Failed to list users during initial validation") assert.Len(ct, listUsers, 1, "Expected exactly 1 user after first login, got %d", len(listUsers)) - wantUsers := []*v1.User{ + wantUsers := []*clientv1.User{ { - Id: 1, + Id: "1", Name: "user1", Email: "user1@headscale.net", Provider: "oidc", @@ -1170,17 +1170,17 @@ func TestOIDCReloginSameNodeSameUser(t *testing.T) { } sort.Slice(listUsers, func(i, j int) bool { - return listUsers[i].GetId() < listUsers[j].GetId() + return mustParseID(listUsers[i].Id) < mustParseID(listUsers[j].Id) }) - if diff := cmp.Diff(wantUsers, listUsers, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" { + if diff := cmp.Diff(wantUsers, listUsers, cmpopts.IgnoreUnexported(clientv1.User{}), cmpopts.IgnoreFields(clientv1.User{}, "CreatedAt")); diff != "" { ct.Errorf("User validation failed after first login - unexpected users: %s", diff) } }, integrationutil.StatusReadyTimeout, 1*time.Second, "validating user1 creation after initial OIDC login") t.Logf("Validating initial node creation at %s", time.Now().Format(TimestampFormat)) - var initialNodes []*v1.Node + var initialNodes []*clientv1.Node assert.EventuallyWithT(t, func(ct *assert.CollectT) { var err error @@ -1211,9 +1211,9 @@ func TestOIDCReloginSameNodeSameUser(t *testing.T) { validateInitialConnection(t, headscale, expectedNodes) // Store initial node keys for comparison - initialMachineKey := initialNodes[0].GetMachineKey() - initialNodeKey := initialNodes[0].GetNodeKey() - initialNodeID := initialNodes[0].GetId() + initialMachineKey := initialNodes[0].MachineKey + initialNodeKey := initialNodes[0].NodeKey + initialNodeID := initialNodes[0].Id // Logout user1 err = ts.Logout() @@ -1262,9 +1262,9 @@ func TestOIDCReloginSameNodeSameUser(t *testing.T) { assert.NoError(ct, err, "Failed to list users during final validation") assert.Len(ct, listUsers, 1, "Should still have exactly 1 user after same-user relogin, got %d", len(listUsers)) - wantUsers := []*v1.User{ + wantUsers := []*clientv1.User{ { - Id: 1, + Id: "1", Name: "user1", Email: "user1@headscale.net", Provider: "oidc", @@ -1273,15 +1273,15 @@ func TestOIDCReloginSameNodeSameUser(t *testing.T) { } sort.Slice(listUsers, func(i, j int) bool { - return listUsers[i].GetId() < listUsers[j].GetId() + return mustParseID(listUsers[i].Id) < mustParseID(listUsers[j].Id) }) - if diff := cmp.Diff(wantUsers, listUsers, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" { + if diff := cmp.Diff(wantUsers, listUsers, cmpopts.IgnoreUnexported(clientv1.User{}), cmpopts.IgnoreFields(clientv1.User{}, "CreatedAt")); diff != "" { ct.Errorf("Final user validation failed - user1 should persist after same-user relogin: %s", diff) } }, integrationutil.StatusReadyTimeout, 1*time.Second, "validating user1 persistence after same-user OIDC relogin cycle") - var finalNodes []*v1.Node + var finalNodes []*clientv1.Node t.Logf("Final node validation: checking node stability after same-user relogin at %s", time.Now().Format(TimestampFormat)) assert.EventuallyWithT(t, func(ct *assert.CollectT) { @@ -1293,19 +1293,19 @@ func TestOIDCReloginSameNodeSameUser(t *testing.T) { finalNode := finalNodes[0] // Machine key should be preserved (same physical machine) - assert.Equal(ct, initialMachineKey, finalNode.GetMachineKey(), "Machine key should be preserved for same user same node relogin") + assert.Equal(ct, initialMachineKey, finalNode.MachineKey, "Machine key should be preserved for same user same node relogin") // Node ID should be preserved (same user, same machine) - assert.Equal(ct, initialNodeID, finalNode.GetId(), "Node ID should be preserved for same user same node relogin") + assert.Equal(ct, initialNodeID, finalNode.Id, "Node ID should be preserved for same user same node relogin") // Node key should be regenerated (new session after logout) - assert.NotEqual(ct, initialNodeKey, finalNode.GetNodeKey(), "Node key should be regenerated after logout/relogin even for same user") + assert.NotEqual(ct, initialNodeKey, finalNode.NodeKey, "Node key should be regenerated after logout/relogin even for same user") t.Logf("Final validation complete - same user relogin key relationships verified at %s", time.Now().Format(TimestampFormat)) }, integrationutil.HAConvergeTimeout, 2*time.Second, "validating final node state after same-user OIDC relogin cycle with key preservation validation") // Security validation: user1's node should be active after relogin - activeUser1NodeID := types.NodeID(finalNodes[0].GetId()) + activeUser1NodeID := types.NodeID(mustParseID(finalNodes[0].Id)) t.Logf("Validating user1 node is online after same-user relogin at %s", time.Now().Format(TimestampFormat)) require.EventuallyWithT(t, func(c *assert.CollectT) { @@ -1389,10 +1389,10 @@ func TestOIDCExpiryAfterRestart(t *testing.T) { assert.Len(ct, nodes, 1) node := nodes[0] - assert.NotNil(ct, node.GetExpiry(), "Expiry should be set after OIDC login") + assert.NotNil(ct, node.Expiry, "Expiry should be set after OIDC login") - if node.GetExpiry() != nil { - expiryTime := node.GetExpiry().AsTime() + if node.Expiry != nil { + expiryTime := *node.Expiry assert.False(ct, expiryTime.IsZero(), "Expiry should not be zero time") initialExpiry = expiryTime @@ -1431,10 +1431,10 @@ func TestOIDCExpiryAfterRestart(t *testing.T) { assert.Len(ct, nodes, 1, "Should still have exactly 1 node after restart") node := nodes[0] - assert.NotNil(ct, node.GetExpiry(), "Expiry should NOT be nil after restart") + assert.NotNil(ct, node.Expiry, "Expiry should NOT be nil after restart") - if node.GetExpiry() != nil { - expiryTime := node.GetExpiry().AsTime() + if node.Expiry != nil { + expiryTime := *node.Expiry // This is the bug check - expiry should NOT be zero time assert.False(ct, expiryTime.IsZero(), @@ -1567,9 +1567,9 @@ func TestOIDCACLPolicyOnJoin(t *testing.T) { assert.Len(ct, nodes, 1) gatewayNode := nodes[0] - gatewayNodeID = gatewayNode.GetId() - assert.Len(ct, gatewayNode.GetAvailableRoutes(), 1) - assert.Contains(ct, gatewayNode.GetAvailableRoutes(), advertiseRoute) + gatewayNodeID = mustParseID(gatewayNode.Id) + assert.Len(ct, gatewayNode.AvailableRoutes, 1) + assert.Contains(ct, gatewayNode.AvailableRoutes, advertiseRoute) }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "route advertisement should propagate to headscale") // Approve the advertised route @@ -1586,8 +1586,8 @@ func TestOIDCACLPolicyOnJoin(t *testing.T) { assert.Len(ct, nodes, 1) gatewayNode := nodes[0] - assert.Len(ct, gatewayNode.GetApprovedRoutes(), 1) - assert.Contains(ct, gatewayNode.GetApprovedRoutes(), advertiseRoute) + assert.Len(ct, gatewayNode.ApprovedRoutes, 1) + assert.Contains(ct, gatewayNode.ApprovedRoutes, advertiseRoute) }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "route approval should propagate to headscale") // NOW create the OIDC user by having them join @@ -1707,10 +1707,10 @@ func TestOIDCACLPolicyOnJoin(t *testing.T) { assert.GreaterOrEqual(ct, len(users), 2, "Should have at least 2 users (gateway CLI user + oidcuser)") // Find gateway CLI user - var gatewayUser *v1.User + var gatewayUser *clientv1.User for _, user := range users { - if user.GetName() == "gateway" && user.GetProvider() == "" { + if user.Name == "gateway" && user.Provider == "" { gatewayUser = user break } @@ -1719,14 +1719,14 @@ func TestOIDCACLPolicyOnJoin(t *testing.T) { assert.NotNil(ct, gatewayUser, "Should have gateway CLI user") if gatewayUser != nil { - assert.Equal(ct, "gateway", gatewayUser.GetName()) + assert.Equal(ct, "gateway", gatewayUser.Name) } // Find OIDC user - var oidcUserFound *v1.User + var oidcUserFound *clientv1.User for _, user := range users { - if user.GetName() == "oidcuser" && user.GetProvider() == "oidc" { + if user.Name == "oidcuser" && user.Provider == "oidc" { oidcUserFound = user break } @@ -1735,8 +1735,8 @@ func TestOIDCACLPolicyOnJoin(t *testing.T) { assert.NotNil(ct, oidcUserFound, "Should have OIDC user") if oidcUserFound != nil { - assert.Equal(ct, "oidcuser", oidcUserFound.GetName()) - assert.Equal(ct, "oidcuser@headscale.net", oidcUserFound.GetEmail()) + assert.Equal(ct, "oidcuser", oidcUserFound.Name) + assert.Equal(ct, "oidcuser@headscale.net", oidcUserFound.Email) } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "headscale should have correct users and nodes") @@ -1834,7 +1834,7 @@ func TestOIDCReloginSameUserRoutesPreserved(t *testing.T) { // Step 1: Verify initial route is advertised, approved, and SERVING t.Logf("Step 1: Verifying initial route is advertised, approved, and SERVING at %s", time.Now().Format(TimestampFormat)) - var initialNode *v1.Node + var initialNode *clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err := headscale.ListNodes() @@ -1844,21 +1844,21 @@ func TestOIDCReloginSameUserRoutesPreserved(t *testing.T) { if len(nodes) == 1 { initialNode = nodes[0] // Check: 1 announced, 1 approved, 1 serving (subnet route) - assert.Lenf(c, initialNode.GetAvailableRoutes(), 1, - "Node should have 1 available route, got %v", initialNode.GetAvailableRoutes()) - assert.Lenf(c, initialNode.GetApprovedRoutes(), 1, - "Node should have 1 approved route, got %v", initialNode.GetApprovedRoutes()) - assert.Lenf(c, initialNode.GetSubnetRoutes(), 1, - "Node should have 1 serving (subnet) route, got %v - THIS IS THE BUG if empty", initialNode.GetSubnetRoutes()) - assert.Contains(c, initialNode.GetSubnetRoutes(), advertiseRoute, + assert.Lenf(c, initialNode.AvailableRoutes, 1, + "Node should have 1 available route, got %v", initialNode.AvailableRoutes) + assert.Lenf(c, initialNode.ApprovedRoutes, 1, + "Node should have 1 approved route, got %v", initialNode.ApprovedRoutes) + assert.Lenf(c, initialNode.SubnetRoutes, 1, + "Node should have 1 serving (subnet) route, got %v - THIS IS THE BUG if empty", initialNode.SubnetRoutes) + assert.Contains(c, initialNode.SubnetRoutes, advertiseRoute, "Subnet routes should contain %s", advertiseRoute) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "initial route should be serving") require.NotNil(t, initialNode, "Initial node should be found") - initialNodeID := initialNode.GetId() - t.Logf("Initial node ID: %d, Available: %v, Approved: %v, Serving: %v", - initialNodeID, initialNode.GetAvailableRoutes(), initialNode.GetApprovedRoutes(), initialNode.GetSubnetRoutes()) + initialNodeID := initialNode.Id + t.Logf("Initial node ID: %s, Available: %v, Approved: %v, Serving: %v", + initialNodeID, initialNode.AvailableRoutes, initialNode.ApprovedRoutes, initialNode.SubnetRoutes) // Step 2: Logout t.Logf("Step 2: Logging out at %s", time.Now().Format(TimestampFormat)) @@ -1911,23 +1911,23 @@ func TestOIDCReloginSameUserRoutesPreserved(t *testing.T) { if len(nodes) == 1 { node := nodes[0] t.Logf("After relogin - Available: %v, Approved: %v, Serving: %v", - node.GetAvailableRoutes(), node.GetApprovedRoutes(), node.GetSubnetRoutes()) + node.AvailableRoutes, node.ApprovedRoutes, node.SubnetRoutes) // This is where issue #2896 manifests: // - Available shows the route (from [tailcfg.Hostinfo.RoutableIPs]) // - Approved shows the route (from [tailcfg.Node.ApprovedRoutes]) // - BUT Serving ([tailcfg.Node.SubnetRoutes]/[ipnstate.PeerStatus.PrimaryRoutes]) is EMPTY! - assert.Lenf(c, node.GetAvailableRoutes(), 1, - "Node should have 1 available route after relogin, got %v", node.GetAvailableRoutes()) - assert.Lenf(c, node.GetApprovedRoutes(), 1, - "Node should have 1 approved route after relogin, got %v", node.GetApprovedRoutes()) - assert.Lenf(c, node.GetSubnetRoutes(), 1, - "BUG #2896: Node should have 1 SERVING route after relogin, got %v", node.GetSubnetRoutes()) - assert.Contains(c, node.GetSubnetRoutes(), advertiseRoute, + assert.Lenf(c, node.AvailableRoutes, 1, + "Node should have 1 available route after relogin, got %v", node.AvailableRoutes) + assert.Lenf(c, node.ApprovedRoutes, 1, + "Node should have 1 approved route after relogin, got %v", node.ApprovedRoutes) + assert.Lenf(c, node.SubnetRoutes, 1, + "BUG #2896: Node should have 1 SERVING route after relogin, got %v", node.SubnetRoutes) + assert.Contains(c, node.SubnetRoutes, advertiseRoute, "BUG #2896: Subnet routes should contain %s after relogin", advertiseRoute) // Also verify node ID was preserved (same node, not new registration) - assert.Equal(c, initialNodeID, node.GetId(), + assert.Equal(c, initialNodeID, node.Id, "Node ID should be preserved after same-user relogin") } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, diff --git a/integration/auth_web_flow_test.go b/integration/auth_web_flow_test.go index 752d78a31..26c23d06e 100644 --- a/integration/auth_web_flow_test.go +++ b/integration/auth_web_flow_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/integration/hsic" "github.com/juanfont/headscale/integration/integrationutil" @@ -98,7 +98,7 @@ func TestAuthWebFlowLogoutAndReloginSameUser(t *testing.T) { // Validate initial connection state validateInitialConnection(t, headscale, expectedNodes) - var listNodes []*v1.Node + var listNodes []*clientv1.Node t.Logf("Validating initial node count after web auth at %s", time.Now().Format(TimestampFormat)) assert.EventuallyWithT(t, func(ct *assert.CollectT) { @@ -256,7 +256,7 @@ func TestAuthWebFlowLogoutAndReloginNewUser(t *testing.T) { // Validate initial connection state validateInitialConnection(t, headscale, expectedNodes) - var listNodes []*v1.Node + var listNodes []*clientv1.Node t.Logf("Validating initial node count after web auth at %s", time.Now().Format(TimestampFormat)) assert.EventuallyWithT(t, func(ct *assert.CollectT) { @@ -316,7 +316,7 @@ func TestAuthWebFlowLogoutAndReloginNewUser(t *testing.T) { t.Logf("all clients logged back in as user1") - var user1Nodes []*v1.Node + var user1Nodes []*clientv1.Node t.Logf("Validating user1 node count after relogin at %s", time.Now().Format(TimestampFormat)) assert.EventuallyWithT(t, func(ct *assert.CollectT) { @@ -330,7 +330,7 @@ func TestAuthWebFlowLogoutAndReloginNewUser(t *testing.T) { // Collect expected node IDs for user1 after relogin expectedUser1Nodes := make([]types.NodeID, 0, len(user1Nodes)) for _, node := range user1Nodes { - expectedUser1Nodes = append(expectedUser1Nodes, types.NodeID(node.GetId())) + expectedUser1Nodes = append(expectedUser1Nodes, types.NodeID(mustParseID(node.Id))) } // Validate connection state after relogin as user1 @@ -338,7 +338,7 @@ func TestAuthWebFlowLogoutAndReloginNewUser(t *testing.T) { // Validate that user2's old nodes still exist in database (but are expired/offline) // When CLI registration creates new nodes for user1, user2's old nodes remain - var user2Nodes []*v1.Node + var user2Nodes []*clientv1.Node t.Logf("Validating user2 old nodes remain in database after CLI registration to user1 at %s", time.Now().Format(TimestampFormat)) assert.EventuallyWithT(t, func(ct *assert.CollectT) { diff --git a/integration/cli_apikeys_test.go b/integration/cli_apikeys_test.go index f42de600e..fab3a1c7d 100644 --- a/integration/cli_apikeys_test.go +++ b/integration/cli_apikeys_test.go @@ -1,11 +1,10 @@ package integration import ( - "strconv" "testing" "time" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/integration/hsic" "github.com/juanfont/headscale/integration/integrationutil" "github.com/juanfont/headscale/integration/tsic" @@ -55,7 +54,7 @@ func TestApiKeyCommand(t *testing.T) { assert.Len(t, keys, 5) - var listedAPIKeys []v1.ApiKey + var listedAPIKeys []clientv1.ApiKey assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal(headscale, @@ -73,43 +72,43 @@ func TestApiKeyCommand(t *testing.T) { assert.Len(t, listedAPIKeys, 5) - assert.Equal(t, uint64(1), listedAPIKeys[0].GetId()) - assert.Equal(t, uint64(2), listedAPIKeys[1].GetId()) - assert.Equal(t, uint64(3), listedAPIKeys[2].GetId()) - assert.Equal(t, uint64(4), listedAPIKeys[3].GetId()) - assert.Equal(t, uint64(5), listedAPIKeys[4].GetId()) + assert.Equal(t, "1", listedAPIKeys[0].Id) + assert.Equal(t, "2", listedAPIKeys[1].Id) + assert.Equal(t, "3", listedAPIKeys[2].Id) + assert.Equal(t, "4", listedAPIKeys[3].Id) + assert.Equal(t, "5", listedAPIKeys[4].Id) - assert.NotEmpty(t, listedAPIKeys[0].GetPrefix()) - assert.NotEmpty(t, listedAPIKeys[1].GetPrefix()) - assert.NotEmpty(t, listedAPIKeys[2].GetPrefix()) - assert.NotEmpty(t, listedAPIKeys[3].GetPrefix()) - assert.NotEmpty(t, listedAPIKeys[4].GetPrefix()) + assert.NotEmpty(t, listedAPIKeys[0].Prefix) + assert.NotEmpty(t, listedAPIKeys[1].Prefix) + assert.NotEmpty(t, listedAPIKeys[2].Prefix) + assert.NotEmpty(t, listedAPIKeys[3].Prefix) + assert.NotEmpty(t, listedAPIKeys[4].Prefix) - assert.True(t, listedAPIKeys[0].GetExpiration().AsTime().After(time.Now())) - assert.True(t, listedAPIKeys[1].GetExpiration().AsTime().After(time.Now())) - assert.True(t, listedAPIKeys[2].GetExpiration().AsTime().After(time.Now())) - assert.True(t, listedAPIKeys[3].GetExpiration().AsTime().After(time.Now())) - assert.True(t, listedAPIKeys[4].GetExpiration().AsTime().After(time.Now())) + assert.True(t, listedAPIKeys[0].Expiration.After(time.Now())) + assert.True(t, listedAPIKeys[1].Expiration.After(time.Now())) + assert.True(t, listedAPIKeys[2].Expiration.After(time.Now())) + assert.True(t, listedAPIKeys[3].Expiration.After(time.Now())) + assert.True(t, listedAPIKeys[4].Expiration.After(time.Now())) assert.True( t, - listedAPIKeys[0].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), + listedAPIKeys[0].Expiration.Before(time.Now().Add(time.Hour*26)), ) assert.True( t, - listedAPIKeys[1].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), + listedAPIKeys[1].Expiration.Before(time.Now().Add(time.Hour*26)), ) assert.True( t, - listedAPIKeys[2].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), + listedAPIKeys[2].Expiration.Before(time.Now().Add(time.Hour*26)), ) assert.True( t, - listedAPIKeys[3].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), + listedAPIKeys[3].Expiration.Before(time.Now().Add(time.Hour*26)), ) assert.True( t, - listedAPIKeys[4].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), + listedAPIKeys[4].Expiration.Before(time.Now().Add(time.Hour*26)), ) expiredPrefixes := make(map[string]bool) @@ -122,15 +121,15 @@ func TestApiKeyCommand(t *testing.T) { "apikeys", "expire", "--prefix", - listedAPIKeys[idx].GetPrefix(), + listedAPIKeys[idx].Prefix, }, ) require.NoError(t, err) - expiredPrefixes[listedAPIKeys[idx].GetPrefix()] = true + expiredPrefixes[listedAPIKeys[idx].Prefix] = true } - var listedAfterExpireAPIKeys []v1.ApiKey + var listedAfterExpireAPIKeys []clientv1.ApiKey assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal(headscale, @@ -147,17 +146,17 @@ func TestApiKeyCommand(t *testing.T) { }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for API keys list after expire") for index := range listedAfterExpireAPIKeys { - if _, ok := expiredPrefixes[listedAfterExpireAPIKeys[index].GetPrefix()]; ok { + if _, ok := expiredPrefixes[listedAfterExpireAPIKeys[index].Prefix]; ok { // Expired assert.True( t, - listedAfterExpireAPIKeys[index].GetExpiration().AsTime().Before(time.Now()), + listedAfterExpireAPIKeys[index].Expiration.Before(time.Now()), ) } else { // Not expired assert.False( t, - listedAfterExpireAPIKeys[index].GetExpiration().AsTime().Before(time.Now()), + listedAfterExpireAPIKeys[index].Expiration.Before(time.Now()), ) } } @@ -168,11 +167,11 @@ func TestApiKeyCommand(t *testing.T) { "apikeys", "delete", "--prefix", - listedAPIKeys[0].GetPrefix(), + listedAPIKeys[0].Prefix, }) require.NoError(t, err) - var listedAPIKeysAfterDelete []v1.ApiKey + var listedAPIKeysAfterDelete []clientv1.ApiKey assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal(headscale, @@ -197,11 +196,11 @@ func TestApiKeyCommand(t *testing.T) { "apikeys", "expire", "--id", - strconv.FormatUint(listedAPIKeysAfterDelete[0].GetId(), 10), + listedAPIKeysAfterDelete[0].Id, }) require.NoError(t, err) - var listedAPIKeysAfterExpireByID []v1.ApiKey + var listedAPIKeysAfterExpireByID []clientv1.ApiKey assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal(headscale, @@ -219,25 +218,25 @@ func TestApiKeyCommand(t *testing.T) { // Verify the key was expired for idx := range listedAPIKeysAfterExpireByID { - if listedAPIKeysAfterExpireByID[idx].GetId() == listedAPIKeysAfterDelete[0].GetId() { - assert.True(t, listedAPIKeysAfterExpireByID[idx].GetExpiration().AsTime().Before(time.Now()), + if listedAPIKeysAfterExpireByID[idx].Id == listedAPIKeysAfterDelete[0].Id { + assert.True(t, listedAPIKeysAfterExpireByID[idx].Expiration.Before(time.Now()), "Key expired by ID should have expiration in the past") } } // Test delete by ID (using key at index 1) - deletedKeyID := listedAPIKeysAfterExpireByID[1].GetId() + deletedKeyID := listedAPIKeysAfterExpireByID[1].Id _, err = headscale.Execute( []string{ "headscale", "apikeys", "delete", "--id", - strconv.FormatUint(deletedKeyID, 10), + deletedKeyID, }) require.NoError(t, err) - var listedAPIKeysAfterDeleteByID []v1.ApiKey + var listedAPIKeysAfterDeleteByID []clientv1.ApiKey assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal(headscale, @@ -257,7 +256,7 @@ func TestApiKeyCommand(t *testing.T) { // Verify the specific key was deleted for idx := range listedAPIKeysAfterDeleteByID { - assert.NotEqual(t, deletedKeyID, listedAPIKeysAfterDeleteByID[idx].GetId(), + assert.NotEqual(t, deletedKeyID, listedAPIKeysAfterDeleteByID[idx].Id, "Deleted key should not be present in the list") } } @@ -277,7 +276,7 @@ func TestApiKeyCommandValidation(t *testing.T) { _, err := headscale.Execute([]string{"headscale", "apikeys", "create", "--output", "json"}) require.NoError(t, err) - var listed []v1.ApiKey + var listed []clientv1.ApiKey assert.EventuallyWithT(t, func(c *assert.CollectT) { err := executeAndUnmarshal(headscale, @@ -288,8 +287,8 @@ func TestApiKeyCommandValidation(t *testing.T) { assert.Len(c, listed, 1) }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for API key list") - prefix := listed[0].GetPrefix() - id := strconv.FormatUint(listed[0].GetId(), 10) + prefix := listed[0].Prefix + id := listed[0].Id tests := []struct { name string diff --git a/integration/cli_nodes_test.go b/integration/cli_nodes_test.go index 4ea21e4e5..bbf02331f 100644 --- a/integration/cli_nodes_test.go +++ b/integration/cli_nodes_test.go @@ -2,12 +2,11 @@ package integration import ( "fmt" - "strconv" "strings" "testing" "time" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/integration/hsic" @@ -43,7 +42,7 @@ func TestNodeCommand(t *testing.T) { types.MustAuthID().String(), types.MustAuthID().String(), } - nodes := make([]*v1.Node, len(regIDs)) + nodes := make([]*clientv1.Node, len(regIDs)) require.NoError(t, err) @@ -65,7 +64,7 @@ func TestNodeCommand(t *testing.T) { ) require.NoError(t, err) - var node v1.Node + var node clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal( @@ -94,7 +93,7 @@ func TestNodeCommand(t *testing.T) { }, integrationutil.ScaledTimeout(15*time.Second), 1*time.Second) // Test list all nodes after added seconds - var listAll []v1.Node + var listAll []clientv1.Node assert.EventuallyWithT(t, func(ct *assert.CollectT) { err := executeAndUnmarshal( @@ -112,23 +111,23 @@ func TestNodeCommand(t *testing.T) { assert.Len(ct, listAll, len(regIDs), "Should list all nodes after CLI operations") }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) - assert.Equal(t, uint64(1), listAll[0].GetId()) - assert.Equal(t, uint64(2), listAll[1].GetId()) - assert.Equal(t, uint64(3), listAll[2].GetId()) - assert.Equal(t, uint64(4), listAll[3].GetId()) - assert.Equal(t, uint64(5), listAll[4].GetId()) + assert.Equal(t, "1", listAll[0].Id) + assert.Equal(t, "2", listAll[1].Id) + assert.Equal(t, "3", listAll[2].Id) + assert.Equal(t, "4", listAll[3].Id) + assert.Equal(t, "5", listAll[4].Id) - assert.Equal(t, "node-1", listAll[0].GetName()) - assert.Equal(t, "node-2", listAll[1].GetName()) - assert.Equal(t, "node-3", listAll[2].GetName()) - assert.Equal(t, "node-4", listAll[3].GetName()) - assert.Equal(t, "node-5", listAll[4].GetName()) + assert.Equal(t, "node-1", listAll[0].Name) + assert.Equal(t, "node-2", listAll[1].Name) + assert.Equal(t, "node-3", listAll[2].Name) + assert.Equal(t, "node-4", listAll[3].Name) + assert.Equal(t, "node-5", listAll[4].Name) otherUserRegIDs := []string{ types.MustAuthID().String(), types.MustAuthID().String(), } - otherUserMachines := make([]*v1.Node, len(otherUserRegIDs)) + otherUserMachines := make([]*clientv1.Node, len(otherUserRegIDs)) require.NoError(t, err) @@ -150,7 +149,7 @@ func TestNodeCommand(t *testing.T) { ) require.NoError(t, err) - var node v1.Node + var node clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal( @@ -179,7 +178,7 @@ func TestNodeCommand(t *testing.T) { }, integrationutil.ScaledTimeout(15*time.Second), 1*time.Second) // Test list all nodes after added otherUser - var listAllWithotherUser []v1.Node + var listAllWithotherUser []clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal( @@ -199,14 +198,14 @@ func TestNodeCommand(t *testing.T) { // All nodes, nodes + otherUser assert.Len(t, listAllWithotherUser, 7) - assert.Equal(t, uint64(6), listAllWithotherUser[5].GetId()) - assert.Equal(t, uint64(7), listAllWithotherUser[6].GetId()) + assert.Equal(t, "6", listAllWithotherUser[5].Id) + assert.Equal(t, "7", listAllWithotherUser[6].Id) - assert.Equal(t, "otheruser-node-1", listAllWithotherUser[5].GetName()) - assert.Equal(t, "otheruser-node-2", listAllWithotherUser[6].GetName()) + assert.Equal(t, "otheruser-node-1", listAllWithotherUser[5].Name) + assert.Equal(t, "otheruser-node-2", listAllWithotherUser[6].Name) // Test list all nodes after added otherUser - var listOnlyotherUserMachineUser []v1.Node + var listOnlyotherUserMachineUser []clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal( @@ -227,18 +226,18 @@ func TestNodeCommand(t *testing.T) { assert.Len(t, listOnlyotherUserMachineUser, 2) - assert.Equal(t, uint64(6), listOnlyotherUserMachineUser[0].GetId()) - assert.Equal(t, uint64(7), listOnlyotherUserMachineUser[1].GetId()) + assert.Equal(t, "6", listOnlyotherUserMachineUser[0].Id) + assert.Equal(t, "7", listOnlyotherUserMachineUser[1].Id) assert.Equal( t, "otheruser-node-1", - listOnlyotherUserMachineUser[0].GetName(), + listOnlyotherUserMachineUser[0].Name, ) assert.Equal( t, "otheruser-node-2", - listOnlyotherUserMachineUser[1].GetName(), + listOnlyotherUserMachineUser[1].Name, ) // Delete a nodes @@ -258,7 +257,7 @@ func TestNodeCommand(t *testing.T) { require.NoError(t, err) // Test: list main user after node is deleted - var listOnlyMachineUserAfterDelete []v1.Node + var listOnlyMachineUserAfterDelete []clientv1.Node assert.EventuallyWithT(t, func(ct *assert.CollectT) { err := executeAndUnmarshal( @@ -304,7 +303,7 @@ func TestNodeExpireCommand(t *testing.T) { types.MustAuthID().String(), types.MustAuthID().String(), } - nodes := make([]*v1.Node, len(regIDs)) + nodes := make([]*clientv1.Node, len(regIDs)) for index, regID := range regIDs { _, err := headscale.Execute( @@ -324,7 +323,7 @@ func TestNodeExpireCommand(t *testing.T) { ) require.NoError(t, err) - var node v1.Node + var node clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal( @@ -350,7 +349,7 @@ func TestNodeExpireCommand(t *testing.T) { assert.Len(t, nodes, len(regIDs)) - var listAll []v1.Node + var listAll []clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal( @@ -372,7 +371,7 @@ func TestNodeExpireCommand(t *testing.T) { // With node.expiry defaulting to 0, non-tagged nodes have zero expiry // (never expire unless explicitly expired). for i := range 5 { - assert.True(t, listAll[i].GetExpiry().AsTime().IsZero(), + assert.True(t, listAll[i].Expiry == nil || listAll[i].Expiry.IsZero(), "node %d should have zero expiry (no default node.expiry)", i) } @@ -383,13 +382,13 @@ func TestNodeExpireCommand(t *testing.T) { "nodes", "expire", "--identifier", - strconv.FormatUint(listAll[idx].GetId(), 10), + listAll[idx].Id, }, ) require.NoError(t, err) } - var listAllAfterExpiry []v1.Node + var listAllAfterExpiry []clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal( @@ -408,11 +407,14 @@ func TestNodeExpireCommand(t *testing.T) { assert.Len(t, listAllAfterExpiry, 5) - assert.True(t, listAllAfterExpiry[0].GetExpiry().AsTime().Before(time.Now())) - assert.True(t, listAllAfterExpiry[1].GetExpiry().AsTime().Before(time.Now())) - assert.True(t, listAllAfterExpiry[2].GetExpiry().AsTime().Before(time.Now())) - assert.True(t, listAllAfterExpiry[3].GetExpiry().AsTime().IsZero()) - assert.True(t, listAllAfterExpiry[4].GetExpiry().AsTime().IsZero()) + require.NotNil(t, listAllAfterExpiry[0].Expiry) + require.NotNil(t, listAllAfterExpiry[1].Expiry) + require.NotNil(t, listAllAfterExpiry[2].Expiry) + assert.True(t, listAllAfterExpiry[0].Expiry.Before(time.Now())) + assert.True(t, listAllAfterExpiry[1].Expiry.Before(time.Now())) + assert.True(t, listAllAfterExpiry[2].Expiry.Before(time.Now())) + assert.True(t, listAllAfterExpiry[3].Expiry == nil || listAllAfterExpiry[3].Expiry.IsZero()) + assert.True(t, listAllAfterExpiry[4].Expiry == nil || listAllAfterExpiry[4].Expiry.IsZero()) } func TestNodeRenameCommand(t *testing.T) { @@ -440,7 +442,7 @@ func TestNodeRenameCommand(t *testing.T) { types.MustAuthID().String(), types.MustAuthID().String(), } - nodes := make([]*v1.Node, len(regIDs)) + nodes := make([]*clientv1.Node, len(regIDs)) require.NoError(t, err) @@ -462,7 +464,7 @@ func TestNodeRenameCommand(t *testing.T) { ) require.NoError(t, err) - var node v1.Node + var node clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal( @@ -488,7 +490,7 @@ func TestNodeRenameCommand(t *testing.T) { assert.Len(t, nodes, len(regIDs)) - var listAll []v1.Node + var listAll []clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal( @@ -507,11 +509,11 @@ func TestNodeRenameCommand(t *testing.T) { assert.Len(t, listAll, 5) - assert.Contains(t, listAll[0].GetGivenName(), "node-1") - assert.Contains(t, listAll[1].GetGivenName(), "node-2") - assert.Contains(t, listAll[2].GetGivenName(), "node-3") - assert.Contains(t, listAll[3].GetGivenName(), "node-4") - assert.Contains(t, listAll[4].GetGivenName(), "node-5") + assert.Contains(t, listAll[0].GivenName, "node-1") + assert.Contains(t, listAll[1].GivenName, "node-2") + assert.Contains(t, listAll[2].GivenName, "node-3") + assert.Contains(t, listAll[3].GivenName, "node-4") + assert.Contains(t, listAll[4].GivenName, "node-5") for idx := range 3 { res, err := headscale.Execute( @@ -520,7 +522,7 @@ func TestNodeRenameCommand(t *testing.T) { "nodes", "rename", "--identifier", - strconv.FormatUint(listAll[idx].GetId(), 10), + listAll[idx].Id, fmt.Sprintf("newnode-%d", idx+1), }, ) @@ -529,7 +531,7 @@ func TestNodeRenameCommand(t *testing.T) { assert.Contains(t, res, "Node renamed") } - var listAllAfterRename []v1.Node + var listAllAfterRename []clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal( @@ -548,11 +550,11 @@ func TestNodeRenameCommand(t *testing.T) { assert.Len(t, listAllAfterRename, 5) - assert.Equal(t, "newnode-1", listAllAfterRename[0].GetGivenName()) - assert.Equal(t, "newnode-2", listAllAfterRename[1].GetGivenName()) - assert.Equal(t, "newnode-3", listAllAfterRename[2].GetGivenName()) - assert.Contains(t, listAllAfterRename[3].GetGivenName(), "node-4") - assert.Contains(t, listAllAfterRename[4].GetGivenName(), "node-5") + assert.Equal(t, "newnode-1", listAllAfterRename[0].GivenName) + assert.Equal(t, "newnode-2", listAllAfterRename[1].GivenName) + assert.Equal(t, "newnode-3", listAllAfterRename[2].GivenName) + assert.Contains(t, listAllAfterRename[3].GivenName, "node-4") + assert.Contains(t, listAllAfterRename[4].GivenName, "node-5") // Test failure for too long names _, err = headscale.Execute( @@ -561,13 +563,13 @@ func TestNodeRenameCommand(t *testing.T) { "nodes", "rename", "--identifier", - strconv.FormatUint(listAll[4].GetId(), 10), + listAll[4].Id, strings.Repeat("t", 64), }, ) require.ErrorContains(t, err, "is too long, max length is 63 bytes") - var listAllAfterRenameAttempt []v1.Node + var listAllAfterRenameAttempt []clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal( @@ -586,11 +588,11 @@ func TestNodeRenameCommand(t *testing.T) { assert.Len(t, listAllAfterRenameAttempt, 5) - assert.Equal(t, "newnode-1", listAllAfterRenameAttempt[0].GetGivenName()) - assert.Equal(t, "newnode-2", listAllAfterRenameAttempt[1].GetGivenName()) - assert.Equal(t, "newnode-3", listAllAfterRenameAttempt[2].GetGivenName()) - assert.Contains(t, listAllAfterRenameAttempt[3].GetGivenName(), "node-4") - assert.Contains(t, listAllAfterRenameAttempt[4].GetGivenName(), "node-5") + assert.Equal(t, "newnode-1", listAllAfterRenameAttempt[0].GivenName) + assert.Equal(t, "newnode-2", listAllAfterRenameAttempt[1].GivenName) + assert.Equal(t, "newnode-3", listAllAfterRenameAttempt[2].GivenName) + assert.Contains(t, listAllAfterRenameAttempt[3].GivenName, "node-4") + assert.Contains(t, listAllAfterRenameAttempt[4].GivenName, "node-5") } func TestPreAuthKeyCorrectUserLoggedInCommand(t *testing.T) { @@ -623,7 +625,7 @@ func TestPreAuthKeyCorrectUserLoggedInCommand(t *testing.T) { u2, err := headscale.CreateUser(user2) require.NoError(t, err) - var user2Key v1.PreAuthKey + var user2Key clientv1.PreAuthKey assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal( @@ -632,7 +634,7 @@ func TestPreAuthKeyCorrectUserLoggedInCommand(t *testing.T) { "headscale", "preauthkeys", "--user", - strconv.FormatUint(u2.GetId(), 10), + u2.Id, "create", "--reusable", "--expiration", @@ -647,7 +649,7 @@ func TestPreAuthKeyCorrectUserLoggedInCommand(t *testing.T) { assert.NoError(c, err) }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for user2 preauth key creation") - var listNodes []*v1.Node + var listNodes []*clientv1.Node assert.EventuallyWithT(t, func(ct *assert.CollectT) { var err error @@ -655,7 +657,7 @@ func TestPreAuthKeyCorrectUserLoggedInCommand(t *testing.T) { listNodes, err = headscale.ListNodes() assert.NoError(ct, err) assert.Len(ct, listNodes, 1, "Should have exactly 1 node for user1") - assert.Equal(ct, user1, listNodes[0].GetUser().GetName(), "Node should belong to user1") + assert.Equal(ct, user1, listNodes[0].User.Name, "Node should belong to user1") }, integrationutil.ScaledTimeout(15*time.Second), 1*time.Second) allClients, err := scenario.ListTailscaleClients() @@ -679,7 +681,7 @@ func TestPreAuthKeyCorrectUserLoggedInCommand(t *testing.T) { "Expected node to be logged out, backend state: %s", status.BackendState) }, integrationutil.StatusReadyTimeout, 2*time.Second) - err = client.Login(headscale.GetEndpoint(), user2Key.GetKey()) + err = client.Login(headscale.GetEndpoint(), user2Key.Key) require.NoError(t, err) assert.EventuallyWithT(t, func(ct *assert.CollectT) { @@ -697,9 +699,9 @@ func TestPreAuthKeyCorrectUserLoggedInCommand(t *testing.T) { listNodes, err = headscale.ListNodes() assert.NoError(ct, err) assert.Len(ct, listNodes, 2, "Should have 2 nodes after re-login") - assert.Equal(ct, user1, listNodes[0].GetUser().GetName(), "First node should belong to user1") + assert.Equal(ct, user1, listNodes[0].User.Name, "First node should belong to user1") // Second node is tagged (created with tagged PreAuthKey), so it shows as "tagged-devices" - assert.Equal(ct, "tagged-devices", listNodes[1].GetUser().GetName(), "Second node should be tagged-devices") + assert.Equal(ct, "tagged-devices", listNodes[1].User.Name, "Second node should be tagged-devices") }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) } @@ -731,7 +733,7 @@ func TestTaggedNodesCLIOutput(t *testing.T) { u2, err := headscale.CreateUser(user2) require.NoError(t, err) - var user2Key v1.PreAuthKey + var user2Key clientv1.PreAuthKey // Create a tagged PreAuthKey for user2 assert.EventuallyWithT(t, func(c *assert.CollectT) { @@ -741,7 +743,7 @@ func TestTaggedNodesCLIOutput(t *testing.T) { "headscale", "preauthkeys", "--user", - strconv.FormatUint(u2.GetId(), 10), + u2.Id, "create", "--reusable", "--expiration", @@ -778,7 +780,7 @@ func TestTaggedNodesCLIOutput(t *testing.T) { }, integrationutil.StatusReadyTimeout, 2*time.Second) // Log in with the tagged PreAuthKey (from user2, with tags) - err = client.Login(headscale.GetEndpoint(), user2Key.GetKey()) + err = client.Login(headscale.GetEndpoint(), user2Key.Key) require.NoError(t, err) assert.EventuallyWithT(t, func(ct *assert.CollectT) { @@ -790,7 +792,7 @@ func TestTaggedNodesCLIOutput(t *testing.T) { }, integrationutil.StatusReadyTimeout, 2*time.Second) // Wait for the second node to appear - var listNodes []*v1.Node + var listNodes []*clientv1.Node assert.EventuallyWithT(t, func(ct *assert.CollectT) { var err error @@ -798,8 +800,8 @@ func TestTaggedNodesCLIOutput(t *testing.T) { listNodes, err = headscale.ListNodes() assert.NoError(ct, err) assert.Len(ct, listNodes, 2, "Should have 2 nodes after re-login with tagged key") - assert.Equal(ct, user1, listNodes[0].GetUser().GetName(), "First node should belong to user1") - assert.Equal(ct, "tagged-devices", listNodes[1].GetUser().GetName(), "Second node should be tagged-devices") + assert.Equal(ct, user1, listNodes[0].User.Name, "First node should belong to user1") + assert.Equal(ct, "tagged-devices", listNodes[1].User.Name, "Second node should be tagged-devices") }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) // Test: tailscale status output should show "tagged-devices" not "userid:2147455555" @@ -838,7 +840,7 @@ func TestNodeExpireFlagsCommand(t *testing.T) { }) require.NoError(t, err) - var node v1.Node + var node clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { err := executeAndUnmarshal(headscale, @@ -853,14 +855,14 @@ func TestNodeExpireFlagsCommand(t *testing.T) { assert.NoError(c, err) }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for node registration") - nodeID := strconv.FormatUint(node.GetId(), 10) + nodeID := node.Id // listNodeByID returns the node with the given id from `nodes list`. The // expire mutations are verified by reading the node back (authoritative, // eventually-consistent) rather than trusting the mutation's immediate // response. - listNodeByID := func(ct *assert.CollectT) *v1.Node { - var nodes []v1.Node + listNodeByID := func(ct *assert.CollectT) *clientv1.Node { + var nodes []clientv1.Node err := executeAndUnmarshal(headscale, []string{"headscale", "nodes", "list", "--output", "json"}, @@ -869,7 +871,7 @@ func TestNodeExpireFlagsCommand(t *testing.T) { require.NoError(ct, err) for i := range nodes { - if nodes[i].GetId() == node.GetId() { + if nodes[i].Id == node.Id { return &nodes[i] } } @@ -896,8 +898,8 @@ func TestNodeExpireFlagsCommand(t *testing.T) { return } - assert.False(ct, n.GetExpiry().AsTime().IsZero(), "expiry should be set") - assert.True(ct, n.GetExpiry().AsTime().After(time.Now()), "expiry should be in the future") + assert.True(ct, n.Expiry != nil && !n.Expiry.IsZero(), "expiry should be set") + assert.True(ct, n.Expiry != nil && n.Expiry.After(time.Now()), "expiry should be in the future") }, integrationutil.ScaledTimeout(15*time.Second), 1*time.Second, "Waiting for future expiry to apply") // Disable expiry entirely; the node should then report no expiry. @@ -919,7 +921,7 @@ func TestNodeExpireFlagsCommand(t *testing.T) { // future — it never expires. A nil expiry deserialises to the Unix // epoch rather than the zero time, so assert "not in the future" // rather than IsZero. - assert.False(ct, n.GetExpiry().AsTime().After(time.Now()), + assert.False(ct, n.Expiry != nil && n.Expiry.After(time.Now()), "disabled node should not have a future expiry") }, integrationutil.ScaledTimeout(15*time.Second), 1*time.Second, "Waiting for --disable to clear expiry") } @@ -943,7 +945,7 @@ func TestNodeCommandValidation(t *testing.T) { }) require.NoError(t, err) - var node v1.Node + var node clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { err := executeAndUnmarshal(headscale, @@ -953,7 +955,7 @@ func TestNodeCommandValidation(t *testing.T) { assert.NoError(c, err) }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for node registration") - id := strconv.FormatUint(node.GetId(), 10) + id := node.Id // wantErr is matched with ErrorContains; an empty wantErr only requires // that the command fails (used where the exact message is not load-bearing). @@ -977,6 +979,10 @@ func TestNodeCommandValidation(t *testing.T) { {"approve missing identifier", []string{"nodes", "approve-routes", "--routes", "10.0.0.0/24"}, "identifier"}, {"approve nonexistent", []string{"nodes", "approve-routes", "--identifier", "99999", "--routes", "10.0.0.0/24"}, ""}, {"approve invalid cidr", []string{"nodes", "approve-routes", "--identifier", id, "--routes", "notacidr"}, "parsing route"}, + // The deprecated `nodes register` alias drives its own RegisterNode path; + // cover its error cases (the happy path is covered via `auth register`). + {"register nonexistent user", []string{"nodes", "register", "--user", "ghost", "--key", types.MustAuthID().String()}, ""}, + {"register invalid key", []string{"nodes", "register", "--user", "user1", "--key", "badkey"}, ""}, } for _, tt := range tests { @@ -1041,7 +1047,7 @@ func TestNodeTagCommand(t *testing.T) { require.NoError(t, scenario.WaitForTailscaleSync()) - var nodeID uint64 + var nodeID string assert.EventuallyWithT(t, func(ct *assert.CollectT) { nodes, err := headscale.ListNodes() @@ -1049,23 +1055,23 @@ func TestNodeTagCommand(t *testing.T) { assert.Len(ct, nodes, 1) if len(nodes) == 1 { - nodeID = nodes[0].GetId() - assert.Equal(ct, "user1", nodes[0].GetUser().GetName(), "node should start user-owned") + nodeID = nodes[0].Id + assert.Equal(ct, "user1", nodes[0].User.Name, "node should start user-owned") } }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) - idStr := strconv.FormatUint(nodeID, 10) + idStr := nodeID // Set two tags. The command response is round-tripped (transport check); // the resulting tag state is asserted via the authoritative list read-back // below rather than the immediate mutation response. - tagged := assertJSONRoundtrip[*v1.Node](t, headscale, []string{ + tagged := assertJSONRoundtrip[*clientv1.Node](t, headscale, []string{ "headscale", "nodes", "tag", "--identifier", idStr, "--tags", "tag:test1,tag:test2", "--output", "json", }) - assert.Equal(t, nodeID, tagged.GetId(), "tag response should be for the same node") + assert.Equal(t, nodeID, tagged.Id, "tag response should be for the same node") // The node is now a tagged node, presented as the tagged-devices user. assert.EventuallyWithT(t, func(ct *assert.CollectT) { @@ -1074,8 +1080,8 @@ func TestNodeTagCommand(t *testing.T) { assert.Len(ct, nodes, 1) if len(nodes) == 1 { - assert.Equal(ct, "tagged-devices", nodes[0].GetUser().GetName(), "tagged node shows as tagged-devices") - assert.ElementsMatch(ct, []string{"tag:test1", "tag:test2"}, nodes[0].GetTags()) + assert.Equal(ct, "tagged-devices", nodes[0].User.Name, "tagged node shows as tagged-devices") + assert.ElementsMatch(ct, []string{"tag:test1", "tag:test2"}, nodes[0].Tags) } }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) @@ -1135,10 +1141,10 @@ func TestNodeRouteCommands(t *testing.T) { require.NoError(t, scenario.WaitForTailscaleSync()) // The advertised route should show up as available (but not approved). - var nodeID uint64 + var nodeID string assert.EventuallyWithT(t, func(ct *assert.CollectT) { - var nodes []v1.Node + var nodes []clientv1.Node err := executeAndUnmarshal(headscale, []string{"headscale", "nodes", "list-routes", "--output", "json"}, @@ -1148,27 +1154,27 @@ func TestNodeRouteCommands(t *testing.T) { assert.Len(ct, nodes, 1, "list-routes should show the route-advertising node") if len(nodes) == 1 { - nodeID = nodes[0].GetId() - assert.Contains(ct, nodes[0].GetAvailableRoutes(), route) - assert.Empty(ct, nodes[0].GetApprovedRoutes()) + nodeID = nodes[0].Id + assert.Contains(ct, nodes[0].AvailableRoutes, route) + assert.Empty(ct, nodes[0].ApprovedRoutes) } }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) - idStr := strconv.FormatUint(nodeID, 10) + idStr := nodeID // Approve the route via the CLI. - approved := assertJSONRoundtrip[*v1.Node](t, headscale, []string{ + approved := assertJSONRoundtrip[*clientv1.Node](t, headscale, []string{ "headscale", "nodes", "approve-routes", "--identifier", idStr, "--routes=" + route, "--output", "json", }) - assert.Contains(t, approved.GetApprovedRoutes(), route) + assert.Contains(t, approved.ApprovedRoutes, route) // list-routes filtered by the identifier should report the approved route // as a primary subnet route. assert.EventuallyWithT(t, func(ct *assert.CollectT) { - var nodes []v1.Node + var nodes []clientv1.Node err := executeAndUnmarshal(headscale, []string{"headscale", "nodes", "list-routes", "--identifier", idStr, "--output", "json"}, @@ -1178,19 +1184,19 @@ func TestNodeRouteCommands(t *testing.T) { assert.Len(ct, nodes, 1) if len(nodes) == 1 { - assert.Contains(ct, nodes[0].GetApprovedRoutes(), route) - assert.Contains(ct, nodes[0].GetSubnetRoutes(), route) + assert.Contains(ct, nodes[0].ApprovedRoutes, route) + assert.Contains(ct, nodes[0].SubnetRoutes, route) } }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) // Remove all approved routes by passing an empty --routes value. - cleared := assertJSONRoundtrip[*v1.Node](t, headscale, []string{ + cleared := assertJSONRoundtrip[*clientv1.Node](t, headscale, []string{ "headscale", "nodes", "approve-routes", "--identifier", idStr, "--routes=", "--output", "json", }) - assert.Empty(t, cleared.GetApprovedRoutes(), "approved routes should be cleared") + assert.Empty(t, cleared.ApprovedRoutes, "approved routes should be cleared") } // TestNodeBackfillIPsCommand exercises `nodes backfillips` against live nodes. @@ -1205,7 +1211,7 @@ func TestNodeBackfillIPsCommand(t *testing.T) { require.NoError(t, scenario.WaitForTailscaleSync()) - var before []*v1.Node + var before []*clientv1.Node assert.EventuallyWithT(t, func(ct *assert.CollectT) { var err error @@ -1215,7 +1221,7 @@ func TestNodeBackfillIPsCommand(t *testing.T) { assert.Len(ct, before, 2) for _, n := range before { - assert.NotEmpty(ct, n.GetIpAddresses(), "node should have IPs before backfill") + assert.NotEmpty(ct, n.IpAddresses, "node should have IPs before backfill") } }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) @@ -1230,7 +1236,7 @@ func TestNodeBackfillIPsCommand(t *testing.T) { assert.Len(ct, after, 2) for _, n := range after { - assert.NotEmpty(ct, n.GetIpAddresses(), "node should still have IPs after backfill") + assert.NotEmpty(ct, n.IpAddresses, "node should still have IPs after backfill") } }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) } diff --git a/integration/cli_policy_test.go b/integration/cli_policy_test.go index 7c8eb0bf8..20e003ed3 100644 --- a/integration/cli_policy_test.go +++ b/integration/cli_policy_test.go @@ -152,7 +152,7 @@ func TestPolicyCheckCommand(t *testing.T) { // --force suppresses the "is the server running?" // confirmation prompt so the command can run // non-interactively under the test harness. - cmd = append(cmd, "--bypass-grpc-and-access-database-directly", "--force") + cmd = append(cmd, "--bypass-server-and-access-database-directly", "--force") } stdout, err := headscale.Execute(cmd) diff --git a/integration/cli_preauthkeys_test.go b/integration/cli_preauthkeys_test.go index 63fb43cd1..75f76edad 100644 --- a/integration/cli_preauthkeys_test.go +++ b/integration/cli_preauthkeys_test.go @@ -1,11 +1,10 @@ package integration import ( - "strconv" "testing" "time" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/integration/hsic" "github.com/juanfont/headscale/integration/integrationutil" "github.com/juanfont/headscale/integration/tsic" @@ -34,12 +33,12 @@ func TestPreAuthKeyCommand(t *testing.T) { headscale, err := scenario.Headscale() require.NoError(t, err) - keys := make([]*v1.PreAuthKey, count) + keys := make([]*clientv1.PreAuthKey, count) require.NoError(t, err) for index := range count { - var preAuthKey v1.PreAuthKey + var preAuthKey clientv1.PreAuthKey assert.EventuallyWithT(t, func(c *assert.CollectT) { err := executeAndUnmarshal( @@ -68,7 +67,7 @@ func TestPreAuthKeyCommand(t *testing.T) { assert.Len(t, keys, 3) - var listedPreAuthKeys []v1.PreAuthKey + var listedPreAuthKeys []clientv1.PreAuthKey assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal( @@ -90,34 +89,34 @@ func TestPreAuthKeyCommand(t *testing.T) { assert.Equal( t, - []uint64{keys[0].GetId(), keys[1].GetId(), keys[2].GetId()}, - []uint64{ - listedPreAuthKeys[1].GetId(), - listedPreAuthKeys[2].GetId(), - listedPreAuthKeys[3].GetId(), + []string{keys[0].Id, keys[1].Id, keys[2].Id}, + []string{ + listedPreAuthKeys[1].Id, + listedPreAuthKeys[2].Id, + listedPreAuthKeys[3].Id, }, ) // New keys show prefix after listing, so check the created keys instead - assert.NotEmpty(t, keys[0].GetKey()) - assert.NotEmpty(t, keys[1].GetKey()) - assert.NotEmpty(t, keys[2].GetKey()) + assert.NotEmpty(t, keys[0].Key) + assert.NotEmpty(t, keys[1].Key) + assert.NotEmpty(t, keys[2].Key) - assert.True(t, listedPreAuthKeys[1].GetExpiration().AsTime().After(time.Now())) - assert.True(t, listedPreAuthKeys[2].GetExpiration().AsTime().After(time.Now())) - assert.True(t, listedPreAuthKeys[3].GetExpiration().AsTime().After(time.Now())) + assert.True(t, listedPreAuthKeys[1].Expiration.After(time.Now())) + assert.True(t, listedPreAuthKeys[2].Expiration.After(time.Now())) + assert.True(t, listedPreAuthKeys[3].Expiration.After(time.Now())) assert.True( t, - listedPreAuthKeys[1].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), + listedPreAuthKeys[1].Expiration.Before(time.Now().Add(time.Hour*26)), ) assert.True( t, - listedPreAuthKeys[2].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), + listedPreAuthKeys[2].Expiration.Before(time.Now().Add(time.Hour*26)), ) assert.True( t, - listedPreAuthKeys[3].GetExpiration().AsTime().Before(time.Now().Add(time.Hour*26)), + listedPreAuthKeys[3].Expiration.Before(time.Now().Add(time.Hour*26)), ) for index := range listedPreAuthKeys { @@ -128,7 +127,7 @@ func TestPreAuthKeyCommand(t *testing.T) { assert.Equal( t, []string{"tag:test1", "tag:test2"}, - listedPreAuthKeys[index].GetAclTags(), + listedPreAuthKeys[index].AclTags, ) } @@ -139,12 +138,12 @@ func TestPreAuthKeyCommand(t *testing.T) { "preauthkeys", "expire", "--id", - strconv.FormatUint(keys[0].GetId(), 10), + keys[0].Id, }, ) require.NoError(t, err) - var listedPreAuthKeysAfterExpire []v1.PreAuthKey + var listedPreAuthKeysAfterExpire []clientv1.PreAuthKey assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal( @@ -161,9 +160,9 @@ func TestPreAuthKeyCommand(t *testing.T) { assert.NoError(c, err) }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for preauth keys list after expire") - assert.True(t, listedPreAuthKeysAfterExpire[1].GetExpiration().AsTime().Before(time.Now())) - assert.True(t, listedPreAuthKeysAfterExpire[2].GetExpiration().AsTime().After(time.Now())) - assert.True(t, listedPreAuthKeysAfterExpire[3].GetExpiration().AsTime().After(time.Now())) + assert.True(t, listedPreAuthKeysAfterExpire[1].Expiration.Before(time.Now())) + assert.True(t, listedPreAuthKeysAfterExpire[2].Expiration.After(time.Now())) + assert.True(t, listedPreAuthKeysAfterExpire[3].Expiration.After(time.Now())) } func TestPreAuthKeyCommandWithoutExpiry(t *testing.T) { @@ -185,7 +184,7 @@ func TestPreAuthKeyCommandWithoutExpiry(t *testing.T) { headscale, err := scenario.Headscale() require.NoError(t, err) - var preAuthKey v1.PreAuthKey + var preAuthKey clientv1.PreAuthKey assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal( @@ -205,7 +204,7 @@ func TestPreAuthKeyCommandWithoutExpiry(t *testing.T) { assert.NoError(c, err) }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for preauth key creation without expiry") - var listedPreAuthKeys []v1.PreAuthKey + var listedPreAuthKeys []clientv1.PreAuthKey assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal( @@ -225,10 +224,10 @@ func TestPreAuthKeyCommandWithoutExpiry(t *testing.T) { // There is one key created by [Scenario.CreateHeadscaleEnv] assert.Len(t, listedPreAuthKeys, 2) - assert.True(t, listedPreAuthKeys[1].GetExpiration().AsTime().After(time.Now())) + assert.True(t, listedPreAuthKeys[1].Expiration.After(time.Now())) assert.True( t, - listedPreAuthKeys[1].GetExpiration().AsTime().Before(time.Now().Add(time.Minute*70)), + listedPreAuthKeys[1].Expiration.Before(time.Now().Add(time.Minute*70)), ) } @@ -251,7 +250,7 @@ func TestPreAuthKeyCommandReusableEphemeral(t *testing.T) { headscale, err := scenario.Headscale() require.NoError(t, err) - var preAuthReusableKey v1.PreAuthKey + var preAuthReusableKey clientv1.PreAuthKey assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal( @@ -271,7 +270,7 @@ func TestPreAuthKeyCommandReusableEphemeral(t *testing.T) { assert.NoError(c, err) }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for reusable preauth key creation") - var preAuthEphemeralKey v1.PreAuthKey + var preAuthEphemeralKey clientv1.PreAuthKey assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal( @@ -291,10 +290,10 @@ func TestPreAuthKeyCommandReusableEphemeral(t *testing.T) { assert.NoError(c, err) }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for ephemeral preauth key creation") - assert.True(t, preAuthEphemeralKey.GetEphemeral()) - assert.False(t, preAuthEphemeralKey.GetReusable()) + assert.True(t, preAuthEphemeralKey.Ephemeral) + assert.False(t, preAuthEphemeralKey.Reusable) - var listedPreAuthKeys []v1.PreAuthKey + var listedPreAuthKeys []clientv1.PreAuthKey assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal( @@ -325,7 +324,7 @@ func TestPreAuthKeyDeleteCommand(t *testing.T) { defer scenario.ShutdownAssertNoPanics(t) // Create a key to delete. - created := assertJSONRoundtrip[*v1.PreAuthKey](t, headscale, []string{ + created := assertJSONRoundtrip[*clientv1.PreAuthKey](t, headscale, []string{ "headscale", "preauthkeys", "--user", "1", @@ -333,7 +332,7 @@ func TestPreAuthKeyDeleteCommand(t *testing.T) { "--reusable", "--output", "json", }) - require.NotZero(t, created.GetId()) + require.NotEmpty(t, created.Id) // delete with no --id must be rejected. _, err := headscale.Execute([]string{"headscale", "preauthkeys", "delete"}) @@ -342,13 +341,13 @@ func TestPreAuthKeyDeleteCommand(t *testing.T) { // delete the created key by id. _, err = headscale.Execute([]string{ "headscale", "preauthkeys", "delete", - "--id", strconv.FormatUint(created.GetId(), 10), + "--id", created.Id, }) require.NoError(t, err) // The deleted key must be gone from the list. assert.EventuallyWithT(t, func(c *assert.CollectT) { - var listed []v1.PreAuthKey + var listed []clientv1.PreAuthKey err := executeAndUnmarshal(headscale, []string{"headscale", "preauthkeys", "list", "--output", "json"}, @@ -357,7 +356,7 @@ func TestPreAuthKeyDeleteCommand(t *testing.T) { assert.NoError(c, err) for i := range listed { - assert.NotEqual(c, created.GetId(), listed[i].GetId(), "deleted key should not be listed") + assert.NotEqual(c, created.Id, listed[i].Id, "deleted key should not be listed") } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for preauth key list after delete") } diff --git a/integration/cli_server_test.go b/integration/cli_server_test.go index 51842b15f..bab44896f 100644 --- a/integration/cli_server_test.go +++ b/integration/cli_server_test.go @@ -3,7 +3,7 @@ package integration import ( "testing" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -20,10 +20,10 @@ func TestServerInfoCommands(t *testing.T) { defer scenario.ShutdownAssertNoPanics(t) t.Run("health", func(t *testing.T) { - health := assertJSONRoundtrip[*v1.HealthResponse](t, headscale, []string{ + health := assertJSONRoundtrip[*clientv1.HealthResponseBody](t, headscale, []string{ "headscale", "health", "--output", "json", }) - assert.True(t, health.GetDatabaseConnectivity(), "database should be reachable") + assert.True(t, health.DatabaseConnectivity, "database should be reachable") }) t.Run("version", func(t *testing.T) { diff --git a/integration/cli_test.go b/integration/cli_test.go index 86d3f4b51..aa047e120 100644 --- a/integration/cli_test.go +++ b/integration/cli_test.go @@ -6,6 +6,7 @@ import ( "fmt" "testing" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/integration/hsic" "github.com/juanfont/headscale/integration/tsic" "github.com/stretchr/testify/require" @@ -19,8 +20,8 @@ import ( // // The whole point of the CLI test suite is to guard the transport: every // command is invoked with `--output json` and the result is unmarshalled into -// the matching gen/go/headscale/v1 Go type, so a change to the gRPC handlers, -// proto definitions or output encoders that breaks a command is caught here. +// the matching HTTP-client Go type, so a change to the API handlers, +// schema definitions or output encoders that breaks a command is caught here. func executeAndUnmarshal[T any](headscale ControlServer, command []string, result T) error { str, err := headscale.Execute(command) @@ -39,7 +40,7 @@ func executeAndUnmarshal[T any](headscale ControlServer, command []string, resul // assertJSONRoundtrip executes command (which must include `--output json`), // decodes the stdout into T, then marshals T back to JSON and re-decodes it, // asserting the serialisation is stable. This is the transport contract guard: -// if the underlying v1 type drifts in a way that loses data, the round-trip +// if the underlying type drifts in a way that loses data, the round-trip // breaks. The decoded value is returned so callers can assert on real fields. func assertJSONRoundtrip[T any](t require.TestingT, headscale ControlServer, command []string) T { var first T @@ -62,14 +63,11 @@ func assertJSONRoundtrip[T any](t require.TestingT, headscale ControlServer, com return second } -// Interface ensuring that we can sort structs from gRPC that -// have an ID field. -type GRPCSortable interface { - GetId() uint64 -} - -func sortWithID[T GRPCSortable](a, b T) int { - return cmp.Compare(a.GetId(), b.GetId()) +// sortWithID orders users by their numeric ID. The HTTP client emits IDs as +// decimal strings, so they are parsed back to integers to preserve numeric +// (not lexicographic) ordering. +func sortWithID(a, b *clientv1.User) int { + return cmp.Compare(mustParseID(a.Id), mustParseID(b.Id)) } // setupCLIScenario boots a scenario with the given users and nodes-per-user, diff --git a/integration/cli_users_test.go b/integration/cli_users_test.go index c2f95bf77..7441f431a 100644 --- a/integration/cli_users_test.go +++ b/integration/cli_users_test.go @@ -2,14 +2,13 @@ package integration import ( "encoding/json" - "fmt" "slices" "testing" "time" tcmp "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/integration/hsic" "github.com/juanfont/headscale/integration/integrationutil" "github.com/juanfont/headscale/integration/tsic" @@ -36,7 +35,7 @@ func TestUserCommand(t *testing.T) { require.NoError(t, err) var ( - listUsers []*v1.User + listUsers []*clientv1.User result []string ) @@ -54,7 +53,7 @@ func TestUserCommand(t *testing.T) { assert.NoError(ct, err) slices.SortFunc(listUsers, sortWithID) - result = []string{listUsers[0].GetName(), listUsers[1].GetName()} + result = []string{listUsers[0].Name, listUsers[1].Name} assert.Equal( ct, @@ -70,13 +69,13 @@ func TestUserCommand(t *testing.T) { "users", "rename", "--output=json", - fmt.Sprintf("--identifier=%d", listUsers[1].GetId()), + "--identifier=" + listUsers[1].Id, "--new-name=newname", }, ) require.NoError(t, err) - var listAfterRenameUsers []*v1.User + var listAfterRenameUsers []*clientv1.User assert.EventuallyWithT(t, func(ct *assert.CollectT) { err := executeAndUnmarshal(headscale, @@ -92,7 +91,7 @@ func TestUserCommand(t *testing.T) { assert.NoError(ct, err) slices.SortFunc(listAfterRenameUsers, sortWithID) - result = []string{listAfterRenameUsers[0].GetName(), listAfterRenameUsers[1].GetName()} + result = []string{listAfterRenameUsers[0].Name, listAfterRenameUsers[1].Name} assert.Equal( ct, @@ -102,7 +101,7 @@ func TestUserCommand(t *testing.T) { ) }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) - var listByUsername []*v1.User + var listByUsername []*clientv1.User assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal(headscale, @@ -121,19 +120,19 @@ func TestUserCommand(t *testing.T) { slices.SortFunc(listByUsername, sortWithID) - want := []*v1.User{ + want := []*clientv1.User{ { - Id: 1, + Id: "1", Name: "user1", Email: "user1@test.no", }, } - if diff := tcmp.Diff(want, listByUsername, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" { + if diff := tcmp.Diff(want, listByUsername, cmpopts.IgnoreUnexported(clientv1.User{}), cmpopts.IgnoreFields(clientv1.User{}, "CreatedAt")); diff != "" { t.Errorf("unexpected users (-want +got):\n%s", diff) } - var listByID []*v1.User + var listByID []*clientv1.User assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal(headscale, @@ -152,15 +151,15 @@ func TestUserCommand(t *testing.T) { slices.SortFunc(listByID, sortWithID) - want = []*v1.User{ + want = []*clientv1.User{ { - Id: 1, + Id: "1", Name: "user1", Email: "user1@test.no", }, } - if diff := tcmp.Diff(want, listByID, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" { + if diff := tcmp.Diff(want, listByID, cmpopts.IgnoreUnexported(clientv1.User{}), cmpopts.IgnoreFields(clientv1.User{}, "CreatedAt")); diff != "" { t.Errorf("unexpected users (-want +got):\n%s", diff) } @@ -177,7 +176,7 @@ func TestUserCommand(t *testing.T) { require.NoError(t, err) assert.Contains(t, deleteResult, "User destroyed") - var listAfterIDDelete []*v1.User + var listAfterIDDelete []*clientv1.User assert.EventuallyWithT(t, func(ct *assert.CollectT) { err := executeAndUnmarshal(headscale, @@ -194,15 +193,15 @@ func TestUserCommand(t *testing.T) { slices.SortFunc(listAfterIDDelete, sortWithID) - want := []*v1.User{ + want := []*clientv1.User{ { - Id: 2, + Id: "2", Name: "newname", Email: "user2@test.no", }, } - if diff := tcmp.Diff(want, listAfterIDDelete, cmpopts.IgnoreUnexported(v1.User{}), cmpopts.IgnoreFields(v1.User{}, "CreatedAt")); diff != "" { + if diff := tcmp.Diff(want, listAfterIDDelete, cmpopts.IgnoreUnexported(clientv1.User{}), cmpopts.IgnoreFields(clientv1.User{}, "CreatedAt")); diff != "" { assert.Fail(ct, "unexpected users", "diff (-want +got):\n%s", diff) } }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) @@ -219,7 +218,7 @@ func TestUserCommand(t *testing.T) { require.NoError(t, err) assert.Contains(t, deleteResult, "User destroyed") - var listAfterNameDelete []v1.User + var listAfterNameDelete []clientv1.User assert.EventuallyWithT(t, func(c *assert.CollectT) { err = executeAndUnmarshal(headscale, @@ -249,8 +248,8 @@ func TestUserCreateCommand(t *testing.T) { defer scenario.ShutdownAssertNoPanics(t) // Create a user populated with every optional field. The created user is - // returned on stdout and round-tripped through the v1.User type. - created := assertJSONRoundtrip[*v1.User](t, headscale, []string{ + // returned on stdout and round-tripped through the User type. + created := assertJSONRoundtrip[*clientv1.User](t, headscale, []string{ "headscale", "users", "create", @@ -261,14 +260,14 @@ func TestUserCreateCommand(t *testing.T) { "--output", "json", }) - assert.Equal(t, "cli-created", created.GetName()) - assert.Equal(t, "CLI Created", created.GetDisplayName()) - assert.Equal(t, "cli-created@example.com", created.GetEmail()) - assert.Equal(t, "https://example.com/avatar.png", created.GetProfilePicUrl()) + assert.Equal(t, "cli-created", created.Name) + assert.Equal(t, "CLI Created", created.DisplayName) + assert.Equal(t, "cli-created@example.com", created.Email) + assert.Equal(t, "https://example.com/avatar.png", created.ProfilePicUrl) // The created fields must survive a list query (read-after-write) and be // filterable by email. - var byEmail []*v1.User + var byEmail []*clientv1.User assert.EventuallyWithT(t, func(ct *assert.CollectT) { err := executeAndUnmarshal(headscale, @@ -286,8 +285,8 @@ func TestUserCreateCommand(t *testing.T) { }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for user list by email") require.Len(t, byEmail, 1) - assert.Equal(t, "cli-created", byEmail[0].GetName()) - assert.Equal(t, "CLI Created", byEmail[0].GetDisplayName()) + assert.Equal(t, "cli-created", byEmail[0].Name) + assert.Equal(t, "CLI Created", byEmail[0].DisplayName) } // TestUserCommandValidation exercises the validation and error permutations of @@ -325,7 +324,7 @@ func TestUserCommandValidation(t *testing.T) { case tt.wantEmptyList: require.NoError(t, err) - var users []v1.User + var users []clientv1.User require.NoError(t, json.Unmarshal([]byte(out), &users)) require.Empty(t, users) diff --git a/integration/control.go b/integration/control.go index 256cc278c..2b2fc651f 100644 --- a/integration/control.go +++ b/integration/control.go @@ -3,7 +3,7 @@ package integration import ( "net/netip" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol" policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" "github.com/juanfont/headscale/hscontrol/types" @@ -24,19 +24,19 @@ type ControlServer interface { GetEndpoint() string WaitForRunning() error Restart() error - CreateUser(user string) (*v1.User, error) - CreateAuthKey(user uint64, reusable bool, ephemeral bool) (*v1.PreAuthKey, error) - CreateAuthKeyWithTags(user uint64, reusable bool, ephemeral bool, tags []string) (*v1.PreAuthKey, error) - CreateAuthKeyWithOptions(opts hsic.AuthKeyOptions) (*v1.PreAuthKey, error) + CreateUser(user string) (*clientv1.User, error) + CreateAuthKey(user uint64, reusable bool, ephemeral bool) (*clientv1.PreAuthKey, error) + CreateAuthKeyWithTags(user uint64, reusable bool, ephemeral bool, tags []string) (*clientv1.PreAuthKey, error) + CreateAuthKeyWithOptions(opts hsic.AuthKeyOptions) (*clientv1.PreAuthKey, error) DeleteAuthKey(id uint64) error - ListNodes(users ...string) ([]*v1.Node, error) + ListNodes(users ...string) ([]*clientv1.Node, error) DeleteNode(nodeID uint64) error - NodesByUser() (map[string][]*v1.Node, error) - NodesByName() (map[string]*v1.Node, error) - ListUsers() ([]*v1.User, error) - MapUsers() (map[string]*v1.User, error) + NodesByUser() (map[string][]*clientv1.Node, error) + NodesByName() (map[string]*clientv1.Node, error) + ListUsers() ([]*clientv1.User, error) + MapUsers() (map[string]*clientv1.User, error) DeleteUser(userID uint64) error - ApproveRoutes(nodeID uint64, routes []netip.Prefix) (*v1.Node, error) + ApproveRoutes(nodeID uint64, routes []netip.Prefix) (*clientv1.Node, error) SetNodeTags(nodeID uint64, tags []string) error GetCert() []byte GetHostname() string diff --git a/integration/general_test.go b/integration/general_test.go index 27ce81979..9bdfd8c71 100644 --- a/integration/general_test.go +++ b/integration/general_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/integration/hsic" "github.com/juanfont/headscale/integration/integrationutil" @@ -169,12 +169,12 @@ func testEphemeralWithOptions(t *testing.T, opts ...hsic.Option) { t.Fatalf("failed to create tailscale nodes in user %s: %s", userName, err) } - key, err := scenario.CreatePreAuthKey(user.GetId(), true, true) + key, err := scenario.CreatePreAuthKey(mustParseID(user.Id), true, true) if err != nil { t.Fatalf("failed to create pre-auth key for user %s: %s", userName, err) } - err = scenario.RunTailscaleUp(userName, headscale.GetEndpoint(), key.GetKey()) + err = scenario.RunTailscaleUp(userName, headscale.GetEndpoint(), key.Key) if err != nil { t.Fatalf("failed to run tailscale up for user %s: %s", userName, err) } @@ -247,12 +247,12 @@ func TestEphemeral2006DeletedTooQuickly(t *testing.T) { t.Fatalf("failed to create tailscale nodes in user %s: %s", userName, err) } - key, err := scenario.CreatePreAuthKey(user.GetId(), true, true) + key, err := scenario.CreatePreAuthKey(mustParseID(user.Id), true, true) if err != nil { t.Fatalf("failed to create pre-auth key for user %s: %s", userName, err) } - err = scenario.RunTailscaleUp(userName, headscale.GetEndpoint(), key.GetKey()) + err = scenario.RunTailscaleUp(userName, headscale.GetEndpoint(), key.Key) if err != nil { t.Fatalf("failed to run tailscale up for user %s: %s", userName, err) } @@ -402,7 +402,7 @@ func TestTaildrop(t *testing.T) { network := networks[0] // Create untagged nodes for user1 using all test versions - user1Key, err := scenario.CreatePreAuthKey(userMap["user1"].GetId(), true, false) + user1Key, err := scenario.CreatePreAuthKey(mustParseID(userMap["user1"].Id), true, false) require.NoError(t, err) var user1Clients []TailscaleClient @@ -414,7 +414,7 @@ func TestTaildrop(t *testing.T) { ) require.NoError(t, err) - err = client.Login(headscale.GetEndpoint(), user1Key.GetKey()) + err = client.Login(headscale.GetEndpoint(), user1Key.Key) require.NoError(t, err) err = client.WaitForRunning(integrationutil.PeerSyncTimeout()) @@ -425,7 +425,7 @@ func TestTaildrop(t *testing.T) { } // Create untagged nodes for user2 using all test versions - user2Key, err := scenario.CreatePreAuthKey(userMap["user2"].GetId(), true, false) + user2Key, err := scenario.CreatePreAuthKey(mustParseID(userMap["user2"].Id), true, false) require.NoError(t, err) var user2Clients []TailscaleClient @@ -437,7 +437,7 @@ func TestTaildrop(t *testing.T) { ) require.NoError(t, err) - err = client.Login(headscale.GetEndpoint(), user2Key.GetKey()) + err = client.Login(headscale.GetEndpoint(), user2Key.Key) require.NoError(t, err) err = client.WaitForRunning(integrationutil.PeerSyncTimeout()) @@ -449,7 +449,7 @@ func TestTaildrop(t *testing.T) { // Create a tagged device (tags-as-identity: tags come from PreAuthKey) // Use "head" version to test latest behavior - taggedKey, err := scenario.CreatePreAuthKeyWithTags(userMap["user1"].GetId(), true, false, []string{"tag:server"}) + taggedKey, err := scenario.CreatePreAuthKeyWithTags(mustParseID(userMap["user1"].Id), true, false, []string{"tag:server"}) require.NoError(t, err) taggedClient, err := scenario.CreateTailscaleNode( @@ -458,7 +458,7 @@ func TestTaildrop(t *testing.T) { ) require.NoError(t, err) - err = taggedClient.Login(headscale.GetEndpoint(), taggedKey.GetKey()) + err = taggedClient.Login(headscale.GetEndpoint(), taggedKey.Key) require.NoError(t, err) err = taggedClient.WaitForRunning(integrationutil.PeerSyncTimeout()) @@ -767,8 +767,8 @@ func TestUpdateHostnameFromClient(t *testing.T) { // Pre-rewrite these were rejected by ApplyHostnameFromHostInfo with // "invalid characters" and the node was stuck on an invalid- // GivenName with the HostName update dropped. The assertions below - // verify both raw preservation ([v1.Node.Name]) and SaaS-matching sanitisation - // ([v1.Node.GivenName]) for each awkward input. + // verify both raw preservation ([clientv1.Node.Name]) and SaaS-matching sanitisation + // ([clientv1.Node.GivenName]) for each awkward input. hostnames := map[string]string{ "1": "Joe's Mac mini", "2": "Test@Host", @@ -814,7 +814,7 @@ func TestUpdateHostnameFromClient(t *testing.T) { // Wait for nodestore batch processing to complete // [state.NodeStore] batching timeout is 500ms, so we wait up to 1 second - var nodes []*v1.Node + var nodes []*clientv1.Node assert.EventuallyWithT(t, func(ct *assert.CollectT) { err := executeAndUnmarshal( headscale, @@ -831,18 +831,18 @@ func TestUpdateHostnameFromClient(t *testing.T) { assert.Len(ct, nodes, 3, "Should have 3 nodes after hostname updates") for _, node := range nodes { - hostname := hostnames[strconv.FormatUint(node.GetId(), 10)] - assert.Equal(ct, hostname, node.GetName(), "Node name should match hostname") + hostname := hostnames[node.Id] + assert.Equal(ct, hostname, node.Name, "Node name should match hostname") // GivenName is sanitised via [dnsname.SanitizeHostname] (SaaS algorithm). - assert.Equal(ct, dnsname.SanitizeHostname(hostname), node.GetGivenName(), + assert.Equal(ct, dnsname.SanitizeHostname(hostname), node.GivenName, "Given name should match SaaS hostname-sanitisation rules") } }, integrationutil.ScaledTimeout(20*time.Second), 1*time.Second) // Rename givenName in nodes for _, node := range nodes { - givenName := fmt.Sprintf("%d-givenname", node.GetId()) + givenName := fmt.Sprintf("%s-givenname", node.Id) _, err = headscale.Execute( []string{ "headscale", @@ -850,7 +850,7 @@ func TestUpdateHostnameFromClient(t *testing.T) { "rename", givenName, "--identifier", - strconv.FormatUint(node.GetId(), 10), + node.Id, }) require.NoError(t, err) } @@ -860,8 +860,8 @@ func TestUpdateHostnameFromClient(t *testing.T) { // Build a map of expected DNSNames by node ID expectedDNSNames := make(map[string]string) for _, node := range nodes { - nodeID := strconv.FormatUint(node.GetId(), 10) - expectedDNSNames[nodeID] = fmt.Sprintf("%d-givenname.headscale.net.", node.GetId()) + nodeID := node.Id + expectedDNSNames[nodeID] = fmt.Sprintf("%s-givenname.headscale.net.", node.Id) } // Verify from each client's perspective @@ -931,9 +931,9 @@ func TestUpdateHostnameFromClient(t *testing.T) { } for _, node := range nodes { - hostname := hostnames[strconv.FormatUint(node.GetId(), 10)] - givenName := fmt.Sprintf("%d-givenname", node.GetId()) - if node.GetName() != hostname+"NEW" || node.GetGivenName() != givenName { + hostname := hostnames[node.Id] + givenName := fmt.Sprintf("%s-givenname", node.Id) + if node.Name != hostname+"NEW" || node.GivenName != givenName { return false } } @@ -993,15 +993,15 @@ func TestExpireNode(t *testing.T) { }) require.NoError(t, err) - var node v1.Node + var node clientv1.Node err = json.Unmarshal([]byte(result), &node) require.NoError(t, err) var expiredNodeKey key.NodePublic - err = expiredNodeKey.UnmarshalText([]byte(node.GetNodeKey())) + err = expiredNodeKey.UnmarshalText([]byte(node.NodeKey)) require.NoError(t, err) - t.Logf("Node %s with node_key %s has been expired", node.GetName(), expiredNodeKey.String()) + t.Logf("Node %s with node_key %s has been expired", node.Name, expiredNodeKey.String()) // Verify that the expired node has been marked in all peers list. assert.EventuallyWithT(t, func(ct *assert.CollectT) { @@ -1009,7 +1009,7 @@ func TestExpireNode(t *testing.T) { status, err := client.Status() assert.NoError(ct, err) - if client.Hostname() != node.GetName() { + if client.Hostname() != node.Name { // Check if the expired node appears as expired in this client's peer list for key, peer := range status.Peer { if key == expiredNodeKey { @@ -1025,7 +1025,7 @@ func TestExpireNode(t *testing.T) { // Verify that the expired node has been marked in all peers list. for _, client := range allClients { - if client.Hostname() == node.GetName() { + if client.Hostname() == node.Name { continue } @@ -1060,11 +1060,11 @@ func TestExpireNode(t *testing.T) { peerStatus.Expired, ) - _, stderr, _ := client.Execute([]string{"tailscale", "ping", node.GetName()}) + _, stderr, _ := client.Execute([]string{"tailscale", "ping", node.Name}) if !strings.Contains(stderr, "node key has expired") { c.Errorf( "expected to be unable to ping expired host %q from %q", - node.GetName(), + node.Name, client.Hostname(), ) } @@ -1111,19 +1111,19 @@ func TestSetNodeExpiryInFuture(t *testing.T) { ) require.NoError(t, err) - var node v1.Node + var node clientv1.Node err = json.Unmarshal([]byte(result), &node) require.NoError(t, err) - require.True(t, node.GetExpiry().AsTime().After(time.Now())) - require.WithinDuration(t, targetExpiry, node.GetExpiry().AsTime(), 2*time.Second) + require.True(t, node.Expiry.After(time.Now())) + require.WithinDuration(t, targetExpiry, *node.Expiry, 2*time.Second) var nodeKey key.NodePublic - err = nodeKey.UnmarshalText([]byte(node.GetNodeKey())) + err = nodeKey.UnmarshalText([]byte(node.NodeKey)) require.NoError(t, err) for _, client := range allClients { - if client.Hostname() == node.GetName() { + if client.Hostname() == node.Name { continue } @@ -1208,10 +1208,10 @@ func TestDisableNodeExpiry(t *testing.T) { ) require.NoError(t, err) - var node v1.Node + var node clientv1.Node err = json.Unmarshal([]byte(result), &node) require.NoError(t, err) - require.NotNil(t, node.GetExpiry(), "node should have an expiry set") + require.NotNil(t, node.Expiry, "node should have an expiry set") // Now disable the expiry. result, err = headscale.Execute( @@ -1224,23 +1224,23 @@ func TestDisableNodeExpiry(t *testing.T) { ) require.NoError(t, err) - var nodeDisabled v1.Node + var nodeDisabled clientv1.Node err = json.Unmarshal([]byte(result), &nodeDisabled) require.NoError(t, err) // Expiry should be nil (or zero time) when disabled. - if nodeDisabled.GetExpiry() != nil { - require.True(t, nodeDisabled.GetExpiry().AsTime().IsZero(), + if nodeDisabled.Expiry != nil { + require.True(t, nodeDisabled.Expiry.IsZero(), "node expiry should be zero/nil after disabling") } var nodeKey key.NodePublic - err = nodeKey.UnmarshalText([]byte(nodeDisabled.GetNodeKey())) + err = nodeKey.UnmarshalText([]byte(nodeDisabled.NodeKey)) require.NoError(t, err) // Verify peers see the node as not expired. for _, client := range allClients { - if client.Hostname() == nodeDisabled.GetName() { + if client.Hostname() == nodeDisabled.Name { continue } @@ -1332,7 +1332,7 @@ func TestNodeOnlineStatus(t *testing.T) { return } - var nodes []*v1.Node + var nodes []*clientv1.Node assert.EventuallyWithT(t, func(ct *assert.CollectT) { result, err := headscale.Execute([]string{ "headscale", "nodes", "list", "--output", "json", @@ -1347,9 +1347,9 @@ func TestNodeOnlineStatus(t *testing.T) { // All nodes should be online assert.Truef( ct, - node.GetOnline(), + node.Online, "expected %s to have online status in Headscale, marked as offline %s after start", - node.GetName(), + node.Name, time.Since(start), ) } @@ -1537,7 +1537,7 @@ func Test2118DeletingOnlineNodePanics(t *testing.T) { require.NoError(t, err) // Test list all nodes after added otherUser - var nodeList []v1.Node + var nodeList []clientv1.Node err = executeAndUnmarshal( headscale, []string{ @@ -1551,8 +1551,8 @@ func Test2118DeletingOnlineNodePanics(t *testing.T) { ) require.NoError(t, err) assert.Len(t, nodeList, 2) - assert.True(t, nodeList[0].GetOnline()) - assert.True(t, nodeList[1].GetOnline()) + assert.True(t, nodeList[0].Online) + assert.True(t, nodeList[1].Online) // Delete the first node, which is online _, err = headscale.Execute( @@ -1562,7 +1562,7 @@ func Test2118DeletingOnlineNodePanics(t *testing.T) { "delete", "--identifier", // Delete the last added machine - fmt.Sprintf("%d", nodeList[0].GetId()), + nodeList[0].Id, "--output", "json", "--force", @@ -1571,7 +1571,7 @@ func Test2118DeletingOnlineNodePanics(t *testing.T) { require.NoError(t, err) // Ensure that the node has been deleted, this did not occur due to a panic. - var nodeListAfter []v1.Node + var nodeListAfter []clientv1.Node assert.EventuallyWithT(t, func(ct *assert.CollectT) { err = executeAndUnmarshal( headscale, @@ -1601,6 +1601,6 @@ func Test2118DeletingOnlineNodePanics(t *testing.T) { ) require.NoError(t, err) assert.Len(t, nodeListAfter, 1) - assert.True(t, nodeListAfter[0].GetOnline()) - assert.Equal(t, nodeList[1].GetId(), nodeListAfter[0].GetId()) + assert.True(t, nodeListAfter[0].Online) + assert.Equal(t, nodeList[1].Id, nodeListAfter[0].Id) } diff --git a/integration/grant_cap_test.go b/integration/grant_cap_test.go index 4c3bc9bfa..b3a897332 100644 --- a/integration/grant_cap_test.go +++ b/integration/grant_cap_test.go @@ -159,10 +159,10 @@ func TestGrantCapRelay(t *testing.T) { defer func() { _, _, _ = relayR.Shutdown() }() pakRelay, err := scenario.CreatePreAuthKeyWithTags( - userMap["relay"].GetId(), false, false, []string{"tag:relay"}, + mustParseID(userMap["relay"].Id), false, false, []string{"tag:relay"}, ) require.NoError(t, err) - err = relayR.Login(headscale.GetEndpoint(), pakRelay.GetKey()) + err = relayR.Login(headscale.GetEndpoint(), pakRelay.Key) require.NoError(t, err) err = relayR.WaitForRunning(30 * time.Second) require.NoError(t, err) @@ -192,10 +192,10 @@ func TestGrantCapRelay(t *testing.T) { defer func() { _, _, _ = clientA.Shutdown() }() pakClientA, err := scenario.CreatePreAuthKeyWithTags( - userMap["clienta"].GetId(), false, false, []string{"tag:client-a"}, + mustParseID(userMap["clienta"].Id), false, false, []string{"tag:client-a"}, ) require.NoError(t, err) - err = clientA.Login(headscale.GetEndpoint(), pakClientA.GetKey()) + err = clientA.Login(headscale.GetEndpoint(), pakClientA.Key) require.NoError(t, err) err = clientA.WaitForRunning(30 * time.Second) require.NoError(t, err) @@ -209,10 +209,10 @@ func TestGrantCapRelay(t *testing.T) { defer func() { _, _, _ = clientB.Shutdown() }() pakClientB, err := scenario.CreatePreAuthKeyWithTags( - userMap["clientb"].GetId(), false, false, []string{"tag:client-b"}, + mustParseID(userMap["clientb"].Id), false, false, []string{"tag:client-b"}, ) require.NoError(t, err) - err = clientB.Login(headscale.GetEndpoint(), pakClientB.GetKey()) + err = clientB.Login(headscale.GetEndpoint(), pakClientB.Key) require.NoError(t, err) err = clientB.WaitForRunning(30 * time.Second) require.NoError(t, err) @@ -637,10 +637,10 @@ func TestGrantCapDrive(t *testing.T) { defer func() { _, _, _ = sharer.Shutdown() }() pakSharer, err := scenario.CreatePreAuthKeyWithTags( - userMap["sharer"].GetId(), false, false, []string{"tag:sharer"}, + mustParseID(userMap["sharer"].Id), false, false, []string{"tag:sharer"}, ) require.NoError(t, err) - err = sharer.Login(headscale.GetEndpoint(), pakSharer.GetKey()) + err = sharer.Login(headscale.GetEndpoint(), pakSharer.Key) require.NoError(t, err) err = sharer.WaitForRunning(30 * time.Second) require.NoError(t, err) @@ -654,10 +654,10 @@ func TestGrantCapDrive(t *testing.T) { defer func() { _, _, _ = rwClient.Shutdown() }() pakRW, err := scenario.CreatePreAuthKeyWithTags( - userMap["rwclient"].GetId(), false, false, []string{"tag:rw-client"}, + mustParseID(userMap["rwclient"].Id), false, false, []string{"tag:rw-client"}, ) require.NoError(t, err) - err = rwClient.Login(headscale.GetEndpoint(), pakRW.GetKey()) + err = rwClient.Login(headscale.GetEndpoint(), pakRW.Key) require.NoError(t, err) err = rwClient.WaitForRunning(30 * time.Second) require.NoError(t, err) @@ -671,10 +671,10 @@ func TestGrantCapDrive(t *testing.T) { defer func() { _, _, _ = roClient.Shutdown() }() pakRO, err := scenario.CreatePreAuthKeyWithTags( - userMap["roclient"].GetId(), false, false, []string{"tag:ro-client"}, + mustParseID(userMap["roclient"].Id), false, false, []string{"tag:ro-client"}, ) require.NoError(t, err) - err = roClient.Login(headscale.GetEndpoint(), pakRO.GetKey()) + err = roClient.Login(headscale.GetEndpoint(), pakRO.Key) require.NoError(t, err) err = roClient.WaitForRunning(30 * time.Second) require.NoError(t, err) @@ -688,10 +688,10 @@ func TestGrantCapDrive(t *testing.T) { defer func() { _, _, _ = noAccess.Shutdown() }() pakNA, err := scenario.CreatePreAuthKeyWithTags( - userMap["noaccess"].GetId(), false, false, []string{"tag:no-access"}, + mustParseID(userMap["noaccess"].Id), false, false, []string{"tag:no-access"}, ) require.NoError(t, err) - err = noAccess.Login(headscale.GetEndpoint(), pakNA.GetKey()) + err = noAccess.Login(headscale.GetEndpoint(), pakNA.Key) require.NoError(t, err) err = noAccess.WaitForRunning(30 * time.Second) require.NoError(t, err) diff --git a/integration/helpers.go b/integration/helpers.go index 7aa1ae1ea..908c7447b 100644 --- a/integration/helpers.go +++ b/integration/helpers.go @@ -18,7 +18,7 @@ import ( "github.com/cenkalti/backoff/v5" "github.com/google/go-cmp/cmp" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" @@ -538,15 +538,15 @@ func requireAllClientsNetInfoAndDERP(t *testing.T, headscale ControlServer, expe // assertLastSeenSet validates that a node has a non-nil LastSeen timestamp. // Critical for ensuring node activity tracking is functioning properly. -func assertLastSeenSet(t *testing.T, node *v1.Node) { +func assertLastSeenSet(t *testing.T, node *clientv1.Node) { t.Helper() assert.NotNil(t, node) - assert.NotNil(t, node.GetLastSeen()) + assert.NotNil(t, node.LastSeen) } -func assertLastSeenSetWithCollect(c *assert.CollectT, node *v1.Node) { +func assertLastSeenSetWithCollect(c *assert.CollectT, node *clientv1.Node) { assert.NotNil(c, node) - assert.NotNil(c, node.GetLastSeen()) + assert.NotNil(c, node.LastSeen) } // assertCurlSuccessWithCollect asserts that a curl request succeeds with @@ -1071,14 +1071,14 @@ func oidcMockUser(username string, emailVerified bool) mockoidc.MockUser { // GetUserByName retrieves a user by name from the headscale server. // This is a common pattern used when creating preauth keys or managing users. -func GetUserByName(headscale ControlServer, username string) (*v1.User, error) { +func GetUserByName(headscale ControlServer, username string) (*clientv1.User, error) { users, err := headscale.ListUsers() if err != nil { return nil, fmt.Errorf("listing users: %w", err) } for _, u := range users { - if u.GetName() == username { + if u.Name == username { return u, nil } } @@ -1088,7 +1088,7 @@ func GetUserByName(headscale ControlServer, username string) (*v1.User, error) { // findNode returns the first node in nodes for which match returns true, // or nil if no node matches. -func findNode(nodes []*v1.Node, match func(*v1.Node) bool) *v1.Node { +func findNode(nodes []*clientv1.Node, match func(*clientv1.Node) bool) *clientv1.Node { for _, n := range nodes { if match(n) { return n @@ -1098,6 +1098,19 @@ func findNode(nodes []*v1.Node, match func(*v1.Node) bool) *v1.Node { return nil } +// mustParseID parses a string ID emitted by the HTTP client types into a +// uint64 for the APIs that still take numeric identifiers (NodeID, user and +// key IDs). It panics on malformed input, which only happens if the server +// emits a non-numeric ID — a bug worth failing the test loudly. +func mustParseID(id string) uint64 { + parsed, err := strconv.ParseUint(id, 10, 64) + if err != nil { + panic(fmt.Sprintf("parsing id %q: %s", id, err)) + } + + return parsed +} + // FindNewClient finds a client that is in the new list but not in the original list. // This is useful when dynamically adding nodes during tests and needing to identify // which client was just added. @@ -1177,13 +1190,13 @@ func (s *Scenario) AddAndLoginClient( return nil, fmt.Errorf("getting user: %w", err) } - authKey, err := s.CreatePreAuthKey(user.GetId(), true, false) + authKey, err := s.CreatePreAuthKey(mustParseID(user.Id), true, false) if err != nil { return nil, fmt.Errorf("creating preauth key: %w", err) } // Login the new client - err = newClient.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = newClient.Login(headscale.GetEndpoint(), authKey.Key) if err != nil { return nil, fmt.Errorf("logging in new client: %w", err) } diff --git a/integration/hsic/hsic.go b/integration/hsic/hsic.go index e20e64661..add0bc481 100644 --- a/integration/hsic/hsic.go +++ b/integration/hsic/hsic.go @@ -24,7 +24,7 @@ import ( "time" "github.com/davecgh/go-spew/spew" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol" policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" "github.com/juanfont/headscale/hscontrol/types" @@ -1095,7 +1095,7 @@ func (t *HeadscaleInContainer) WaitForRunning() error { // CreateUser adds a new user to the Headscale instance. func (t *HeadscaleInContainer) CreateUser( user string, -) (*v1.User, error) { +) (*clientv1.User, error) { command := []string{ binHeadscale, "users", @@ -1115,7 +1115,7 @@ func (t *HeadscaleInContainer) CreateUser( return nil, err } - var u v1.User + var u clientv1.User err = json.Unmarshal([]byte(result), &u) if err != nil { @@ -1140,7 +1140,7 @@ type AuthKeyOptions struct { // CreateAuthKeyWithOptions creates a new "authorisation key" with the specified options. // This supports both user-owned and tags-only auth keys. -func (t *HeadscaleInContainer) CreateAuthKeyWithOptions(opts AuthKeyOptions) (*v1.PreAuthKey, error) { +func (t *HeadscaleInContainer) CreateAuthKeyWithOptions(opts AuthKeyOptions) (*clientv1.PreAuthKey, error) { command := []string{ binHeadscale, } @@ -1181,7 +1181,7 @@ func (t *HeadscaleInContainer) CreateAuthKeyWithOptions(opts AuthKeyOptions) (*v return nil, fmt.Errorf("executing create auth key command: %w", err) } - var preAuthKey v1.PreAuthKey + var preAuthKey clientv1.PreAuthKey err = json.Unmarshal([]byte(result), &preAuthKey) if err != nil { @@ -1197,7 +1197,7 @@ func (t *HeadscaleInContainer) CreateAuthKey( user uint64, reusable bool, ephemeral bool, -) (*v1.PreAuthKey, error) { +) (*clientv1.PreAuthKey, error) { return t.CreateAuthKeyWithOptions(AuthKeyOptions{ User: &user, Reusable: reusable, @@ -1212,7 +1212,7 @@ func (t *HeadscaleInContainer) CreateAuthKeyWithTags( reusable bool, ephemeral bool, tags []string, -) (*v1.PreAuthKey, error) { +) (*clientv1.PreAuthKey, error) { return t.CreateAuthKeyWithOptions(AuthKeyOptions{ User: &user, Reusable: reusable, @@ -1252,8 +1252,8 @@ func (t *HeadscaleInContainer) DeleteAuthKey( // specific users. func (t *HeadscaleInContainer) ListNodes( users ...string, -) ([]*v1.Node, error) { - var ret []*v1.Node +) ([]*clientv1.Node, error) { + var ret []*clientv1.Node execUnmarshal := func(command []string) error { result, _, err := dockertestutil.ExecuteCommand( @@ -1265,7 +1265,7 @@ func (t *HeadscaleInContainer) ListNodes( return fmt.Errorf("executing list node command: %w", err) } - var nodes []*v1.Node + var nodes []*clientv1.Node err = json.Unmarshal([]byte(result), &nodes) if err != nil { @@ -1293,8 +1293,11 @@ func (t *HeadscaleInContainer) ListNodes( } } - slices.SortFunc(ret, func(a, b *v1.Node) int { - return cmp.Compare(a.GetId(), b.GetId()) + slices.SortFunc(ret, func(a, b *clientv1.Node) int { + ai, _ := strconv.ParseUint(a.Id, 10, 64) + bi, _ := strconv.ParseUint(b.Id, 10, 64) + + return cmp.Compare(ai, bi) }) return ret, nil @@ -1324,37 +1327,37 @@ func (t *HeadscaleInContainer) DeleteNode(nodeID uint64) error { return nil } -func (t *HeadscaleInContainer) NodesByUser() (map[string][]*v1.Node, error) { +func (t *HeadscaleInContainer) NodesByUser() (map[string][]*clientv1.Node, error) { nodes, err := t.ListNodes() if err != nil { return nil, err } - userMap := make(map[string][]*v1.Node) + userMap := make(map[string][]*clientv1.Node) for _, node := range nodes { - name := node.GetUser().GetName() + name := node.User.Name userMap[name] = append(userMap[name], node) } return userMap, nil } -func (t *HeadscaleInContainer) NodesByName() (map[string]*v1.Node, error) { +func (t *HeadscaleInContainer) NodesByName() (map[string]*clientv1.Node, error) { nodes, err := t.ListNodes() if err != nil { return nil, err } - var nameMap map[string]*v1.Node + var nameMap map[string]*clientv1.Node for _, node := range nodes { - mak.Set(&nameMap, node.GetName(), node) + mak.Set(&nameMap, node.Name, node) } return nameMap, nil } // ListUsers returns a list of users from Headscale. -func (t *HeadscaleInContainer) ListUsers() ([]*v1.User, error) { +func (t *HeadscaleInContainer) ListUsers() ([]*clientv1.User, error) { command := []string{binHeadscale, "users", "list", flagOutput, "json"} result, _, err := dockertestutil.ExecuteCommand( @@ -1366,7 +1369,7 @@ func (t *HeadscaleInContainer) ListUsers() ([]*v1.User, error) { return nil, fmt.Errorf("executing list node command: %w", err) } - var users []*v1.User + var users []*clientv1.User err = json.Unmarshal([]byte(result), &users) if err != nil { @@ -1378,15 +1381,15 @@ func (t *HeadscaleInContainer) ListUsers() ([]*v1.User, error) { // MapUsers returns a map of users from Headscale. It is keyed by the // user name. -func (t *HeadscaleInContainer) MapUsers() (map[string]*v1.User, error) { +func (t *HeadscaleInContainer) MapUsers() (map[string]*clientv1.User, error) { users, err := t.ListUsers() if err != nil { return nil, err } - var userMap map[string]*v1.User + var userMap map[string]*clientv1.User for _, user := range users { - mak.Set(&userMap, user.GetName(), user) + mak.Set(&userMap, user.Name, user) } return userMap, nil @@ -1545,7 +1548,7 @@ func (h *HeadscaleInContainer) Restart() error { } // ApproveRoutes approves routes for a node. -func (t *HeadscaleInContainer) ApproveRoutes(id uint64, routes []netip.Prefix) (*v1.Node, error) { +func (t *HeadscaleInContainer) ApproveRoutes(id uint64, routes []netip.Prefix) (*clientv1.Node, error) { command := []string{ binHeadscale, "nodes", "approve-routes", flagOutput, "json", @@ -1567,7 +1570,7 @@ func (t *HeadscaleInContainer) ApproveRoutes(id uint64, routes []netip.Prefix) ( ) } - var node *v1.Node + var node *clientv1.Node err = json.Unmarshal([]byte(result), &node) if err != nil { diff --git a/integration/route_test.go b/integration/route_test.go index beff6ff06..387a0ae1f 100644 --- a/integration/route_test.go +++ b/integration/route_test.go @@ -8,14 +8,13 @@ import ( "net/netip" "slices" "sort" - "strconv" "strings" "testing" "time" cmpdiff "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" @@ -87,7 +86,7 @@ func TestEnablingRoutes(t *testing.T) { err = scenario.WaitForTailscaleSync() requireNoErrSync(t, err) - var nodes []*v1.Node + var nodes []*clientv1.Node // Wait for route advertisements to propagate to [state.NodeStore] assert.EventuallyWithT(t, func(ct *assert.CollectT) { var err error @@ -96,9 +95,9 @@ func TestEnablingRoutes(t *testing.T) { assert.NoError(ct, err) for _, node := range nodes { - assert.Len(ct, node.GetAvailableRoutes(), 1) - assert.Empty(ct, node.GetApprovedRoutes()) - assert.Empty(ct, node.GetSubnetRoutes()) + assert.Len(ct, node.AvailableRoutes, 1) + assert.Empty(ct, node.ApprovedRoutes) + assert.Empty(ct, node.SubnetRoutes) } }, integrationutil.ScaledTimeout(10*time.Second), 100*time.Millisecond, "route advertisements should propagate to all nodes") @@ -119,8 +118,8 @@ func TestEnablingRoutes(t *testing.T) { for _, node := range nodes { _, err := headscale.ApproveRoutes( - node.GetId(), - util.MustStringsToPrefixes(node.GetAvailableRoutes()), + mustParseID(node.Id), + util.MustStringsToPrefixes(node.AvailableRoutes), ) require.NoError(t, err) } @@ -133,9 +132,9 @@ func TestEnablingRoutes(t *testing.T) { assert.NoError(ct, err) for _, node := range nodes { - assert.Len(ct, node.GetAvailableRoutes(), 1) - assert.Len(ct, node.GetApprovedRoutes(), 1) - assert.Len(ct, node.GetSubnetRoutes(), 1) + assert.Len(ct, node.AvailableRoutes, 1) + assert.Len(ct, node.ApprovedRoutes, 1) + assert.Len(ct, node.SubnetRoutes, 1) } }, integrationutil.ScaledTimeout(10*time.Second), 100*time.Millisecond, "route approvals should propagate to all nodes") @@ -181,18 +180,19 @@ func TestEnablingRoutes(t *testing.T) { assert.NoError(c, err) for _, node := range nodes { - if node.GetId() == 1 { - assert.Len(c, node.GetAvailableRoutes(), 1) // 10.0.0.0/24 - assert.Len(c, node.GetApprovedRoutes(), 1) // 10.0.1.0/24 - assert.Empty(c, node.GetSubnetRoutes()) - } else if node.GetId() == 2 { - assert.Len(c, node.GetAvailableRoutes(), 1) // 10.0.1.0/24 - assert.Empty(c, node.GetApprovedRoutes()) - assert.Empty(c, node.GetSubnetRoutes()) - } else { - assert.Len(c, node.GetAvailableRoutes(), 1) // 10.0.2.0/24 - assert.Len(c, node.GetApprovedRoutes(), 1) // 10.0.2.0/24 - assert.Len(c, node.GetSubnetRoutes(), 1) // 10.0.2.0/24 + switch node.Id { + case "1": + assert.Len(c, node.AvailableRoutes, 1) // 10.0.0.0/24 + assert.Len(c, node.ApprovedRoutes, 1) // 10.0.1.0/24 + assert.Empty(c, node.SubnetRoutes) + case "2": + assert.Len(c, node.AvailableRoutes, 1) // 10.0.1.0/24 + assert.Empty(c, node.ApprovedRoutes) + assert.Empty(c, node.SubnetRoutes) + default: + assert.Len(c, node.AvailableRoutes, 1) // 10.0.2.0/24 + assert.Len(c, node.ApprovedRoutes, 1) // 10.0.2.0/24 + assert.Len(c, node.SubnetRoutes, 1) // 10.0.2.0/24 } } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "route state changes should propagate to nodes") @@ -333,7 +333,7 @@ func TestHASubnetRouterFailover(t *testing.T) { requireNoErrSync(t, err) // Wait for route configuration changes after advertising routes - var nodes []*v1.Node + var nodes []*clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err = headscale.ListNodes() @@ -400,7 +400,7 @@ func TestHASubnetRouterFailover(t *testing.T) { t.Logf(" Expected: Client can access webservice through router 1 only") _, err = headscale.ApproveRoutes( - MustFindNode(subRouter1.Hostname(), nodes).GetId(), + mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id), []netip.Prefix{pref}, ) require.NoError(t, err) @@ -479,11 +479,11 @@ func TestHASubnetRouterFailover(t *testing.T) { // Validate primary routes table state - router 1 is primary validatePrimaryRoutes(t, headscale, &types.DebugRoutes{ AvailableRoutes: map[types.NodeID][]netip.Prefix{ - types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id)): {pref}, // Note: Router 2 and 3 are available but not approved }, PrimaryRoutes: map[string]types.NodeID{ - pref.String(): types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()), + pref.String(): types.NodeID(mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id)), }, }, "Router 1 should be primary for route "+pref.String()) @@ -498,7 +498,7 @@ func TestHASubnetRouterFailover(t *testing.T) { t.Logf(" Expected: HA is now active - if router 1 fails, router 2 can take over") _, err = headscale.ApproveRoutes( - MustFindNode(subRouter2.Hostname(), nodes).GetId(), + mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id), []netip.Prefix{pref}, ) require.NoError(t, err) @@ -559,12 +559,12 @@ func TestHASubnetRouterFailover(t *testing.T) { // Validate primary routes table state - router 1 still primary, router 2 approved but standby validatePrimaryRoutes(t, headscale, &types.DebugRoutes{ AvailableRoutes: map[types.NodeID][]netip.Prefix{ - types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()): {pref}, - types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id)): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id)): {pref}, // Note: Router 3 is available but not approved }, PrimaryRoutes: map[string]types.NodeID{ - pref.String(): types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()), + pref.String(): types.NodeID(mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id)), }, }, "Router 1 should remain primary after router 2 approval") @@ -594,12 +594,12 @@ func TestHASubnetRouterFailover(t *testing.T) { // Validate primary routes table state - router 1 primary, router 2 approved (standby) validatePrimaryRoutes(t, headscale, &types.DebugRoutes{ AvailableRoutes: map[types.NodeID][]netip.Prefix{ - types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()): {pref}, - types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id)): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id)): {pref}, // Note: Router 3 is available but not approved }, PrimaryRoutes: map[string]types.NodeID{ - pref.String(): types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()), + pref.String(): types.NodeID(mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id)), }, }, "Router 1 primary with router 2 as standby") @@ -615,7 +615,7 @@ func TestHASubnetRouterFailover(t *testing.T) { t.Logf(" Expected: Full HA configuration with 1 PRIMARY + 2 STANDBY routers") _, err = headscale.ApproveRoutes( - MustFindNode(subRouter3.Hostname(), nodes).GetId(), + mustParseID(MustFindNode(subRouter3.Hostname(), nodes).Id), []netip.Prefix{pref}, ) require.NoError(t, err) @@ -702,12 +702,12 @@ func TestHASubnetRouterFailover(t *testing.T) { // Validate primary routes table state - all 3 routers approved, router 1 still primary validatePrimaryRoutes(t, headscale, &types.DebugRoutes{ AvailableRoutes: map[types.NodeID][]netip.Prefix{ - types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()): {pref}, - types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()): {pref}, - types.NodeID(MustFindNode(subRouter3.Hostname(), nodes).GetId()): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id)): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id)): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter3.Hostname(), nodes).Id)): {pref}, }, PrimaryRoutes: map[string]types.NodeID{ - pref.String(): types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()), + pref.String(): types.NodeID(mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id)), }, }, "Router 1 primary with all 3 routers approved") @@ -782,11 +782,11 @@ func TestHASubnetRouterFailover(t *testing.T) { validatePrimaryRoutes(t, headscale, &types.DebugRoutes{ AvailableRoutes: map[types.NodeID][]netip.Prefix{ // Router 1 is disconnected, so not in AvailableRoutes - types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()): {pref}, - types.NodeID(MustFindNode(subRouter3.Hostname(), nodes).GetId()): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id)): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter3.Hostname(), nodes).Id)): {pref}, }, PrimaryRoutes: map[string]types.NodeID{ - pref.String(): types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()), + pref.String(): types.NodeID(mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id)), }, }, "Router 2 should be primary after router 1 failure") @@ -854,10 +854,10 @@ func TestHASubnetRouterFailover(t *testing.T) { validatePrimaryRoutes(t, headscale, &types.DebugRoutes{ AvailableRoutes: map[types.NodeID][]netip.Prefix{ // Routers 1 and 2 are disconnected, so not in AvailableRoutes - types.NodeID(MustFindNode(subRouter3.Hostname(), nodes).GetId()): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter3.Hostname(), nodes).Id)): {pref}, }, PrimaryRoutes: map[string]types.NodeID{ - pref.String(): types.NodeID(MustFindNode(subRouter3.Hostname(), nodes).GetId()), + pref.String(): types.NodeID(mustParseID(MustFindNode(subRouter3.Hostname(), nodes).Id)), }, }, "Router 3 should be primary after router 2 failure") @@ -931,12 +931,12 @@ func TestHASubnetRouterFailover(t *testing.T) { // Validate primary routes table state - router 3 remains primary after router 1 comes back validatePrimaryRoutes(t, headscale, &types.DebugRoutes{ AvailableRoutes: map[types.NodeID][]netip.Prefix{ - types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id)): {pref}, // Router 2 is still disconnected - types.NodeID(MustFindNode(subRouter3.Hostname(), nodes).GetId()): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter3.Hostname(), nodes).Id)): {pref}, }, PrimaryRoutes: map[string]types.NodeID{ - pref.String(): types.NodeID(MustFindNode(subRouter3.Hostname(), nodes).GetId()), + pref.String(): types.NodeID(mustParseID(MustFindNode(subRouter3.Hostname(), nodes).Id)), }, }, "Router 3 should remain primary after router 1 recovery") @@ -1012,12 +1012,12 @@ func TestHASubnetRouterFailover(t *testing.T) { // Validate primary routes table state - router 3 remains primary after all routers back online validatePrimaryRoutes(t, headscale, &types.DebugRoutes{ AvailableRoutes: map[types.NodeID][]netip.Prefix{ - types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()): {pref}, - types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()): {pref}, - types.NodeID(MustFindNode(subRouter3.Hostname(), nodes).GetId()): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id)): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id)): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter3.Hostname(), nodes).Id)): {pref}, }, PrimaryRoutes: map[string]types.NodeID{ - pref.String(): types.NodeID(MustFindNode(subRouter3.Hostname(), nodes).GetId()), + pref.String(): types.NodeID(mustParseID(MustFindNode(subRouter3.Hostname(), nodes).Id)), }, }, "Router 3 should remain primary after full recovery") @@ -1030,7 +1030,7 @@ func TestHASubnetRouterFailover(t *testing.T) { t.Logf(" Expected: Router 1 (%s) should become new PRIMARY (lowest ID with approved route)", subRouter1.Hostname()) t.Logf(" Expected: Router 2 (%s) remains STANDBY", subRouter2.Hostname()) t.Logf(" Expected: Router 3 (%s) goes to advertised-only state (no longer serving)", subRouter3.Hostname()) - _, err = headscale.ApproveRoutes(MustFindNode(subRouter3.Hostname(), nodes).GetId(), []netip.Prefix{}) + _, err = headscale.ApproveRoutes(mustParseID(MustFindNode(subRouter3.Hostname(), nodes).Id), []netip.Prefix{}) // Wait for nodestore batch processing and route state changes to complete // [state.NodeStore] batching timeout is 500ms, so we wait up to 10 seconds for route failover @@ -1097,12 +1097,12 @@ func TestHASubnetRouterFailover(t *testing.T) { // Validate primary routes table state - router 1 is primary after router 3 route disabled validatePrimaryRoutes(t, headscale, &types.DebugRoutes{ AvailableRoutes: map[types.NodeID][]netip.Prefix{ - types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()): {pref}, - types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id)): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id)): {pref}, // Router 3's route is no longer approved, so not in AvailableRoutes }, PrimaryRoutes: map[string]types.NodeID{ - pref.String(): types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()), + pref.String(): types.NodeID(mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id)), }, }, "Router 1 should be primary after router 3 route disabled") @@ -1116,7 +1116,7 @@ func TestHASubnetRouterFailover(t *testing.T) { t.Logf(" Expected: Router 2 (%s) should become new PRIMARY (only remaining approved route)", subRouter2.Hostname()) t.Logf(" Expected: Router 1 (%s) goes to advertised-only state", subRouter1.Hostname()) t.Logf(" Expected: Router 3 (%s) remains advertised-only", subRouter3.Hostname()) - _, err = headscale.ApproveRoutes(MustFindNode(subRouter1.Hostname(), nodes).GetId(), []netip.Prefix{}) + _, err = headscale.ApproveRoutes(mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id), []netip.Prefix{}) // Wait for nodestore batch processing and route state changes to complete // [state.NodeStore] batching timeout is 500ms, so we wait up to 10 seconds for route failover @@ -1184,11 +1184,11 @@ func TestHASubnetRouterFailover(t *testing.T) { validatePrimaryRoutes(t, headscale, &types.DebugRoutes{ AvailableRoutes: map[types.NodeID][]netip.Prefix{ // Router 1's route is no longer approved, so not in AvailableRoutes - types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id)): {pref}, // Router 3's route is still not approved }, PrimaryRoutes: map[string]types.NodeID{ - pref.String(): types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()), + pref.String(): types.NodeID(mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id)), }, }, "Router 2 should be primary after router 1 route disabled") @@ -1205,8 +1205,8 @@ func TestHASubnetRouterFailover(t *testing.T) { r1Node := MustFindNode(subRouter1.Hostname(), nodes) _, err = headscale.ApproveRoutes( - r1Node.GetId(), - util.MustStringsToPrefixes(r1Node.GetAvailableRoutes()), + mustParseID(r1Node.Id), + util.MustStringsToPrefixes(r1Node.AvailableRoutes), ) // Wait for route state changes after re-enabling r1 @@ -1268,12 +1268,12 @@ func TestHASubnetRouterFailover(t *testing.T) { // Validate primary routes table state after router 1 re-approval validatePrimaryRoutes(t, headscale, &types.DebugRoutes{ AvailableRoutes: map[types.NodeID][]netip.Prefix{ - types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()): {pref}, - types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id)): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id)): {pref}, // Router 3 route is still not approved }, PrimaryRoutes: map[string]types.NodeID{ - pref.String(): types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()), + pref.String(): types.NodeID(mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id)), }, }, "Router 2 should remain primary after router 1 re-approval") @@ -1290,8 +1290,8 @@ func TestHASubnetRouterFailover(t *testing.T) { r3Node := MustFindNode(subRouter3.Hostname(), nodes) _, err = headscale.ApproveRoutes( - r3Node.GetId(), - util.MustStringsToPrefixes(r3Node.GetAvailableRoutes()), + mustParseID(r3Node.Id), + util.MustStringsToPrefixes(r3Node.AvailableRoutes), ) // Wait for route state changes after re-enabling r3 @@ -1310,12 +1310,12 @@ func TestHASubnetRouterFailover(t *testing.T) { // Validate primary routes table state after router 3 re-approval validatePrimaryRoutes(t, headscale, &types.DebugRoutes{ AvailableRoutes: map[types.NodeID][]netip.Prefix{ - types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()): {pref}, - types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()): {pref}, - types.NodeID(MustFindNode(subRouter3.Hostname(), nodes).GetId()): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id)): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id)): {pref}, + types.NodeID(mustParseID(MustFindNode(subRouter3.Hostname(), nodes).Id)): {pref}, }, PrimaryRoutes: map[string]types.NodeID{ - pref.String(): types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()), + pref.String(): types.NodeID(mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id)), }, }, "Router 2 should remain primary after router 3 re-approval") @@ -1426,7 +1426,7 @@ func TestSubnetRouteACL(t *testing.T) { requireNoErrSync(t, err) // Wait for route advertisements to propagate to the server - var nodes []*v1.Node + var nodes []*clientv1.Node require.EventuallyWithT(t, func(c *assert.CollectT) { var err error @@ -1437,12 +1437,12 @@ func TestSubnetRouteACL(t *testing.T) { // Find the node that should have the route by checking node IDs var ( - routeNode *v1.Node - otherNode *v1.Node + routeNode *clientv1.Node + otherNode *clientv1.Node ) for _, node := range nodes { - nodeIDStr := strconv.FormatUint(node.GetId(), 10) + nodeIDStr := node.Id if _, shouldHaveRoute := expectedRoutes[nodeIDStr]; shouldHaveRoute { routeNode = node } else { @@ -1623,7 +1623,7 @@ func TestEnablingExitRoutes(t *testing.T) { err = scenario.WaitForTailscaleSync() requireNoErrSync(t, err) - var nodes []*v1.Node + var nodes []*clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { var err error @@ -1654,12 +1654,12 @@ func TestEnablingExitRoutes(t *testing.T) { // Enable all routes, but do v4 on one and v6 on other to ensure they // are both added since they are exit routes. _, err = headscale.ApproveRoutes( - nodes[0].GetId(), + mustParseID(nodes[0].Id), []netip.Prefix{tsaddr.AllIPv4()}, ) require.NoError(t, err) _, err = headscale.ApproveRoutes( - nodes[1].GetId(), + mustParseID(nodes[1].Id), []netip.Prefix{tsaddr.AllIPv6()}, ) require.NoError(t, err) @@ -1754,7 +1754,7 @@ func TestExitRoutesWithAutogroupInternetACL(t *testing.T) { // so the standard WaitForTailscaleSync wait would deadlock here — // the post-approval [assert.EventuallyWithT] block below covers the peer // state we actually care about. - var nodes []*v1.Node + var nodes []*clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err = headscale.ListNodes() @@ -1770,12 +1770,12 @@ func TestExitRoutesWithAutogroupInternetACL(t *testing.T) { // alice's exit. The bug fix is about visibility, not which node // is chosen. _, err = headscale.ApproveRoutes( - nodes[0].GetId(), + mustParseID(nodes[0].Id), []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}, ) require.NoError(t, err) _, err = headscale.ApproveRoutes( - nodes[1].GetId(), + mustParseID(nodes[1].Id), []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()}, ) require.NoError(t, err) @@ -1902,7 +1902,7 @@ func TestSubnetRouterMultiNetwork(t *testing.T) { _, _, err = user1c.Execute(command) require.NoErrorf(t, err, "failed to advertise route: %s", err) - var nodes []*v1.Node + var nodes []*clientv1.Node // Wait for route advertisements to propagate to [state.NodeStore] assert.EventuallyWithT(t, func(ct *assert.CollectT) { var err error @@ -1929,7 +1929,7 @@ func TestSubnetRouterMultiNetwork(t *testing.T) { // Enable route _, err = headscale.ApproveRoutes( - nodes[0].GetId(), + mustParseID(nodes[0].Id), []netip.Prefix{*pref}, ) require.NoError(t, err) @@ -2058,7 +2058,7 @@ func TestSubnetRouterMultiNetworkExitNode(t *testing.T) { _, _, err = user1c.Execute(command) require.NoErrorf(t, err, "failed to advertise routes: %s", err) - var nodes []*v1.Node + var nodes []*clientv1.Node // Wait for route advertisements to propagate (3 routes: v4 exit + v6 exit + subnet). assert.EventuallyWithT(t, func(ct *assert.CollectT) { var err error @@ -2084,7 +2084,7 @@ func TestSubnetRouterMultiNetworkExitNode(t *testing.T) { }, integrationutil.ScaledTimeout(5*time.Second), integrationutil.FastPoll, "Verifying no routes sent to client before approval") // Approve exit routes and subnet route. - _, err = headscale.ApproveRoutes(nodes[0].GetId(), []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6(), *route}) + _, err = headscale.ApproveRoutes(mustParseID(nodes[0].Id), []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6(), *route}) require.NoError(t, err) // Wait for route state changes to propagate. @@ -2151,9 +2151,9 @@ func TestSubnetRouterMultiNetworkExitNode(t *testing.T) { }, 10*time.Second, 200*time.Millisecond, "user2 traceroute should go through user1 exit node") } -func MustFindNode(hostname string, nodes []*v1.Node) *v1.Node { +func MustFindNode(hostname string, nodes []*clientv1.Node) *clientv1.Node { for _, node := range nodes { - if node.GetName() == hostname { + if node.Name == hostname { return node } } @@ -2447,7 +2447,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { require.NoErrorf(t, err, "failed to create scenario: %s", err) defer scenario.ShutdownAssertNoPanics(t) - var nodes []*v1.Node + var nodes []*clientv1.Node opts := []hsic.Option{ hsic.WithTestName("autoapprovemulti"), @@ -2565,16 +2565,16 @@ func TestAutoApproveMultiNetwork(t *testing.T) { // If the approver is a tag, create a tagged PreAuthKey // (tags-as-identity model: tags come from PreAuthKey, not --advertise-tags) - var pak *v1.PreAuthKey + var pak *clientv1.PreAuthKey if strings.HasPrefix(tt.approver, "tag:") { - pak, err = scenario.CreatePreAuthKeyWithTags(userMap["user1"].GetId(), false, false, []string{tt.approver}) + pak, err = scenario.CreatePreAuthKeyWithTags(mustParseID(userMap["user1"].Id), false, false, []string{tt.approver}) } else { - pak, err = scenario.CreatePreAuthKey(userMap["user1"].GetId(), false, false) + pak, err = scenario.CreatePreAuthKey(mustParseID(userMap["user1"].Id), false, false) } require.NoError(t, err) - err = routerUsernet1.Login(headscale.GetEndpoint(), pak.GetKey()) + err = routerUsernet1.Login(headscale.GetEndpoint(), pak.Key) require.NoError(t, err) } // extra creation end. @@ -2646,10 +2646,10 @@ func TestAutoApproveMultiNetwork(t *testing.T) { routerNode := MustFindNode(routerUsernet1.Hostname(), nodes) t.Logf("Initial auto-approval check - Router node %s: announced=%v, approved=%v, subnet=%v", - routerNode.GetName(), - routerNode.GetAvailableRoutes(), - routerNode.GetApprovedRoutes(), - routerNode.GetSubnetRoutes()) + routerNode.Name, + routerNode.AvailableRoutes, + routerNode.ApprovedRoutes, + routerNode.SubnetRoutes) requireNodeRouteCountWithCollect(c, routerNode, 1, 1, 1) }, assertTimeout, 500*time.Millisecond, "Initial route auto-approval: Route should be approved via policy") @@ -2740,10 +2740,10 @@ func TestAutoApproveMultiNetwork(t *testing.T) { routerNode := MustFindNode(routerUsernet1.Hostname(), nodes) t.Logf("After policy removal - Router node %s: announced=%v, approved=%v, subnet=%v", - routerNode.GetName(), - routerNode.GetAvailableRoutes(), - routerNode.GetApprovedRoutes(), - routerNode.GetSubnetRoutes()) + routerNode.Name, + routerNode.AvailableRoutes, + routerNode.ApprovedRoutes, + routerNode.SubnetRoutes) requireNodeRouteCountWithCollect(c, routerNode, 1, 1, 1) }, assertTimeout, 500*time.Millisecond, "Routes should remain approved after auto-approver removal") @@ -2791,7 +2791,7 @@ func TestAutoApproveMultiNetwork(t *testing.T) { // Disable the route, making it unavailable since it is no longer auto-approved _, err = headscale.ApproveRoutes( - MustFindNode(routerUsernet1.Hostname(), nodes).GetId(), + mustParseID(MustFindNode(routerUsernet1.Hostname(), nodes).Id), []netip.Prefix{}, ) require.NoError(t, err) @@ -3078,10 +3078,10 @@ func requirePeerSubnetRoutesWithCollect(c *assert.CollectT, status *ipnstate.Pee } } -func requireNodeRouteCountWithCollect(c *assert.CollectT, node *v1.Node, announced, approved, subnet int) { - assert.Lenf(c, node.GetAvailableRoutes(), announced, "expected %q announced routes(%v) to have %d route, had %d", node.GetName(), node.GetAvailableRoutes(), announced, len(node.GetAvailableRoutes())) - assert.Lenf(c, node.GetApprovedRoutes(), approved, "expected %q approved routes(%v) to have %d route, had %d", node.GetName(), node.GetApprovedRoutes(), approved, len(node.GetApprovedRoutes())) - assert.Lenf(c, node.GetSubnetRoutes(), subnet, "expected %q subnet routes(%v) to have %d route, had %d", node.GetName(), node.GetSubnetRoutes(), subnet, len(node.GetSubnetRoutes())) +func requireNodeRouteCountWithCollect(c *assert.CollectT, node *clientv1.Node, announced, approved, subnet int) { + assert.Lenf(c, node.AvailableRoutes, announced, "expected %q announced routes(%v) to have %d route, had %d", node.Name, node.AvailableRoutes, announced, len(node.AvailableRoutes)) + assert.Lenf(c, node.ApprovedRoutes, approved, "expected %q approved routes(%v) to have %d route, had %d", node.Name, node.ApprovedRoutes, approved, len(node.ApprovedRoutes)) + assert.Lenf(c, node.SubnetRoutes, subnet, "expected %q subnet routes(%v) to have %d route, had %d", node.Name, node.SubnetRoutes, subnet, len(node.SubnetRoutes)) } // TestSubnetRouteACLFiltering tests that a node can only access subnet routes @@ -3218,7 +3218,7 @@ func TestSubnetRouteACLFiltering(t *testing.T) { err = scenario.WaitForTailscaleSync() requireNoErrSync(t, err) - var routerNode, nodeNode *v1.Node + var routerNode, nodeNode *clientv1.Node // Wait for route advertisements to propagate to [state.NodeStore] assert.EventuallyWithT(t, func(ct *assert.CollectT) { // List nodes and verify the router has 3 available routes @@ -3240,8 +3240,8 @@ func TestSubnetRouteACLFiltering(t *testing.T) { // Approve all routes for the router _, err = headscale.ApproveRoutes( - routerNode.GetId(), - util.MustStringsToPrefixes(routerNode.GetAvailableRoutes()), + mustParseID(routerNode.Id), + util.MustStringsToPrefixes(routerNode.AvailableRoutes), ) require.NoError(t, err) @@ -3409,10 +3409,10 @@ func TestGrantViaSubnetSteering(t *testing.T) { defer func() { _, _, _ = routerA.Shutdown() }() pakRouterA, err := scenario.CreatePreAuthKeyWithTags( - userMap["router"].GetId(), false, false, []string{"tag:router-a"}, + mustParseID(userMap["router"].Id), false, false, []string{"tag:router-a"}, ) require.NoError(t, err) - err = routerA.Login(headscale.GetEndpoint(), pakRouterA.GetKey()) + err = routerA.Login(headscale.GetEndpoint(), pakRouterA.Key) require.NoError(t, err) err = routerA.WaitForRunning(30 * time.Second) require.NoError(t, err) @@ -3426,10 +3426,10 @@ func TestGrantViaSubnetSteering(t *testing.T) { defer func() { _, _, _ = routerB.Shutdown() }() pakRouterB, err := scenario.CreatePreAuthKeyWithTags( - userMap["router"].GetId(), false, false, []string{"tag:router-b"}, + mustParseID(userMap["router"].Id), false, false, []string{"tag:router-b"}, ) require.NoError(t, err) - err = routerB.Login(headscale.GetEndpoint(), pakRouterB.GetKey()) + err = routerB.Login(headscale.GetEndpoint(), pakRouterB.Key) require.NoError(t, err) err = routerB.WaitForRunning(30 * time.Second) require.NoError(t, err) @@ -3444,10 +3444,10 @@ func TestGrantViaSubnetSteering(t *testing.T) { defer func() { _, _, _ = clientA.Shutdown() }() pakClientA, err := scenario.CreatePreAuthKeyWithTags( - userMap["client"].GetId(), false, false, []string{"tag:group-a"}, + mustParseID(userMap["client"].Id), false, false, []string{"tag:group-a"}, ) require.NoError(t, err) - err = clientA.Login(headscale.GetEndpoint(), pakClientA.GetKey()) + err = clientA.Login(headscale.GetEndpoint(), pakClientA.Key) require.NoError(t, err) err = clientA.WaitForRunning(30 * time.Second) require.NoError(t, err) @@ -3462,10 +3462,10 @@ func TestGrantViaSubnetSteering(t *testing.T) { defer func() { _, _, _ = clientB.Shutdown() }() pakClientB, err := scenario.CreatePreAuthKeyWithTags( - userMap["client"].GetId(), false, false, []string{"tag:group-b"}, + mustParseID(userMap["client"].Id), false, false, []string{"tag:group-b"}, ) require.NoError(t, err) - err = clientB.Login(headscale.GetEndpoint(), pakClientB.GetKey()) + err = clientB.Login(headscale.GetEndpoint(), pakClientB.Key) require.NoError(t, err) err = clientB.WaitForRunning(30 * time.Second) require.NoError(t, err) @@ -3497,21 +3497,21 @@ func TestGrantViaSubnetSteering(t *testing.T) { routerANode := MustFindNode(routerA.Hostname(), nodes) t.Logf("Router A %s: announced=%v, approved=%v, subnet=%v", - routerANode.GetName(), - routerANode.GetAvailableRoutes(), - routerANode.GetApprovedRoutes(), - routerANode.GetSubnetRoutes()) - assert.Len(c, routerANode.GetAvailableRoutes(), 1, "Router A should have 1 announced route") - assert.Len(c, routerANode.GetApprovedRoutes(), 1, "Router A should have 1 approved route") + routerANode.Name, + routerANode.AvailableRoutes, + routerANode.ApprovedRoutes, + routerANode.SubnetRoutes) + assert.Len(c, routerANode.AvailableRoutes, 1, "Router A should have 1 announced route") + assert.Len(c, routerANode.ApprovedRoutes, 1, "Router A should have 1 approved route") routerBNode := MustFindNode(routerB.Hostname(), nodes) t.Logf("Router B %s: announced=%v, approved=%v, subnet=%v", - routerBNode.GetName(), - routerBNode.GetAvailableRoutes(), - routerBNode.GetApprovedRoutes(), - routerBNode.GetSubnetRoutes()) - assert.Len(c, routerBNode.GetAvailableRoutes(), 1, "Router B should have 1 announced route") - assert.Len(c, routerBNode.GetApprovedRoutes(), 1, "Router B should have 1 approved route") + routerBNode.Name, + routerBNode.AvailableRoutes, + routerBNode.ApprovedRoutes, + routerBNode.SubnetRoutes) + assert.Len(c, routerBNode.AvailableRoutes, 1, "Router B should have 1 announced route") + assert.Len(c, routerBNode.ApprovedRoutes, 1, "Router B should have 1 approved route") }, assertTimeout, 500*time.Millisecond, "Both routers should have auto-approved routes") // Get webservice info. @@ -3697,7 +3697,7 @@ func TestHASubnetRouterPingFailover(t *testing.T) { err = scenario.WaitForTailscaleSync() requireNoErrSync(t, err) - var nodes []*v1.Node + var nodes []*clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err = headscale.ListNodes() @@ -3707,19 +3707,19 @@ func TestHASubnetRouterPingFailover(t *testing.T) { // Approve routes on both routers. _, err = headscale.ApproveRoutes( - MustFindNode(subRouter1.Hostname(), nodes).GetId(), + mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id), []netip.Prefix{pref}, ) require.NoError(t, err) _, err = headscale.ApproveRoutes( - MustFindNode(subRouter2.Hostname(), nodes).GetId(), + mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id), []netip.Prefix{pref}, ) require.NoError(t, err) - nodeID1 := types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()) - nodeID2 := types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()) + nodeID1 := types.NodeID(mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id)) + nodeID2 := types.NodeID(mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id)) // Wait for HA to be set up: router 1 primary, router 2 standby. assert.EventuallyWithT(t, func(c *assert.CollectT) { @@ -3937,7 +3937,7 @@ func TestHASubnetRouterFailoverBothOffline(t *testing.T) { err = scenario.WaitForTailscaleSync() requireNoErrSync(t, err) - var nodes []*v1.Node + var nodes []*clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err = headscale.ListNodes() @@ -3947,19 +3947,19 @@ func TestHASubnetRouterFailoverBothOffline(t *testing.T) { // Approve the route on both routers explicitly. _, err = headscale.ApproveRoutes( - MustFindNode(subRouter1.Hostname(), nodes).GetId(), + mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id), []netip.Prefix{pref}, ) require.NoError(t, err) _, err = headscale.ApproveRoutes( - MustFindNode(subRouter2.Hostname(), nodes).GetId(), + mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id), []netip.Prefix{pref}, ) require.NoError(t, err) - nodeID1 := types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()) - nodeID2 := types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()) + nodeID1 := types.NodeID(mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id)) + nodeID2 := types.NodeID(mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id)) // Sanity: r1 starts as primary (lower NodeID). assert.EventuallyWithT(t, func(c *assert.CollectT) { @@ -4142,7 +4142,7 @@ func TestHASubnetRouterFailoverBothOfflineCablePull(t *testing.T) { err = scenario.WaitForTailscaleSync() requireNoErrSync(t, err) - var nodes []*v1.Node + var nodes []*clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err = headscale.ListNodes() @@ -4151,18 +4151,18 @@ func TestHASubnetRouterFailoverBothOfflineCablePull(t *testing.T) { }, propagationTime, 200*time.Millisecond, "nodes registered") _, err = headscale.ApproveRoutes( - MustFindNode(subRouter1.Hostname(), nodes).GetId(), + mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id), []netip.Prefix{pref}, ) require.NoError(t, err) _, err = headscale.ApproveRoutes( - MustFindNode(subRouter2.Hostname(), nodes).GetId(), + mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id), []netip.Prefix{pref}, ) require.NoError(t, err) - nodeID2 := types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()) + nodeID2 := types.NodeID(mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id)) // Sanity: r1 starts as primary. assert.EventuallyWithT(t, func(c *assert.CollectT) { @@ -4366,7 +4366,7 @@ func TestHASubnetRouterFailoverDockerDisconnect(t *testing.T) { err = scenario.WaitForTailscaleSync() requireNoErrSync(t, err) - var nodes []*v1.Node + var nodes []*clientv1.Node assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err = headscale.ListNodes() @@ -4375,19 +4375,19 @@ func TestHASubnetRouterFailoverDockerDisconnect(t *testing.T) { }, propagationTime, 200*time.Millisecond, "nodes registered") _, err = headscale.ApproveRoutes( - MustFindNode(subRouter1.Hostname(), nodes).GetId(), + mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id), []netip.Prefix{pref}, ) require.NoError(t, err) _, err = headscale.ApproveRoutes( - MustFindNode(subRouter2.Hostname(), nodes).GetId(), + mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id), []netip.Prefix{pref}, ) require.NoError(t, err) - nodeID1 := types.NodeID(MustFindNode(subRouter1.Hostname(), nodes).GetId()) - nodeID2 := types.NodeID(MustFindNode(subRouter2.Hostname(), nodes).GetId()) + nodeID1 := types.NodeID(mustParseID(MustFindNode(subRouter1.Hostname(), nodes).Id)) + nodeID2 := types.NodeID(mustParseID(MustFindNode(subRouter2.Hostname(), nodes).Id)) // requirePrimary blocks until headscale reports want as the // primary advertiser for pref. diff --git a/integration/scenario.go b/integration/scenario.go index ab31546dc..7af281619 100644 --- a/integration/scenario.go +++ b/integration/scenario.go @@ -21,7 +21,7 @@ import ( "testing" "time" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" "github.com/juanfont/headscale/hscontrol/capver" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/integration/dockertestutil" @@ -520,7 +520,7 @@ func (s *Scenario) CreatePreAuthKey( user uint64, reusable bool, ephemeral bool, -) (*v1.PreAuthKey, error) { +) (*clientv1.PreAuthKey, error) { if headscale, err := s.Headscale(); err == nil { //nolint:noinlineerr key, err := headscale.CreateAuthKey(user, reusable, ephemeral) if err != nil { @@ -535,7 +535,7 @@ func (s *Scenario) CreatePreAuthKey( // CreatePreAuthKeyWithOptions creates a "pre authorised key" with the specified options // to be created in the Headscale instance on behalf of the [Scenario]. -func (s *Scenario) CreatePreAuthKeyWithOptions(opts hsic.AuthKeyOptions) (*v1.PreAuthKey, error) { +func (s *Scenario) CreatePreAuthKeyWithOptions(opts hsic.AuthKeyOptions) (*clientv1.PreAuthKey, error) { headscale, err := s.Headscale() if err != nil { return nil, fmt.Errorf("creating preauth key with options: %w", errNoHeadscaleAvailable) @@ -556,7 +556,7 @@ func (s *Scenario) CreatePreAuthKeyWithTags( reusable bool, ephemeral bool, tags []string, -) (*v1.PreAuthKey, error) { +) (*clientv1.PreAuthKey, error) { headscale, err := s.Headscale() if err != nil { return nil, fmt.Errorf("creating preauth key with tags: %w", errNoHeadscaleAvailable) @@ -572,7 +572,7 @@ func (s *Scenario) CreatePreAuthKeyWithTags( // CreateUser creates a [User] to be created in the // Headscale instance on behalf of the [Scenario]. -func (s *Scenario) CreateUser(user string) (*v1.User, error) { +func (s *Scenario) CreateUser(user string) (*clientv1.User, error) { if headscale, err := s.Headscale(); err == nil { //nolint:noinlineerr u, err := headscale.CreateUser(user) if err != nil { @@ -925,7 +925,7 @@ func (s *Scenario) createHeadscaleEnvWithTags( } for _, user := range s.spec.Users { - var u *v1.User + var u *clientv1.User if s.spec.OIDCSkipUserCreation { // Only register locally — OIDC login will create the headscale user. @@ -964,18 +964,18 @@ func (s *Scenario) createHeadscaleEnvWithTags( } } else { // Use tagged PreAuthKey if tags are provided (tags-as-identity model) - var key *v1.PreAuthKey + var key *clientv1.PreAuthKey if len(preAuthKeyTags) > 0 { - key, err = s.CreatePreAuthKeyWithTags(u.GetId(), true, false, preAuthKeyTags) + key, err = s.CreatePreAuthKeyWithTags(mustParseID(u.Id), true, false, preAuthKeyTags) } else { - key, err = s.CreatePreAuthKey(u.GetId(), true, false) + key, err = s.CreatePreAuthKey(mustParseID(u.Id), true, false) } if err != nil { return err } - err = s.RunTailscaleUp(user, headscale.GetEndpoint(), key.GetKey()) + err = s.RunTailscaleUp(user, headscale.GetEndpoint(), key.Key) if err != nil { return err } diff --git a/integration/scenario_test.go b/integration/scenario_test.go index ae264221e..9fa0d854e 100644 --- a/integration/scenario_test.go +++ b/integration/scenario_test.go @@ -137,7 +137,7 @@ func TestTailscaleNodesJoiningHeadcale(t *testing.T) { err = scenario.RunTailscaleUp( user, headscale.GetEndpoint(), - key.GetKey(), + key.Key, ) if err != nil { t.Fatalf("failed to login: %s", err) diff --git a/integration/tags_test.go b/integration/tags_test.go index fa4739a05..27cf1c731 100644 --- a/integration/tags_test.go +++ b/integration/tags_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + clientv1 "github.com/juanfont/headscale/gen/client/v1" policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" "github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/integration/hsic" @@ -67,19 +67,19 @@ func tagsEqual(actual, expected []string) bool { } // assertNodeHasTagsWithCollect asserts that a node has exactly the expected tags (order-independent). -func assertNodeHasTagsWithCollect(c *assert.CollectT, node *v1.Node, expectedTags []string) { - actualTags := node.GetTags() +func assertNodeHasTagsWithCollect(c *assert.CollectT, node *clientv1.Node, expectedTags []string) { + actualTags := node.Tags sortedActual := append([]string{}, actualTags...) sortedExpected := append([]string{}, expectedTags...) sort.Strings(sortedActual) sort.Strings(sortedExpected) - assert.Equal(c, sortedExpected, sortedActual, "Node %s tags mismatch", node.GetName()) + assert.Equal(c, sortedExpected, sortedActual, "Node %s tags mismatch", node.Name) } // assertNodeHasNoTagsWithCollect asserts that a node has no tags. -func assertNodeHasNoTagsWithCollect(c *assert.CollectT, node *v1.Node) { - assert.Empty(c, node.GetTags(), "Node %s should have no tags, but has: %v", node.GetName(), node.GetTags()) +func assertNodeHasNoTagsWithCollect(c *assert.CollectT, node *clientv1.Node) { + assert.Empty(c, node.Tags, "Node %s should have no tags, but has: %v", node.Name, node.Tags) } // assertNodeSelfHasTagsWithCollect asserts that a client's self view has exactly the expected tags. @@ -148,12 +148,12 @@ func TestTagsAuthKeyWithTagRequestDifferentTag(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create a tagged PreAuthKey with tag:valid-owned authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) require.NoError(t, err) - t.Logf("Created tagged PreAuthKey with tags: %v", authKey.GetAclTags()) + t.Logf("Created tagged PreAuthKey with tags: %v", authKey.AclTags) // Create a tailscale client that will try to use --advertise-tags with a DIFFERENT tag client, err := scenario.CreateTailscaleNode( @@ -164,7 +164,7 @@ func TestTagsAuthKeyWithTagRequestDifferentTag(t *testing.T) { require.NoError(t, err) // Login should fail because the advertised tags don't match the auth key's tags - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) // Document actual behavior - we expect this to fail if err != nil { @@ -180,7 +180,7 @@ func TestTagsAuthKeyWithTagRequestDifferentTag(t *testing.T) { assert.NoError(c, err) if len(nodes) == 1 { - t.Logf("Node registered with tags: %v (expected rejection)", nodes[0].GetTags()) + t.Logf("Node registered with tags: %v (expected rejection)", nodes[0].Tags) } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "checking node state") @@ -222,12 +222,12 @@ func TestTagsAuthKeyWithTagNoAdvertiseFlag(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create a tagged PreAuthKey with tag:valid-owned authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) require.NoError(t, err) - t.Logf("Created tagged PreAuthKey with tags: %v", authKey.GetAclTags()) + t.Logf("Created tagged PreAuthKey with tags: %v", authKey.AclTags) // Create a tailscale client WITHOUT --advertise-tags client, err := scenario.CreateTailscaleNode( @@ -238,7 +238,7 @@ func TestTagsAuthKeyWithTagNoAdvertiseFlag(t *testing.T) { require.NoError(t, err) // Login with the tagged PreAuthKey - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) require.NoError(t, err) // Wait for node to be registered and verify it has the key's tags @@ -249,7 +249,7 @@ func TestTagsAuthKeyWithTagNoAdvertiseFlag(t *testing.T) { if len(nodes) == 1 { node := nodes[0] - t.Logf("Node registered with tags: %v", node.GetTags()) + t.Logf("Node registered with tags: %v", node.Tags) assertNodeHasTagsWithCollect(c, node, []string{"tag:valid-owned"}) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "verifying node inherited tags from auth key") @@ -294,7 +294,7 @@ func TestTagsAuthKeyWithTagCannotAddViaCLI(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create a tagged PreAuthKey with tag:valid-owned authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) @@ -308,7 +308,7 @@ func TestTagsAuthKeyWithTagCannotAddViaCLI(t *testing.T) { require.NoError(t, err) // Initial login - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) require.NoError(t, err) // Wait for initial registration @@ -328,7 +328,7 @@ func TestTagsAuthKeyWithTagCannotAddViaCLI(t *testing.T) { command := []string{ "tailscale", "up", "--login-server=" + headscale.GetEndpoint(), - "--authkey=" + authKey.GetKey(), + "--authkey=" + authKey.Key, "--advertise-tags=tag:valid-owned,tag:second", } _, stderr, err := client.Execute(command) @@ -346,10 +346,10 @@ func TestTagsAuthKeyWithTagCannotAddViaCLI(t *testing.T) { if len(nodes) == 1 { // If still only has original tag, that's the expected behavior - if tagsEqual(nodes[0].GetTags(), []string{"tag:valid-owned"}) { - t.Logf("Test 2.3 PASS: Tags unchanged after CLI attempt: %v", nodes[0].GetTags()) + if tagsEqual(nodes[0].Tags, []string{"tag:valid-owned"}) { + t.Logf("Test 2.3 PASS: Tags unchanged after CLI attempt: %v", nodes[0].Tags) } else { - t.Logf("Test 2.3 FAIL: Tags changed unexpectedly to: %v", nodes[0].GetTags()) + t.Logf("Test 2.3 FAIL: Tags changed unexpectedly to: %v", nodes[0].Tags) assert.Fail(c, "Tags should not have changed") } } @@ -394,7 +394,7 @@ func TestTagsAuthKeyWithTagCannotChangeViaCLI(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create a tagged PreAuthKey with tag:valid-owned authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) @@ -408,7 +408,7 @@ func TestTagsAuthKeyWithTagCannotChangeViaCLI(t *testing.T) { require.NoError(t, err) // Initial login - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) require.NoError(t, err) // Wait for initial registration @@ -424,7 +424,7 @@ func TestTagsAuthKeyWithTagCannotChangeViaCLI(t *testing.T) { command := []string{ "tailscale", "up", "--login-server=" + headscale.GetEndpoint(), - "--authkey=" + authKey.GetKey(), + "--authkey=" + authKey.Key, "--advertise-tags=tag:second", } _, stderr, err := client.Execute(command) @@ -441,10 +441,10 @@ func TestTagsAuthKeyWithTagCannotChangeViaCLI(t *testing.T) { assert.NoError(c, err) if len(nodes) == 1 { - if tagsEqual(nodes[0].GetTags(), []string{"tag:valid-owned"}) { - t.Logf("Test 2.4 PASS: Tags unchanged: %v", nodes[0].GetTags()) + if tagsEqual(nodes[0].Tags, []string{"tag:valid-owned"}) { + t.Logf("Test 2.4 PASS: Tags unchanged: %v", nodes[0].Tags) } else { - t.Logf("Test 2.4 FAIL: Tags changed unexpectedly to: %v", nodes[0].GetTags()) + t.Logf("Test 2.4 FAIL: Tags changed unexpectedly to: %v", nodes[0].Tags) assert.Fail(c, "Tags should not have changed") } } @@ -490,7 +490,7 @@ func TestTagsAuthKeyWithTagAdminOverrideReauthPreserves(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create a tagged PreAuthKey with tag:valid-owned authKey, err := scenario.CreatePreAuthKeyWithTags(userID, true, false, []string{"tag:valid-owned"}) @@ -504,7 +504,7 @@ func TestTagsAuthKeyWithTagAdminOverrideReauthPreserves(t *testing.T) { require.NoError(t, err) // Initial login - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) require.NoError(t, err) // Wait for initial registration and get node ID @@ -516,7 +516,7 @@ func TestTagsAuthKeyWithTagAdminOverrideReauthPreserves(t *testing.T) { assert.Len(c, nodes, 1) if len(nodes) == 1 { - nodeID = nodes[0].GetId() + nodeID = mustParseID(nodes[0].Id) assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"}) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "waiting for initial registration") @@ -533,7 +533,7 @@ func TestTagsAuthKeyWithTagAdminOverrideReauthPreserves(t *testing.T) { assert.NoError(c, err) if len(nodes) == 1 { - t.Logf("After admin assignment, server tags are: %v", nodes[0].GetTags()) + t.Logf("After admin assignment, server tags are: %v", nodes[0].Tags) assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:second"}) } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "verifying admin tag assignment on server") @@ -549,7 +549,7 @@ func TestTagsAuthKeyWithTagAdminOverrideReauthPreserves(t *testing.T) { command := []string{ "tailscale", "up", "--login-server=" + headscale.GetEndpoint(), - "--authkey=" + authKey.GetKey(), + "--authkey=" + authKey.Key, "--force-reauth", } //nolint:errcheck // Intentionally ignoring error - we check results below @@ -564,7 +564,7 @@ func TestTagsAuthKeyWithTagAdminOverrideReauthPreserves(t *testing.T) { if len(nodes) >= 1 { // Find the most recently updated node (in case a new one was created) node := nodes[len(nodes)-1] - t.Logf("After reauth, server tags are: %v", node.GetTags()) + t.Logf("After reauth, server tags are: %v", node.Tags) // Expected: admin-assigned tags are preserved through reauth assertNodeHasTagsWithCollect(c, node, []string{"tag:second"}) @@ -617,7 +617,7 @@ func TestTagsAuthKeyWithTagCLICannotModifyAdminTags(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create a tagged PreAuthKey with tag:valid-owned authKey, err := scenario.CreatePreAuthKeyWithTags(userID, true, false, []string{"tag:valid-owned"}) @@ -631,7 +631,7 @@ func TestTagsAuthKeyWithTagCLICannotModifyAdminTags(t *testing.T) { require.NoError(t, err) // Initial login - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) require.NoError(t, err) // Wait for initial registration and get node ID @@ -643,7 +643,7 @@ func TestTagsAuthKeyWithTagCLICannotModifyAdminTags(t *testing.T) { assert.Len(c, nodes, 1) if len(nodes) == 1 { - nodeID = nodes[0].GetId() + nodeID = mustParseID(nodes[0].Id) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "waiting for initial registration") @@ -672,7 +672,7 @@ func TestTagsAuthKeyWithTagCLICannotModifyAdminTags(t *testing.T) { command := []string{ "tailscale", "up", "--login-server=" + headscale.GetEndpoint(), - "--authkey=" + authKey.GetKey(), + "--authkey=" + authKey.Key, "--advertise-tags=tag:valid-owned", } _, stderr, err := client.Execute(command) @@ -686,7 +686,7 @@ func TestTagsAuthKeyWithTagCLICannotModifyAdminTags(t *testing.T) { assert.Len(c, nodes, 1, "Should have exactly 1 node") if len(nodes) == 1 { - t.Logf("After CLI attempt, server tags are: %v", nodes[0].GetTags()) + t.Logf("After CLI attempt, server tags are: %v", nodes[0].Tags) // Expected: tags should remain unchanged (admin wins) assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned", "tag:second"}) @@ -739,7 +739,7 @@ func TestTagsAuthKeyWithoutTagCannotRequestTags(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create an auth key WITHOUT tags authKey, err := scenario.CreatePreAuthKey(userID, false, false) @@ -755,7 +755,7 @@ func TestTagsAuthKeyWithoutTagCannotRequestTags(t *testing.T) { require.NoError(t, err) // Login should fail because the auth key has no tags - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) if err != nil { t.Logf("Test 3.1 PASS: Registration correctly rejected: %v", err) assert.ErrorContains(t, err, "requested tags") @@ -768,7 +768,7 @@ func TestTagsAuthKeyWithoutTagCannotRequestTags(t *testing.T) { assert.NoError(c, err) if len(nodes) == 1 { - t.Logf("Node registered with tags: %v (expected rejection)", nodes[0].GetTags()) + t.Logf("Node registered with tags: %v (expected rejection)", nodes[0].Tags) } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "checking node state") @@ -810,7 +810,7 @@ func TestTagsAuthKeyWithoutTagRegisterNoTags(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create an auth key WITHOUT tags authKey, err := scenario.CreatePreAuthKey(userID, false, false) @@ -824,7 +824,7 @@ func TestTagsAuthKeyWithoutTagRegisterNoTags(t *testing.T) { require.NoError(t, err) // Login should succeed - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) require.NoError(t, err) // Verify node has no tags @@ -834,7 +834,7 @@ func TestTagsAuthKeyWithoutTagRegisterNoTags(t *testing.T) { assert.Len(c, nodes, 1) if len(nodes) == 1 { - t.Logf("Node registered with tags: %v", nodes[0].GetTags()) + t.Logf("Node registered with tags: %v", nodes[0].Tags) assertNodeHasNoTagsWithCollect(c, nodes[0]) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "verifying node has no tags") @@ -879,7 +879,7 @@ func TestTagsAuthKeyWithoutTagCannotAddViaCLI(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create an auth key WITHOUT tags authKey, err := scenario.CreatePreAuthKey(userID, true, false) @@ -893,7 +893,7 @@ func TestTagsAuthKeyWithoutTagCannotAddViaCLI(t *testing.T) { require.NoError(t, err) // Initial login - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) require.NoError(t, err) // Wait for initial registration @@ -913,7 +913,7 @@ func TestTagsAuthKeyWithoutTagCannotAddViaCLI(t *testing.T) { command := []string{ "tailscale", "up", "--login-server=" + headscale.GetEndpoint(), - "--authkey=" + authKey.GetKey(), + "--authkey=" + authKey.Key, "--advertise-tags=tag:valid-owned", } _, stderr, err := client.Execute(command) @@ -929,10 +929,10 @@ func TestTagsAuthKeyWithoutTagCannotAddViaCLI(t *testing.T) { assert.NoError(c, err) if len(nodes) == 1 { - if len(nodes[0].GetTags()) == 0 { + if len(nodes[0].Tags) == 0 { t.Logf("Test 3.3 PASS: Tags still empty after CLI attempt") } else { - t.Logf("Test 3.3 FAIL: Tags changed to: %v", nodes[0].GetTags()) + t.Logf("Test 3.3 FAIL: Tags changed to: %v", nodes[0].Tags) assert.Fail(c, "Tags should not have changed") } } @@ -978,7 +978,7 @@ func TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithReset(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create an auth key WITHOUT tags authKey, err := scenario.CreatePreAuthKey(userID, true, false) @@ -992,7 +992,7 @@ func TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithReset(t *testing.T) { require.NoError(t, err) // Initial login - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) require.NoError(t, err) // Wait for initial registration and get node ID @@ -1004,7 +1004,7 @@ func TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithReset(t *testing.T) { assert.Len(c, nodes, 1) if len(nodes) == 1 { - nodeID = nodes[0].GetId() + nodeID = mustParseID(nodes[0].Id) assertNodeHasNoTagsWithCollect(c, nodes[0]) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "waiting for initial registration") @@ -1034,7 +1034,7 @@ func TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithReset(t *testing.T) { command := []string{ "tailscale", "up", "--login-server=" + headscale.GetEndpoint(), - "--authkey=" + authKey.GetKey(), + "--authkey=" + authKey.Key, "--reset", } _, stderr, err := client.Execute(command) @@ -1047,7 +1047,7 @@ func TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithReset(t *testing.T) { assert.Len(c, nodes, 1, "Should have exactly 1 node") if len(nodes) == 1 { - t.Logf("After --reset, server tags are: %v", nodes[0].GetTags()) + t.Logf("After --reset, server tags are: %v", nodes[0].Tags) assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"}) } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "admin tags should be preserved after --reset on server") @@ -1098,7 +1098,7 @@ func TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithEmptyAdvertise(t *testing.T) userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create an auth key WITHOUT tags authKey, err := scenario.CreatePreAuthKey(userID, true, false) @@ -1112,7 +1112,7 @@ func TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithEmptyAdvertise(t *testing.T) require.NoError(t, err) // Initial login - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) require.NoError(t, err) // Wait for initial registration and get node ID @@ -1124,7 +1124,7 @@ func TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithEmptyAdvertise(t *testing.T) assert.Len(c, nodes, 1) if len(nodes) == 1 { - nodeID = nodes[0].GetId() + nodeID = mustParseID(nodes[0].Id) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "waiting for initial registration") @@ -1153,7 +1153,7 @@ func TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithEmptyAdvertise(t *testing.T) command := []string{ "tailscale", "up", "--login-server=" + headscale.GetEndpoint(), - "--authkey=" + authKey.GetKey(), + "--authkey=" + authKey.Key, "--advertise-tags=", } _, stderr, err := client.Execute(command) @@ -1166,7 +1166,7 @@ func TestTagsAuthKeyWithoutTagCLINoOpAfterAdminWithEmptyAdvertise(t *testing.T) assert.Len(c, nodes, 1, "Should have exactly 1 node") if len(nodes) == 1 { - t.Logf("After empty --advertise-tags, server tags are: %v", nodes[0].GetTags()) + t.Logf("After empty --advertise-tags, server tags are: %v", nodes[0].Tags) assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"}) } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "admin tags should be preserved after empty --advertise-tags on server") @@ -1217,7 +1217,7 @@ func TestTagsAuthKeyWithoutTagCLICannotReduceAdminMultiTag(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create an auth key WITHOUT tags authKey, err := scenario.CreatePreAuthKey(userID, true, false) @@ -1231,7 +1231,7 @@ func TestTagsAuthKeyWithoutTagCLICannotReduceAdminMultiTag(t *testing.T) { require.NoError(t, err) // Initial login - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) require.NoError(t, err) // Wait for initial registration and get node ID @@ -1243,7 +1243,7 @@ func TestTagsAuthKeyWithoutTagCLICannotReduceAdminMultiTag(t *testing.T) { assert.Len(c, nodes, 1) if len(nodes) == 1 { - nodeID = nodes[0].GetId() + nodeID = mustParseID(nodes[0].Id) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "waiting for initial registration") @@ -1272,7 +1272,7 @@ func TestTagsAuthKeyWithoutTagCLICannotReduceAdminMultiTag(t *testing.T) { command := []string{ "tailscale", "up", "--login-server=" + headscale.GetEndpoint(), - "--authkey=" + authKey.GetKey(), + "--authkey=" + authKey.Key, "--advertise-tags=tag:valid-owned", } _, stderr, err := client.Execute(command) @@ -1285,7 +1285,7 @@ func TestTagsAuthKeyWithoutTagCLICannotReduceAdminMultiTag(t *testing.T) { assert.Len(c, nodes, 1, "Should have exactly 1 node") if len(nodes) == 1 { - t.Logf("After CLI reduce attempt, server tags are: %v", nodes[0].GetTags()) + t.Logf("After CLI reduce attempt, server tags are: %v", nodes[0].Tags) assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned", "tag:second"}) } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "admin tags should be preserved after CLI reduce attempt on server") @@ -1366,7 +1366,7 @@ func TestTagsUserLoginOwnedTagAtRegistration(t *testing.T) { assert.Len(c, nodes, 1, "Should have exactly 1 node") if len(nodes) == 1 { - t.Logf("Node registered with tags: %v", nodes[0].GetTags()) + t.Logf("Node registered with tags: %v", nodes[0].Tags) assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"}) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "verifying node has advertised tag") @@ -1438,9 +1438,9 @@ func TestTagsUserLoginNonExistentTagAtRegistration(t *testing.T) { t.Logf("Test 1.2 PASS: Registration rejected - no nodes registered") } else { // If a node was registered, it should NOT have the non-existent tag - assert.NotContains(c, nodes[0].GetTags(), "tag:nonexistent", + assert.NotContains(c, nodes[0].Tags, "tag:nonexistent", "Non-existent tag should not be applied to node") - t.Logf("Test 1.2: Node registered with tags: %v (non-existent tag correctly rejected)", nodes[0].GetTags()) + t.Logf("Test 1.2: Node registered with tags: %v (non-existent tag correctly rejected)", nodes[0].Tags) } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "checking node registration result") } @@ -1506,9 +1506,9 @@ func TestTagsUserLoginUnownedTagAtRegistration(t *testing.T) { t.Logf("Test 1.3 PASS: Registration rejected - no nodes registered") } else { // If a node was registered, it should NOT have the unowned tag - assert.NotContains(c, nodes[0].GetTags(), "tag:valid-unowned", + assert.NotContains(c, nodes[0].Tags, "tag:valid-unowned", "Unowned tag should not be applied to node (tag:valid-unowned is owned by other-user)") - t.Logf("Test 1.3: Node registered with tags: %v (unowned tag correctly rejected)", nodes[0].GetTags()) + t.Logf("Test 1.3: Node registered with tags: %v (unowned tag correctly rejected)", nodes[0].Tags) } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "checking node registration result") } @@ -1572,7 +1572,7 @@ func TestTagsUserLoginAddTagViaCLIReauth(t *testing.T) { assert.NoError(c, err) if len(nodes) == 1 { - t.Logf("Initial tags: %v", nodes[0].GetTags()) + t.Logf("Initial tags: %v", nodes[0].Tags) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "checking initial tags") @@ -1593,12 +1593,12 @@ func TestTagsUserLoginAddTagViaCLIReauth(t *testing.T) { assert.NoError(c, err) if len(nodes) >= 1 { - t.Logf("Test 1.4: After CLI, tags are: %v", nodes[0].GetTags()) + t.Logf("Test 1.4: After CLI, tags are: %v", nodes[0].Tags) - if tagsEqual(nodes[0].GetTags(), []string{"tag:valid-owned", "tag:second"}) { + if tagsEqual(nodes[0].Tags, []string{"tag:valid-owned", "tag:second"}) { t.Logf("Test 1.4 PASS: Both tags present after reauth") } else { - t.Logf("Test 1.4: Tags are %v (may require manual reauth completion)", nodes[0].GetTags()) + t.Logf("Test 1.4: Tags are %v (may require manual reauth completion)", nodes[0].Tags) } } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "checking tags after CLI") @@ -1663,7 +1663,7 @@ func TestTagsUserLoginRemoveTagViaCLIReauth(t *testing.T) { assert.NoError(c, err) if len(nodes) == 1 { - t.Logf("Initial tags: %v", nodes[0].GetTags()) + t.Logf("Initial tags: %v", nodes[0].Tags) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "checking initial tags") @@ -1684,9 +1684,9 @@ func TestTagsUserLoginRemoveTagViaCLIReauth(t *testing.T) { assert.NoError(c, err) if len(nodes) >= 1 { - t.Logf("Test 1.5: After CLI, tags are: %v", nodes[0].GetTags()) + t.Logf("Test 1.5: After CLI, tags are: %v", nodes[0].Tags) - if tagsEqual(nodes[0].GetTags(), []string{"tag:valid-owned"}) { + if tagsEqual(nodes[0].Tags, []string{"tag:valid-owned"}) { t.Logf("Test 1.5 PASS: Only one tag after removal") } } @@ -1757,8 +1757,8 @@ func TestTagsUserLoginCLINoOpAfterAdminAssignment(t *testing.T) { assert.Len(c, nodes, 1) if len(nodes) == 1 { - nodeID = nodes[0].GetId() - t.Logf("Step 1: Node %d registered with tags: %v", nodeID, nodes[0].GetTags()) + nodeID = mustParseID(nodes[0].Id) + t.Logf("Step 1: Node %d registered with tags: %v", nodeID, nodes[0].Tags) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "waiting for initial registration") @@ -1772,7 +1772,7 @@ func TestTagsUserLoginCLINoOpAfterAdminAssignment(t *testing.T) { assert.NoError(c, err) if len(nodes) == 1 { - t.Logf("Step 2: After admin assignment, server tags: %v", nodes[0].GetTags()) + t.Logf("Step 2: After admin assignment, server tags: %v", nodes[0].Tags) assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:second"}) } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "verifying admin assignment on server") @@ -1798,7 +1798,7 @@ func TestTagsUserLoginCLINoOpAfterAdminAssignment(t *testing.T) { assert.Len(c, nodes, 1, "Should have exactly 1 node") if len(nodes) == 1 { - t.Logf("Step 3: After CLI, server tags are: %v", nodes[0].GetTags()) + t.Logf("Step 3: After CLI, server tags are: %v", nodes[0].Tags) assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:second"}) } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "admin tags should be preserved - CLI advertise-tags should be no-op on server") @@ -1874,7 +1874,7 @@ func TestTagsUserLoginCLICannotRemoveAdminTags(t *testing.T) { assert.Len(c, nodes, 1) if len(nodes) == 1 { - nodeID = nodes[0].GetId() + nodeID = mustParseID(nodes[0].Id) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "waiting for initial registration") @@ -1888,7 +1888,7 @@ func TestTagsUserLoginCLICannotRemoveAdminTags(t *testing.T) { assert.NoError(c, err) if len(nodes) == 1 { - t.Logf("After admin assignment, server tags: %v", nodes[0].GetTags()) + t.Logf("After admin assignment, server tags: %v", nodes[0].Tags) assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned", "tag:second"}) } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "verifying admin assignment on server") @@ -1914,7 +1914,7 @@ func TestTagsUserLoginCLICannotRemoveAdminTags(t *testing.T) { assert.Len(c, nodes, 1, "Should have exactly 1 node") if len(nodes) == 1 { - t.Logf("Test 1.7: After CLI, server tags are: %v", nodes[0].GetTags()) + t.Logf("Test 1.7: After CLI, server tags are: %v", nodes[0].Tags) assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned", "tag:second"}) } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "admin tags should be preserved - CLI cannot remove them on server") @@ -1965,12 +1965,12 @@ func TestTagsAuthKeyWithTagRequestNonExistentTag(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create a tagged PreAuthKey with tag:valid-owned authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) require.NoError(t, err) - t.Logf("Created tagged PreAuthKey with tags: %v", authKey.GetAclTags()) + t.Logf("Created tagged PreAuthKey with tags: %v", authKey.AclTags) // Create a tailscale client that will try to use --advertise-tags with a NON-EXISTENT tag client, err := scenario.CreateTailscaleNode( @@ -1981,7 +1981,7 @@ func TestTagsAuthKeyWithTagRequestNonExistentTag(t *testing.T) { require.NoError(t, err) // Login should fail because ANY advertise-tags is rejected for PreAuthKey registrations - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) if err != nil { t.Logf("Test 2.7 PASS: Registration correctly rejected with error: %v", err) assert.ErrorContains(t, err, "requested tags") @@ -1993,7 +1993,7 @@ func TestTagsAuthKeyWithTagRequestNonExistentTag(t *testing.T) { assert.NoError(c, err) if len(nodes) == 1 { - t.Logf("Node registered with tags: %v (expected rejection)", nodes[0].GetTags()) + t.Logf("Node registered with tags: %v (expected rejection)", nodes[0].Tags) } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "checking node state") @@ -2035,12 +2035,12 @@ func TestTagsAuthKeyWithTagRequestUnownedTag(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create a tagged PreAuthKey with tag:valid-owned authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) require.NoError(t, err) - t.Logf("Created tagged PreAuthKey with tags: %v", authKey.GetAclTags()) + t.Logf("Created tagged PreAuthKey with tags: %v", authKey.AclTags) // Create a tailscale client that will try to use --advertise-tags with an UNOWNED tag client, err := scenario.CreateTailscaleNode( @@ -2051,7 +2051,7 @@ func TestTagsAuthKeyWithTagRequestUnownedTag(t *testing.T) { require.NoError(t, err) // Login should fail because ANY advertise-tags is rejected for PreAuthKey registrations - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) if err != nil { t.Logf("Test 2.8 PASS: Registration correctly rejected with error: %v", err) assert.ErrorContains(t, err, "requested tags") @@ -2063,7 +2063,7 @@ func TestTagsAuthKeyWithTagRequestUnownedTag(t *testing.T) { assert.NoError(c, err) if len(nodes) == 1 { - t.Logf("Node registered with tags: %v (expected rejection)", nodes[0].GetTags()) + t.Logf("Node registered with tags: %v (expected rejection)", nodes[0].Tags) } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "checking node state") @@ -2109,7 +2109,7 @@ func TestTagsAuthKeyWithoutTagRequestNonExistentTag(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create an auth key WITHOUT tags authKey, err := scenario.CreatePreAuthKey(userID, false, false) @@ -2125,7 +2125,7 @@ func TestTagsAuthKeyWithoutTagRequestNonExistentTag(t *testing.T) { require.NoError(t, err) // Login should fail because ANY advertise-tags is rejected for PreAuthKey registrations - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) if err != nil { t.Logf("Test 3.7 PASS: Registration correctly rejected: %v", err) assert.ErrorContains(t, err, "requested tags") @@ -2137,7 +2137,7 @@ func TestTagsAuthKeyWithoutTagRequestNonExistentTag(t *testing.T) { assert.NoError(c, err) if len(nodes) == 1 { - t.Logf("Node registered with tags: %v (expected rejection)", nodes[0].GetTags()) + t.Logf("Node registered with tags: %v (expected rejection)", nodes[0].Tags) } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "checking node state") @@ -2179,7 +2179,7 @@ func TestTagsAuthKeyWithoutTagRequestUnownedTag(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create an auth key WITHOUT tags authKey, err := scenario.CreatePreAuthKey(userID, false, false) @@ -2195,7 +2195,7 @@ func TestTagsAuthKeyWithoutTagRequestUnownedTag(t *testing.T) { require.NoError(t, err) // Login should fail because ANY advertise-tags is rejected for PreAuthKey registrations - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) if err != nil { t.Logf("Test 3.8 PASS: Registration correctly rejected: %v", err) assert.ErrorContains(t, err, "requested tags") @@ -2207,7 +2207,7 @@ func TestTagsAuthKeyWithoutTagRequestUnownedTag(t *testing.T) { assert.NoError(c, err) if len(nodes) == 1 { - t.Logf("Node registered with tags: %v (expected rejection)", nodes[0].GetTags()) + t.Logf("Node registered with tags: %v (expected rejection)", nodes[0].Tags) } }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.SlowPoll, "checking node state") @@ -2253,7 +2253,7 @@ func TestTagsAdminAPICannotSetNonExistentTag(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create a tagged PreAuthKey to register a node authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) @@ -2266,7 +2266,7 @@ func TestTagsAdminAPICannotSetNonExistentTag(t *testing.T) { ) require.NoError(t, err) - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) require.NoError(t, err) // Wait for registration and get node ID @@ -2278,8 +2278,8 @@ func TestTagsAdminAPICannotSetNonExistentTag(t *testing.T) { assert.Len(c, nodes, 1) if len(nodes) == 1 { - nodeID = nodes[0].GetId() - t.Logf("Node %d registered with tags: %v", nodeID, nodes[0].GetTags()) + nodeID = mustParseID(nodes[0].Id) + t.Logf("Node %d registered with tags: %v", nodeID, nodes[0].Tags) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "waiting for registration") @@ -2325,7 +2325,7 @@ func TestTagsAdminAPICanSetUnownedTag(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create a tagged PreAuthKey to register a node authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) @@ -2338,7 +2338,7 @@ func TestTagsAdminAPICanSetUnownedTag(t *testing.T) { ) require.NoError(t, err) - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) require.NoError(t, err) // Wait for registration and get node ID @@ -2350,8 +2350,8 @@ func TestTagsAdminAPICanSetUnownedTag(t *testing.T) { assert.Len(c, nodes, 1) if len(nodes) == 1 { - nodeID = nodes[0].GetId() - t.Logf("Node %d registered with tags: %v", nodeID, nodes[0].GetTags()) + nodeID = mustParseID(nodes[0].Id) + t.Logf("Node %d registered with tags: %v", nodeID, nodes[0].Tags) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "waiting for registration") @@ -2413,7 +2413,7 @@ func TestTagsAdminAPICannotRemoveAllTags(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create a tagged PreAuthKey to register a node authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) @@ -2426,7 +2426,7 @@ func TestTagsAdminAPICannotRemoveAllTags(t *testing.T) { ) require.NoError(t, err) - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) require.NoError(t, err) // Wait for registration and get node ID @@ -2438,8 +2438,8 @@ func TestTagsAdminAPICannotRemoveAllTags(t *testing.T) { assert.Len(c, nodes, 1) if len(nodes) == 1 { - nodeID = nodes[0].GetId() - t.Logf("Node %d registered with tags: %v", nodeID, nodes[0].GetTags()) + nodeID = mustParseID(nodes[0].Id) + t.Logf("Node %d registered with tags: %v", nodeID, nodes[0].Tags) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "waiting for registration") @@ -2560,7 +2560,7 @@ func TestTagsIssue2978ReproTagReplacement(t *testing.T) { assert.Len(c, nodes, 1) if len(nodes) == 1 { - nodeID = nodes[0].GetId() + nodeID = mustParseID(nodes[0].Id) assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"}) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "waiting for initial registration") @@ -2735,7 +2735,7 @@ func TestTagsAdminAPICannotSetInvalidFormat(t *testing.T) { userMap, err := headscale.MapUsers() require.NoError(t, err) - userID := userMap[tagTestUser].GetId() + userID := mustParseID(userMap[tagTestUser].Id) // Create a tagged PreAuthKey to register a node authKey, err := scenario.CreatePreAuthKeyWithTags(userID, false, false, []string{"tag:valid-owned"}) @@ -2748,7 +2748,7 @@ func TestTagsAdminAPICannotSetInvalidFormat(t *testing.T) { ) require.NoError(t, err) - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) require.NoError(t, err) // Wait for registration and get node ID @@ -2760,8 +2760,8 @@ func TestTagsAdminAPICannotSetInvalidFormat(t *testing.T) { assert.Len(c, nodes, 1) if len(nodes) == 1 { - nodeID = nodes[0].GetId() - t.Logf("Node %d registered with tags: %v", nodeID, nodes[0].GetTags()) + nodeID = mustParseID(nodes[0].Id) + t.Logf("Node %d registered with tags: %v", nodeID, nodes[0].Tags) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "waiting for registration") @@ -2855,7 +2855,7 @@ func TestTagsUserLoginReauthWithEmptyTagsRemovesAllTags(t *testing.T) { require.NoError(t, err) // Verify initial tags - var initialNodeID uint64 + var initialNodeID string assert.EventuallyWithT(t, func(c *assert.CollectT) { nodes, err := headscale.ListNodes() @@ -2864,9 +2864,9 @@ func TestTagsUserLoginReauthWithEmptyTagsRemovesAllTags(t *testing.T) { if len(nodes) == 1 { node := nodes[0] - initialNodeID = node.GetId() - t.Logf("Initial state - Node ID: %d, Tags: %v, User: %s", - node.GetId(), node.GetTags(), node.GetUser().GetName()) + initialNodeID = node.Id + t.Logf("Initial state - Node ID: %s, Tags: %v, User: %s", + node.Id, node.Tags, node.User.Name) // Verify node has the expected tags assertNodeHasTagsWithCollect(c, node, []string{"tag:valid-owned", "tag:second"}) @@ -2926,25 +2926,25 @@ func TestTagsUserLoginReauthWithEmptyTagsRemovesAllTags(t *testing.T) { if len(nodes) >= 1 { node := nodes[0] - t.Logf("After reauth - Node ID: %d, Tags: %v, User: %s", - node.GetId(), node.GetTags(), node.GetUser().GetName()) + t.Logf("After reauth - Node ID: %s, Tags: %v, User: %s", + node.Id, node.Tags, node.User.Name) // Assert: Node should have NO tags assertNodeHasNoTagsWithCollect(c, node) // Assert: Node should be owned by the user (not tagged-devices) - assert.Equal(c, tagTestUser, node.GetUser().GetName(), + assert.Equal(c, tagTestUser, node.User.Name, "Node ownership should return to user %s after untagging", tagTestUser) // Verify the node ID is still the same (not a new registration) - assert.Equal(c, initialNodeID, node.GetId(), + assert.Equal(c, initialNodeID, node.Id, "Node ID should remain the same after reauth") - if len(node.GetTags()) == 0 && node.GetUser().GetName() == tagTestUser { + if len(node.Tags) == 0 && node.User.Name == tagTestUser { t.Logf("Test #2979 (%s) PASS: Node successfully untagged and ownership returned to user", tc.name) } else { t.Logf("Test #2979 (%s) FAIL: Expected no tags and user=%s, got tags=%v user=%s", - tc.name, tagTestUser, node.GetTags(), node.GetUser().GetName()) + tc.name, tagTestUser, node.Tags, node.User.Name) } } }, integrationutil.HAConvergeTimeout, 1*time.Second, "verifying tags removed and ownership returned") @@ -2994,7 +2994,7 @@ func TestTagsAuthKeyWithoutUserInheritsTags(t *testing.T) { Tags: []string{"tag:valid-owned"}, }) require.NoError(t, err) - t.Logf("Created tags-only PreAuthKey with tags: %v", authKey.GetAclTags()) + t.Logf("Created tags-only PreAuthKey with tags: %v", authKey.AclTags) // Create a tailscale client WITHOUT --advertise-tags client, err := scenario.CreateTailscaleNode( @@ -3005,7 +3005,7 @@ func TestTagsAuthKeyWithoutUserInheritsTags(t *testing.T) { require.NoError(t, err) // Login with the tags-only auth key - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) require.NoError(t, err) // Wait for node to be registered and verify it has the key's tags @@ -3017,7 +3017,7 @@ func TestTagsAuthKeyWithoutUserInheritsTags(t *testing.T) { if len(nodes) == 1 { node := nodes[0] - t.Logf("Node registered with tags: %v", node.GetTags()) + t.Logf("Node registered with tags: %v", node.Tags) assertNodeHasTagsWithCollect(c, node, []string{"tag:valid-owned"}) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "verifying node inherited tags from auth key") @@ -3065,7 +3065,7 @@ func TestTagsAuthKeyWithoutUserRejectsAdvertisedTags(t *testing.T) { Tags: []string{"tag:valid-owned"}, }) require.NoError(t, err) - t.Logf("Created tags-only PreAuthKey with tags: %v", authKey.GetAclTags()) + t.Logf("Created tags-only PreAuthKey with tags: %v", authKey.AclTags) // Create a tailscale client WITH --advertise-tags for a DIFFERENT tag client, err := scenario.CreateTailscaleNode( @@ -3076,7 +3076,7 @@ func TestTagsAuthKeyWithoutUserRejectsAdvertisedTags(t *testing.T) { require.NoError(t, err) // Login should fail because ANY advertise-tags is rejected for PreAuthKey registrations - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) if err != nil { t.Logf("Test 5.2 PASS: Registration correctly rejected with error: %v", err) assert.ErrorContains(t, err, "requested tags") @@ -3134,7 +3134,7 @@ func TestTagsAuthKeyConvertToUserViaCLIRegister(t *testing.T) { Tags: []string{"tag:valid-owned"}, }) require.NoError(t, err) - t.Logf("Created tags-only PreAuthKey (no user) with tags: %v", authKey.GetAclTags()) + t.Logf("Created tags-only PreAuthKey (no user) with tags: %v", authKey.AclTags) client, err := scenario.CreateTailscaleNode( "head", @@ -3142,7 +3142,7 @@ func TestTagsAuthKeyConvertToUserViaCLIRegister(t *testing.T) { ) require.NoError(t, err) - err = client.Login(headscale.GetEndpoint(), authKey.GetKey()) + err = client.Login(headscale.GetEndpoint(), authKey.Key) require.NoError(t, err) err = client.WaitForRunning(integrationutil.PeerSyncTimeout()) @@ -3156,7 +3156,7 @@ func TestTagsAuthKeyConvertToUserViaCLIRegister(t *testing.T) { if len(nodes) == 1 { assertNodeHasTagsWithCollect(c, nodes[0], []string{"tag:valid-owned"}) - t.Logf("Initial state - Node ID: %d, Tags: %v", nodes[0].GetId(), nodes[0].GetTags()) + t.Logf("Initial state - Node ID: %s, Tags: %v", nodes[0].Id, nodes[0].Tags) } }, integrationutil.StatusReadyTimeout, integrationutil.SlowPoll, "node should be tagged initially") @@ -3196,10 +3196,10 @@ func TestTagsAuthKeyConvertToUserViaCLIRegister(t *testing.T) { if len(nodes) == 1 { assertNodeHasNoTagsWithCollect(c, nodes[0]) - assert.Equal(c, tagTestUser, nodes[0].GetUser().GetName(), + assert.Equal(c, tagTestUser, nodes[0].User.Name, "Node ownership should be returned to user after untagging") - t.Logf("After conversion - Node ID: %d, Tags: %v, User: %s", - nodes[0].GetId(), nodes[0].GetTags(), nodes[0].GetUser().GetName()) + t.Logf("After conversion - Node ID: %s, Tags: %v, User: %s", + nodes[0].Id, nodes[0].Tags, nodes[0].User.Name) } }, integrationutil.HAConvergeTimeout, 1*time.Second, "node should be user-owned after conversion via CLI register") } diff --git a/integration/tsric_test.go b/integration/tsric_test.go index 757e08b6d..73a147403 100644 --- a/integration/tsric_test.go +++ b/integration/tsric_test.go @@ -73,8 +73,8 @@ func TestTailscaleRustAxum(t *testing.T) { var userID uint64 for _, u := range users { - if u.GetName() == "user1" { //nolint:goconst - userID = u.GetId() + if u.Name == "user1" { //nolint:goconst + userID = mustParseID(u.Id) break } @@ -101,7 +101,7 @@ func TestTailscaleRustAxum(t *testing.T) { tsrsOpts := []tsric.Option{ tsric.WithNetwork(network), tsric.WithHeadscaleURL(headscaleEndpoint), - tsric.WithAuthKey(pak.GetKey()), + tsric.WithAuthKey(pak.Key), tsric.WithExtraHosts([]string{headscaleHostname + ":" + headscaleIP}), } @@ -142,8 +142,8 @@ func TestTailscaleRustAxum(t *testing.T) { // Find the tailscale-rs node by hostname prefix for _, n := range nodes { - if strings.HasPrefix(n.GetGivenName(), "tsrs-") { - addrs := n.GetIpAddresses() + if strings.HasPrefix(n.GivenName, "tsrs-") { + addrs := n.IpAddresses if len(addrs) > 0 { rustNodeIPv4 = addrs[0] } @@ -152,7 +152,7 @@ func TestTailscaleRustAxum(t *testing.T) { rustNodeIPv6 = addrs[1] } - rustNodeName = n.GetGivenName() + rustNodeName = n.GivenName } } From 560b6d81ad237252cb89867885409d7a2fa87eae Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 19 Jun 2026 06:14:36 +0000 Subject: [PATCH 061/127] hscontrol: remove the proto, gRPC and grpc-gateway stack With the v1 API served by Huma and the CLI on the HTTP client, nothing uses the gRPC service or its generated code. Delete proto/, the generated gen/go and gen/openapiv2, grpcv1.go, the buf config, the proto .Proto() helpers and gRPC config, and the proto build tooling from the flake and CI. --- .github/workflows/check-generated.yml | 2 - .github/workflows/lint.yml | 14 - buf.gen.yaml | 21 - cmd/dev/main.go | 20 +- config-example.yaml | 16 - flake.nix | 43 - gen/go/headscale/v1/apikey.pb.go | 537 ---- gen/go/headscale/v1/auth.pb.go | 351 --- gen/go/headscale/v1/device.pb.go | 890 ------- gen/go/headscale/v1/headscale.pb.go | 318 --- gen/go/headscale/v1/headscale.pb.gw.go | 2201 ----------------- gen/go/headscale/v1/headscale_grpc.pb.go | 1199 --------- gen/go/headscale/v1/node.pb.go | 1379 ----------- gen/go/headscale/v1/policy.pb.go | 363 --- gen/go/headscale/v1/preauthkey.pb.go | 596 ----- gen/go/headscale/v1/user.pb.go | 615 ----- .../headscale/v1/apikey.swagger.json | 44 - gen/openapiv2/headscale/v1/auth.swagger.json | 44 - .../headscale/v1/device.swagger.json | 44 - .../headscale/v1/headscale.swagger.json | 1535 ------------ gen/openapiv2/headscale/v1/node.swagger.json | 44 - .../headscale/v1/policy.swagger.json | 44 - .../headscale/v1/preauthkey.swagger.json | 44 - gen/openapiv2/headscale/v1/user.swagger.json | 44 - hscontrol/auth_tags_test.go | 5 +- hscontrol/db/preauth_keys_test.go | 2 +- hscontrol/grpcv1.go | 944 ------- hscontrol/grpcv1_test.go | 819 ------ hscontrol/state/state.go | 4 +- hscontrol/types/api_key.go | 24 - hscontrol/types/config.go | 7 - hscontrol/types/node.go | 81 +- hscontrol/types/node_test.go | 51 - hscontrol/types/preauth_key.go | 62 - hscontrol/types/users.go | 24 - proto/buf.lock | 18 - proto/buf.yaml | 12 - proto/headscale/v1/apikey.proto | 35 - proto/headscale/v1/auth.proto | 26 - proto/headscale/v1/device.proto | 76 - proto/headscale/v1/headscale.proto | 256 -- proto/headscale/v1/node.proto | 144 -- proto/headscale/v1/policy.proto | 23 - proto/headscale/v1/preauthkey.proto | 49 - proto/headscale/v1/user.proto | 44 - 45 files changed, 17 insertions(+), 13097 deletions(-) delete mode 100644 buf.gen.yaml delete mode 100644 gen/go/headscale/v1/apikey.pb.go delete mode 100644 gen/go/headscale/v1/auth.pb.go delete mode 100644 gen/go/headscale/v1/device.pb.go delete mode 100644 gen/go/headscale/v1/headscale.pb.go delete mode 100644 gen/go/headscale/v1/headscale.pb.gw.go delete mode 100644 gen/go/headscale/v1/headscale_grpc.pb.go delete mode 100644 gen/go/headscale/v1/node.pb.go delete mode 100644 gen/go/headscale/v1/policy.pb.go delete mode 100644 gen/go/headscale/v1/preauthkey.pb.go delete mode 100644 gen/go/headscale/v1/user.pb.go delete mode 100644 gen/openapiv2/headscale/v1/apikey.swagger.json delete mode 100644 gen/openapiv2/headscale/v1/auth.swagger.json delete mode 100644 gen/openapiv2/headscale/v1/device.swagger.json delete mode 100644 gen/openapiv2/headscale/v1/headscale.swagger.json delete mode 100644 gen/openapiv2/headscale/v1/node.swagger.json delete mode 100644 gen/openapiv2/headscale/v1/policy.swagger.json delete mode 100644 gen/openapiv2/headscale/v1/preauthkey.swagger.json delete mode 100644 gen/openapiv2/headscale/v1/user.swagger.json delete mode 100644 hscontrol/grpcv1.go delete mode 100644 hscontrol/grpcv1_test.go delete mode 100644 proto/buf.lock delete mode 100644 proto/buf.yaml delete mode 100644 proto/headscale/v1/apikey.proto delete mode 100644 proto/headscale/v1/auth.proto delete mode 100644 proto/headscale/v1/device.proto delete mode 100644 proto/headscale/v1/headscale.proto delete mode 100644 proto/headscale/v1/node.proto delete mode 100644 proto/headscale/v1/policy.proto delete mode 100644 proto/headscale/v1/preauthkey.proto delete mode 100644 proto/headscale/v1/user.proto diff --git a/.github/workflows/check-generated.yml b/.github/workflows/check-generated.yml index 71d721fa0..4b463fccd 100644 --- a/.github/workflows/check-generated.yml +++ b/.github/workflows/check-generated.yml @@ -28,8 +28,6 @@ jobs: - '*.nix' - 'go.*' - '**/*.go' - - '**/*.proto' - - 'buf.gen.yaml' - 'tools/**' - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 if: steps.changed-files.outputs.files == 'true' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0f493783b..ed809ac59 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -77,17 +77,3 @@ jobs: if: steps.changed-files.outputs.files == 'true' run: nix develop --fallback --command -- prettier --no-error-on-unmatched-pattern --ignore-unknown --check **/*.{ts,js,md,yaml,yml,sass,css,scss,html} - - proto-lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', - '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} - - - name: Buf lint - run: nix develop --fallback --command -- buf lint proto diff --git a/buf.gen.yaml b/buf.gen.yaml deleted file mode 100644 index d7b832ab3..000000000 --- a/buf.gen.yaml +++ /dev/null @@ -1,21 +0,0 @@ -version: v1 -plugins: - - name: go - out: gen/go - opt: - - paths=source_relative - - name: go-grpc - out: gen/go - opt: - - paths=source_relative - - name: grpc-gateway - out: gen/go - opt: - - paths=source_relative - - generate_unbound_methods=true - # - name: gorm - # out: gen/go - # opt: - # - paths=source_relative,enums=string,gateway=true - - name: openapiv2 - out: gen/openapiv2 diff --git a/cmd/dev/main.go b/cmd/dev/main.go index b3c7a8407..a1953123b 100644 --- a/cmd/dev/main.go +++ b/cmd/dev/main.go @@ -28,17 +28,14 @@ var errHealthTimeout = errors.New("health check timed out") var errEmptyAuthKey = errors.New("empty auth key in response") -// maxDevPort is the highest --port value that keeps both the derived -// metrics port (port+1010) and gRPC port (port+42363) inside the valid -// 1..65535 TCP range. -const maxDevPort = 23172 +// maxDevPort is the highest --port value that keeps the derived metrics +// port (port+1010) inside the valid 1..65535 TCP range. +const maxDevPort = 64525 const devConfig = `--- server_url: http://127.0.0.1:%d listen_addr: 127.0.0.1:%d metrics_listen_addr: 127.0.0.1:%d -grpc_listen_addr: 127.0.0.1:%d -grpc_allow_insecure: true noise: private_key_path: %s/noise_private.key @@ -83,7 +80,7 @@ func main() { if *port < 1 || *port > maxDevPort { log.Fatalf( - "--port must be in 1..%d (higher values overflow the derived gRPC port); got %d", + "--port must be in 1..%d (higher values overflow the derived metrics port); got %d", maxDevPort, *port, ) } @@ -101,7 +98,6 @@ func main() { func run() error { metricsPort := *port + 1010 // default 9090 - grpcPort := *port + 42363 // default 50443 tmpDir, err := os.MkdirTemp("", "headscale-dev-") if err != nil { @@ -114,8 +110,9 @@ func run() error { // Write config. configPath := filepath.Join(tmpDir, "config.yaml") - configContent := fmt.Sprintf(devConfig, - *port, *port, metricsPort, grpcPort, + configContent := fmt.Sprintf( + devConfig, + *port, *port, metricsPort, tmpDir, tmpDir, tmpDir, ) @@ -193,7 +190,8 @@ func run() error { } // Print banner. - fmt.Printf(` + fmt.Printf( + ` === Headscale Dev Environment === Server: http://127.0.0.1:%d Metrics: http://127.0.0.1:%d diff --git a/config-example.yaml b/config-example.yaml index 22b9f3496..28fc617d3 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -23,22 +23,6 @@ listen_addr: 127.0.0.1:8080 # Use an empty value to disable the metrics listener. metrics_listen_addr: 127.0.0.1:9090 -# Address to listen for gRPC. -# gRPC is used for controlling a headscale server -# remotely with the CLI -# Note: Remote access _only_ works if you have -# valid certificates. -# -# For production: -# grpc_listen_addr: 0.0.0.0:50443 -grpc_listen_addr: 127.0.0.1:50443 - -# Allow the gRPC admin interface to run in INSECURE -# mode. This is not recommended as the traffic will -# be unencrypted. Only enable if you know what you -# are doing. -grpc_allow_insecure: false - # CIDR(s) of reverse proxies (e.g. 127.0.0.1/32) whose # True-Client-IP, X-Real-IP and X-Forwarded-For headers should # be honoured. Empty (default) ignores those headers; setting diff --git a/flake.nix b/flake.nix index b948dde39..78daf7427 100644 --- a/flake.nix +++ b/flake.nix @@ -66,40 +66,6 @@ subPackages = [ "cmd/hi" ]; }; - protoc-gen-grpc-gateway = buildGo rec { - pname = "grpc-gateway"; - version = "2.29.0"; - - src = pkgs.fetchFromGitHub { - owner = "grpc-ecosystem"; - repo = "grpc-gateway"; - rev = "v${version}"; - sha256 = "sha256-d9OIIGttyMBSNgpS6mbR5JEIm13qGu2gFHJazJAexdw="; - }; - - vendorHash = "sha256-p51yD+v8+rPs+ztlX7r0VQ4XlwUkxu+PxgknKEvH00k="; - - nativeBuildInputs = [ pkgs.installShellFiles ]; - - subPackages = [ "protoc-gen-grpc-gateway" "protoc-gen-openapiv2" ]; - }; - - protobuf-language-server = buildGo rec { - pname = "protobuf-language-server"; - version = "ab4c128"; - - src = pkgs.fetchFromGitHub { - owner = "lasorda"; - repo = "protobuf-language-server"; - rev = "ab4c128f00774d51bd6d1f4cfa735f4b7c8619e3"; - sha256 = "sha256-yF6kG+qTRxVO/qp2V9HgTyFBeOm5RQzeqdZFrdidwxM="; - }; - - vendorHash = "sha256-4nTpKBe7ekJsfQf+P6edT/9Vp2SBYbKz1ITawD3bhkI="; - - subPackages = [ "." ]; - }; - # Build golangci-lint with stock Go 1.26 (upstream uses hardcoded Go # version); it does not build against the pinned 1.26.4. golangci-lint = buildGo rec { @@ -195,15 +161,6 @@ # 'dot' is needed for pprof graphs # go tool pprof -http=: graphviz - - # Protobuf dependencies - protobuf - protoc-gen-go - protoc-gen-go-grpc - protoc-gen-grpc-gateway - buf - clang-tools # clang-format - protobuf-language-server ] ++ lib.optionals pkgs.stdenv.isLinux [ traceroute ]; diff --git a/gen/go/headscale/v1/apikey.pb.go b/gen/go/headscale/v1/apikey.pb.go deleted file mode 100644 index 0c8557384..000000000 --- a/gen/go/headscale/v1/apikey.pb.go +++ /dev/null @@ -1,537 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: headscale/v1/apikey.proto - -package v1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type ApiKey struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` - Prefix string `protobuf:"bytes,2,opt,name=prefix,proto3" json:"prefix,omitempty"` - Expiration *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=expiration,proto3" json:"expiration,omitempty"` - CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - LastSeen *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=last_seen,json=lastSeen,proto3" json:"last_seen,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ApiKey) Reset() { - *x = ApiKey{} - mi := &file_headscale_v1_apikey_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ApiKey) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ApiKey) ProtoMessage() {} - -func (x *ApiKey) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_apikey_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ApiKey.ProtoReflect.Descriptor instead. -func (*ApiKey) Descriptor() ([]byte, []int) { - return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{0} -} - -func (x *ApiKey) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -func (x *ApiKey) GetPrefix() string { - if x != nil { - return x.Prefix - } - return "" -} - -func (x *ApiKey) GetExpiration() *timestamppb.Timestamp { - if x != nil { - return x.Expiration - } - return nil -} - -func (x *ApiKey) GetCreatedAt() *timestamppb.Timestamp { - if x != nil { - return x.CreatedAt - } - return nil -} - -func (x *ApiKey) GetLastSeen() *timestamppb.Timestamp { - if x != nil { - return x.LastSeen - } - return nil -} - -type CreateApiKeyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Expiration *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=expiration,proto3" json:"expiration,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CreateApiKeyRequest) Reset() { - *x = CreateApiKeyRequest{} - mi := &file_headscale_v1_apikey_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CreateApiKeyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CreateApiKeyRequest) ProtoMessage() {} - -func (x *CreateApiKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_apikey_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CreateApiKeyRequest.ProtoReflect.Descriptor instead. -func (*CreateApiKeyRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{1} -} - -func (x *CreateApiKeyRequest) GetExpiration() *timestamppb.Timestamp { - if x != nil { - return x.Expiration - } - return nil -} - -type CreateApiKeyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - ApiKey string `protobuf:"bytes,1,opt,name=api_key,json=apiKey,proto3" json:"api_key,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CreateApiKeyResponse) Reset() { - *x = CreateApiKeyResponse{} - mi := &file_headscale_v1_apikey_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CreateApiKeyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CreateApiKeyResponse) ProtoMessage() {} - -func (x *CreateApiKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_apikey_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CreateApiKeyResponse.ProtoReflect.Descriptor instead. -func (*CreateApiKeyResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{2} -} - -func (x *CreateApiKeyResponse) GetApiKey() string { - if x != nil { - return x.ApiKey - } - return "" -} - -type ExpireApiKeyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Prefix string `protobuf:"bytes,1,opt,name=prefix,proto3" json:"prefix,omitempty"` - Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExpireApiKeyRequest) Reset() { - *x = ExpireApiKeyRequest{} - mi := &file_headscale_v1_apikey_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExpireApiKeyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExpireApiKeyRequest) ProtoMessage() {} - -func (x *ExpireApiKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_apikey_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExpireApiKeyRequest.ProtoReflect.Descriptor instead. -func (*ExpireApiKeyRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{3} -} - -func (x *ExpireApiKeyRequest) GetPrefix() string { - if x != nil { - return x.Prefix - } - return "" -} - -func (x *ExpireApiKeyRequest) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -type ExpireApiKeyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExpireApiKeyResponse) Reset() { - *x = ExpireApiKeyResponse{} - mi := &file_headscale_v1_apikey_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExpireApiKeyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExpireApiKeyResponse) ProtoMessage() {} - -func (x *ExpireApiKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_apikey_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExpireApiKeyResponse.ProtoReflect.Descriptor instead. -func (*ExpireApiKeyResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{4} -} - -type ListApiKeysRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListApiKeysRequest) Reset() { - *x = ListApiKeysRequest{} - mi := &file_headscale_v1_apikey_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListApiKeysRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListApiKeysRequest) ProtoMessage() {} - -func (x *ListApiKeysRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_apikey_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListApiKeysRequest.ProtoReflect.Descriptor instead. -func (*ListApiKeysRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{5} -} - -type ListApiKeysResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - ApiKeys []*ApiKey `protobuf:"bytes,1,rep,name=api_keys,json=apiKeys,proto3" json:"api_keys,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListApiKeysResponse) Reset() { - *x = ListApiKeysResponse{} - mi := &file_headscale_v1_apikey_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListApiKeysResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListApiKeysResponse) ProtoMessage() {} - -func (x *ListApiKeysResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_apikey_proto_msgTypes[6] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListApiKeysResponse.ProtoReflect.Descriptor instead. -func (*ListApiKeysResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{6} -} - -func (x *ListApiKeysResponse) GetApiKeys() []*ApiKey { - if x != nil { - return x.ApiKeys - } - return nil -} - -type DeleteApiKeyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Prefix string `protobuf:"bytes,1,opt,name=prefix,proto3" json:"prefix,omitempty"` - Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteApiKeyRequest) Reset() { - *x = DeleteApiKeyRequest{} - mi := &file_headscale_v1_apikey_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteApiKeyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteApiKeyRequest) ProtoMessage() {} - -func (x *DeleteApiKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_apikey_proto_msgTypes[7] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteApiKeyRequest.ProtoReflect.Descriptor instead. -func (*DeleteApiKeyRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{7} -} - -func (x *DeleteApiKeyRequest) GetPrefix() string { - if x != nil { - return x.Prefix - } - return "" -} - -func (x *DeleteApiKeyRequest) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -type DeleteApiKeyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteApiKeyResponse) Reset() { - *x = DeleteApiKeyResponse{} - mi := &file_headscale_v1_apikey_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteApiKeyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteApiKeyResponse) ProtoMessage() {} - -func (x *DeleteApiKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_apikey_proto_msgTypes[8] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteApiKeyResponse.ProtoReflect.Descriptor instead. -func (*DeleteApiKeyResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_apikey_proto_rawDescGZIP(), []int{8} -} - -var File_headscale_v1_apikey_proto protoreflect.FileDescriptor - -const file_headscale_v1_apikey_proto_rawDesc = "" + - "\n" + - "\x19headscale/v1/apikey.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xe0\x01\n" + - "\x06ApiKey\x12\x0e\n" + - "\x02id\x18\x01 \x01(\x04R\x02id\x12\x16\n" + - "\x06prefix\x18\x02 \x01(\tR\x06prefix\x12:\n" + - "\n" + - "expiration\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "expiration\x129\n" + - "\n" + - "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x127\n" + - "\tlast_seen\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\blastSeen\"Q\n" + - "\x13CreateApiKeyRequest\x12:\n" + - "\n" + - "expiration\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "expiration\"/\n" + - "\x14CreateApiKeyResponse\x12\x17\n" + - "\aapi_key\x18\x01 \x01(\tR\x06apiKey\"=\n" + - "\x13ExpireApiKeyRequest\x12\x16\n" + - "\x06prefix\x18\x01 \x01(\tR\x06prefix\x12\x0e\n" + - "\x02id\x18\x02 \x01(\x04R\x02id\"\x16\n" + - "\x14ExpireApiKeyResponse\"\x14\n" + - "\x12ListApiKeysRequest\"F\n" + - "\x13ListApiKeysResponse\x12/\n" + - "\bapi_keys\x18\x01 \x03(\v2\x14.headscale.v1.ApiKeyR\aapiKeys\"=\n" + - "\x13DeleteApiKeyRequest\x12\x16\n" + - "\x06prefix\x18\x01 \x01(\tR\x06prefix\x12\x0e\n" + - "\x02id\x18\x02 \x01(\x04R\x02id\"\x16\n" + - "\x14DeleteApiKeyResponseB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3" - -var ( - file_headscale_v1_apikey_proto_rawDescOnce sync.Once - file_headscale_v1_apikey_proto_rawDescData []byte -) - -func file_headscale_v1_apikey_proto_rawDescGZIP() []byte { - file_headscale_v1_apikey_proto_rawDescOnce.Do(func() { - file_headscale_v1_apikey_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_apikey_proto_rawDesc), len(file_headscale_v1_apikey_proto_rawDesc))) - }) - return file_headscale_v1_apikey_proto_rawDescData -} - -var file_headscale_v1_apikey_proto_msgTypes = make([]protoimpl.MessageInfo, 9) -var file_headscale_v1_apikey_proto_goTypes = []any{ - (*ApiKey)(nil), // 0: headscale.v1.ApiKey - (*CreateApiKeyRequest)(nil), // 1: headscale.v1.CreateApiKeyRequest - (*CreateApiKeyResponse)(nil), // 2: headscale.v1.CreateApiKeyResponse - (*ExpireApiKeyRequest)(nil), // 3: headscale.v1.ExpireApiKeyRequest - (*ExpireApiKeyResponse)(nil), // 4: headscale.v1.ExpireApiKeyResponse - (*ListApiKeysRequest)(nil), // 5: headscale.v1.ListApiKeysRequest - (*ListApiKeysResponse)(nil), // 6: headscale.v1.ListApiKeysResponse - (*DeleteApiKeyRequest)(nil), // 7: headscale.v1.DeleteApiKeyRequest - (*DeleteApiKeyResponse)(nil), // 8: headscale.v1.DeleteApiKeyResponse - (*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp -} -var file_headscale_v1_apikey_proto_depIdxs = []int32{ - 9, // 0: headscale.v1.ApiKey.expiration:type_name -> google.protobuf.Timestamp - 9, // 1: headscale.v1.ApiKey.created_at:type_name -> google.protobuf.Timestamp - 9, // 2: headscale.v1.ApiKey.last_seen:type_name -> google.protobuf.Timestamp - 9, // 3: headscale.v1.CreateApiKeyRequest.expiration:type_name -> google.protobuf.Timestamp - 0, // 4: headscale.v1.ListApiKeysResponse.api_keys:type_name -> headscale.v1.ApiKey - 5, // [5:5] is the sub-list for method output_type - 5, // [5:5] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name -} - -func init() { file_headscale_v1_apikey_proto_init() } -func file_headscale_v1_apikey_proto_init() { - if File_headscale_v1_apikey_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_apikey_proto_rawDesc), len(file_headscale_v1_apikey_proto_rawDesc)), - NumEnums: 0, - NumMessages: 9, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_headscale_v1_apikey_proto_goTypes, - DependencyIndexes: file_headscale_v1_apikey_proto_depIdxs, - MessageInfos: file_headscale_v1_apikey_proto_msgTypes, - }.Build() - File_headscale_v1_apikey_proto = out.File - file_headscale_v1_apikey_proto_goTypes = nil - file_headscale_v1_apikey_proto_depIdxs = nil -} diff --git a/gen/go/headscale/v1/auth.pb.go b/gen/go/headscale/v1/auth.pb.go deleted file mode 100644 index 2d6bf779e..000000000 --- a/gen/go/headscale/v1/auth.pb.go +++ /dev/null @@ -1,351 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: headscale/v1/auth.proto - -package v1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type AuthRegisterRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` - AuthId string `protobuf:"bytes,2,opt,name=auth_id,json=authId,proto3" json:"auth_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AuthRegisterRequest) Reset() { - *x = AuthRegisterRequest{} - mi := &file_headscale_v1_auth_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AuthRegisterRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AuthRegisterRequest) ProtoMessage() {} - -func (x *AuthRegisterRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_auth_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AuthRegisterRequest.ProtoReflect.Descriptor instead. -func (*AuthRegisterRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_auth_proto_rawDescGZIP(), []int{0} -} - -func (x *AuthRegisterRequest) GetUser() string { - if x != nil { - return x.User - } - return "" -} - -func (x *AuthRegisterRequest) GetAuthId() string { - if x != nil { - return x.AuthId - } - return "" -} - -type AuthRegisterResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AuthRegisterResponse) Reset() { - *x = AuthRegisterResponse{} - mi := &file_headscale_v1_auth_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AuthRegisterResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AuthRegisterResponse) ProtoMessage() {} - -func (x *AuthRegisterResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_auth_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AuthRegisterResponse.ProtoReflect.Descriptor instead. -func (*AuthRegisterResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_auth_proto_rawDescGZIP(), []int{1} -} - -func (x *AuthRegisterResponse) GetNode() *Node { - if x != nil { - return x.Node - } - return nil -} - -type AuthApproveRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - AuthId string `protobuf:"bytes,1,opt,name=auth_id,json=authId,proto3" json:"auth_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AuthApproveRequest) Reset() { - *x = AuthApproveRequest{} - mi := &file_headscale_v1_auth_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AuthApproveRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AuthApproveRequest) ProtoMessage() {} - -func (x *AuthApproveRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_auth_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AuthApproveRequest.ProtoReflect.Descriptor instead. -func (*AuthApproveRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_auth_proto_rawDescGZIP(), []int{2} -} - -func (x *AuthApproveRequest) GetAuthId() string { - if x != nil { - return x.AuthId - } - return "" -} - -type AuthApproveResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AuthApproveResponse) Reset() { - *x = AuthApproveResponse{} - mi := &file_headscale_v1_auth_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AuthApproveResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AuthApproveResponse) ProtoMessage() {} - -func (x *AuthApproveResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_auth_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AuthApproveResponse.ProtoReflect.Descriptor instead. -func (*AuthApproveResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_auth_proto_rawDescGZIP(), []int{3} -} - -type AuthRejectRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - AuthId string `protobuf:"bytes,1,opt,name=auth_id,json=authId,proto3" json:"auth_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AuthRejectRequest) Reset() { - *x = AuthRejectRequest{} - mi := &file_headscale_v1_auth_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AuthRejectRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AuthRejectRequest) ProtoMessage() {} - -func (x *AuthRejectRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_auth_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AuthRejectRequest.ProtoReflect.Descriptor instead. -func (*AuthRejectRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_auth_proto_rawDescGZIP(), []int{4} -} - -func (x *AuthRejectRequest) GetAuthId() string { - if x != nil { - return x.AuthId - } - return "" -} - -type AuthRejectResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AuthRejectResponse) Reset() { - *x = AuthRejectResponse{} - mi := &file_headscale_v1_auth_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AuthRejectResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AuthRejectResponse) ProtoMessage() {} - -func (x *AuthRejectResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_auth_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AuthRejectResponse.ProtoReflect.Descriptor instead. -func (*AuthRejectResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_auth_proto_rawDescGZIP(), []int{5} -} - -var File_headscale_v1_auth_proto protoreflect.FileDescriptor - -const file_headscale_v1_auth_proto_rawDesc = "" + - "\n" + - "\x17headscale/v1/auth.proto\x12\fheadscale.v1\x1a\x17headscale/v1/node.proto\"B\n" + - "\x13AuthRegisterRequest\x12\x12\n" + - "\x04user\x18\x01 \x01(\tR\x04user\x12\x17\n" + - "\aauth_id\x18\x02 \x01(\tR\x06authId\">\n" + - "\x14AuthRegisterResponse\x12&\n" + - "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\"-\n" + - "\x12AuthApproveRequest\x12\x17\n" + - "\aauth_id\x18\x01 \x01(\tR\x06authId\"\x15\n" + - "\x13AuthApproveResponse\",\n" + - "\x11AuthRejectRequest\x12\x17\n" + - "\aauth_id\x18\x01 \x01(\tR\x06authId\"\x14\n" + - "\x12AuthRejectResponseB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3" - -var ( - file_headscale_v1_auth_proto_rawDescOnce sync.Once - file_headscale_v1_auth_proto_rawDescData []byte -) - -func file_headscale_v1_auth_proto_rawDescGZIP() []byte { - file_headscale_v1_auth_proto_rawDescOnce.Do(func() { - file_headscale_v1_auth_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_auth_proto_rawDesc), len(file_headscale_v1_auth_proto_rawDesc))) - }) - return file_headscale_v1_auth_proto_rawDescData -} - -var file_headscale_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 6) -var file_headscale_v1_auth_proto_goTypes = []any{ - (*AuthRegisterRequest)(nil), // 0: headscale.v1.AuthRegisterRequest - (*AuthRegisterResponse)(nil), // 1: headscale.v1.AuthRegisterResponse - (*AuthApproveRequest)(nil), // 2: headscale.v1.AuthApproveRequest - (*AuthApproveResponse)(nil), // 3: headscale.v1.AuthApproveResponse - (*AuthRejectRequest)(nil), // 4: headscale.v1.AuthRejectRequest - (*AuthRejectResponse)(nil), // 5: headscale.v1.AuthRejectResponse - (*Node)(nil), // 6: headscale.v1.Node -} -var file_headscale_v1_auth_proto_depIdxs = []int32{ - 6, // 0: headscale.v1.AuthRegisterResponse.node:type_name -> headscale.v1.Node - 1, // [1:1] is the sub-list for method output_type - 1, // [1:1] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name -} - -func init() { file_headscale_v1_auth_proto_init() } -func file_headscale_v1_auth_proto_init() { - if File_headscale_v1_auth_proto != nil { - return - } - file_headscale_v1_node_proto_init() - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_auth_proto_rawDesc), len(file_headscale_v1_auth_proto_rawDesc)), - NumEnums: 0, - NumMessages: 6, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_headscale_v1_auth_proto_goTypes, - DependencyIndexes: file_headscale_v1_auth_proto_depIdxs, - MessageInfos: file_headscale_v1_auth_proto_msgTypes, - }.Build() - File_headscale_v1_auth_proto = out.File - file_headscale_v1_auth_proto_goTypes = nil - file_headscale_v1_auth_proto_depIdxs = nil -} diff --git a/gen/go/headscale/v1/device.pb.go b/gen/go/headscale/v1/device.pb.go deleted file mode 100644 index e2362b05a..000000000 --- a/gen/go/headscale/v1/device.pb.go +++ /dev/null @@ -1,890 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: headscale/v1/device.proto - -package v1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type Latency struct { - state protoimpl.MessageState `protogen:"open.v1"` - LatencyMs float32 `protobuf:"fixed32,1,opt,name=latency_ms,json=latencyMs,proto3" json:"latency_ms,omitempty"` - Preferred bool `protobuf:"varint,2,opt,name=preferred,proto3" json:"preferred,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Latency) Reset() { - *x = Latency{} - mi := &file_headscale_v1_device_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Latency) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Latency) ProtoMessage() {} - -func (x *Latency) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Latency.ProtoReflect.Descriptor instead. -func (*Latency) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{0} -} - -func (x *Latency) GetLatencyMs() float32 { - if x != nil { - return x.LatencyMs - } - return 0 -} - -func (x *Latency) GetPreferred() bool { - if x != nil { - return x.Preferred - } - return false -} - -type ClientSupports struct { - state protoimpl.MessageState `protogen:"open.v1"` - HairPinning bool `protobuf:"varint,1,opt,name=hair_pinning,json=hairPinning,proto3" json:"hair_pinning,omitempty"` - Ipv6 bool `protobuf:"varint,2,opt,name=ipv6,proto3" json:"ipv6,omitempty"` - Pcp bool `protobuf:"varint,3,opt,name=pcp,proto3" json:"pcp,omitempty"` - Pmp bool `protobuf:"varint,4,opt,name=pmp,proto3" json:"pmp,omitempty"` - Udp bool `protobuf:"varint,5,opt,name=udp,proto3" json:"udp,omitempty"` - Upnp bool `protobuf:"varint,6,opt,name=upnp,proto3" json:"upnp,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ClientSupports) Reset() { - *x = ClientSupports{} - mi := &file_headscale_v1_device_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ClientSupports) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ClientSupports) ProtoMessage() {} - -func (x *ClientSupports) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ClientSupports.ProtoReflect.Descriptor instead. -func (*ClientSupports) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{1} -} - -func (x *ClientSupports) GetHairPinning() bool { - if x != nil { - return x.HairPinning - } - return false -} - -func (x *ClientSupports) GetIpv6() bool { - if x != nil { - return x.Ipv6 - } - return false -} - -func (x *ClientSupports) GetPcp() bool { - if x != nil { - return x.Pcp - } - return false -} - -func (x *ClientSupports) GetPmp() bool { - if x != nil { - return x.Pmp - } - return false -} - -func (x *ClientSupports) GetUdp() bool { - if x != nil { - return x.Udp - } - return false -} - -func (x *ClientSupports) GetUpnp() bool { - if x != nil { - return x.Upnp - } - return false -} - -type ClientConnectivity struct { - state protoimpl.MessageState `protogen:"open.v1"` - Endpoints []string `protobuf:"bytes,1,rep,name=endpoints,proto3" json:"endpoints,omitempty"` - Derp string `protobuf:"bytes,2,opt,name=derp,proto3" json:"derp,omitempty"` - MappingVariesByDestIp bool `protobuf:"varint,3,opt,name=mapping_varies_by_dest_ip,json=mappingVariesByDestIp,proto3" json:"mapping_varies_by_dest_ip,omitempty"` - Latency map[string]*Latency `protobuf:"bytes,4,rep,name=latency,proto3" json:"latency,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - ClientSupports *ClientSupports `protobuf:"bytes,5,opt,name=client_supports,json=clientSupports,proto3" json:"client_supports,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ClientConnectivity) Reset() { - *x = ClientConnectivity{} - mi := &file_headscale_v1_device_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ClientConnectivity) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ClientConnectivity) ProtoMessage() {} - -func (x *ClientConnectivity) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ClientConnectivity.ProtoReflect.Descriptor instead. -func (*ClientConnectivity) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{2} -} - -func (x *ClientConnectivity) GetEndpoints() []string { - if x != nil { - return x.Endpoints - } - return nil -} - -func (x *ClientConnectivity) GetDerp() string { - if x != nil { - return x.Derp - } - return "" -} - -func (x *ClientConnectivity) GetMappingVariesByDestIp() bool { - if x != nil { - return x.MappingVariesByDestIp - } - return false -} - -func (x *ClientConnectivity) GetLatency() map[string]*Latency { - if x != nil { - return x.Latency - } - return nil -} - -func (x *ClientConnectivity) GetClientSupports() *ClientSupports { - if x != nil { - return x.ClientSupports - } - return nil -} - -type GetDeviceRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetDeviceRequest) Reset() { - *x = GetDeviceRequest{} - mi := &file_headscale_v1_device_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetDeviceRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetDeviceRequest) ProtoMessage() {} - -func (x *GetDeviceRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetDeviceRequest.ProtoReflect.Descriptor instead. -func (*GetDeviceRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{3} -} - -func (x *GetDeviceRequest) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -type GetDeviceResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Addresses []string `protobuf:"bytes,1,rep,name=addresses,proto3" json:"addresses,omitempty"` - Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` - User string `protobuf:"bytes,3,opt,name=user,proto3" json:"user,omitempty"` - Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` - Hostname string `protobuf:"bytes,5,opt,name=hostname,proto3" json:"hostname,omitempty"` - ClientVersion string `protobuf:"bytes,6,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"` - UpdateAvailable bool `protobuf:"varint,7,opt,name=update_available,json=updateAvailable,proto3" json:"update_available,omitempty"` - Os string `protobuf:"bytes,8,opt,name=os,proto3" json:"os,omitempty"` - Created *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=created,proto3" json:"created,omitempty"` - LastSeen *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=last_seen,json=lastSeen,proto3" json:"last_seen,omitempty"` - KeyExpiryDisabled bool `protobuf:"varint,11,opt,name=key_expiry_disabled,json=keyExpiryDisabled,proto3" json:"key_expiry_disabled,omitempty"` - Expires *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=expires,proto3" json:"expires,omitempty"` - Authorized bool `protobuf:"varint,13,opt,name=authorized,proto3" json:"authorized,omitempty"` - IsExternal bool `protobuf:"varint,14,opt,name=is_external,json=isExternal,proto3" json:"is_external,omitempty"` - MachineKey string `protobuf:"bytes,15,opt,name=machine_key,json=machineKey,proto3" json:"machine_key,omitempty"` - NodeKey string `protobuf:"bytes,16,opt,name=node_key,json=nodeKey,proto3" json:"node_key,omitempty"` - BlocksIncomingConnections bool `protobuf:"varint,17,opt,name=blocks_incoming_connections,json=blocksIncomingConnections,proto3" json:"blocks_incoming_connections,omitempty"` - EnabledRoutes []string `protobuf:"bytes,18,rep,name=enabled_routes,json=enabledRoutes,proto3" json:"enabled_routes,omitempty"` - AdvertisedRoutes []string `protobuf:"bytes,19,rep,name=advertised_routes,json=advertisedRoutes,proto3" json:"advertised_routes,omitempty"` - ClientConnectivity *ClientConnectivity `protobuf:"bytes,20,opt,name=client_connectivity,json=clientConnectivity,proto3" json:"client_connectivity,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetDeviceResponse) Reset() { - *x = GetDeviceResponse{} - mi := &file_headscale_v1_device_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetDeviceResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetDeviceResponse) ProtoMessage() {} - -func (x *GetDeviceResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetDeviceResponse.ProtoReflect.Descriptor instead. -func (*GetDeviceResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{4} -} - -func (x *GetDeviceResponse) GetAddresses() []string { - if x != nil { - return x.Addresses - } - return nil -} - -func (x *GetDeviceResponse) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -func (x *GetDeviceResponse) GetUser() string { - if x != nil { - return x.User - } - return "" -} - -func (x *GetDeviceResponse) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *GetDeviceResponse) GetHostname() string { - if x != nil { - return x.Hostname - } - return "" -} - -func (x *GetDeviceResponse) GetClientVersion() string { - if x != nil { - return x.ClientVersion - } - return "" -} - -func (x *GetDeviceResponse) GetUpdateAvailable() bool { - if x != nil { - return x.UpdateAvailable - } - return false -} - -func (x *GetDeviceResponse) GetOs() string { - if x != nil { - return x.Os - } - return "" -} - -func (x *GetDeviceResponse) GetCreated() *timestamppb.Timestamp { - if x != nil { - return x.Created - } - return nil -} - -func (x *GetDeviceResponse) GetLastSeen() *timestamppb.Timestamp { - if x != nil { - return x.LastSeen - } - return nil -} - -func (x *GetDeviceResponse) GetKeyExpiryDisabled() bool { - if x != nil { - return x.KeyExpiryDisabled - } - return false -} - -func (x *GetDeviceResponse) GetExpires() *timestamppb.Timestamp { - if x != nil { - return x.Expires - } - return nil -} - -func (x *GetDeviceResponse) GetAuthorized() bool { - if x != nil { - return x.Authorized - } - return false -} - -func (x *GetDeviceResponse) GetIsExternal() bool { - if x != nil { - return x.IsExternal - } - return false -} - -func (x *GetDeviceResponse) GetMachineKey() string { - if x != nil { - return x.MachineKey - } - return "" -} - -func (x *GetDeviceResponse) GetNodeKey() string { - if x != nil { - return x.NodeKey - } - return "" -} - -func (x *GetDeviceResponse) GetBlocksIncomingConnections() bool { - if x != nil { - return x.BlocksIncomingConnections - } - return false -} - -func (x *GetDeviceResponse) GetEnabledRoutes() []string { - if x != nil { - return x.EnabledRoutes - } - return nil -} - -func (x *GetDeviceResponse) GetAdvertisedRoutes() []string { - if x != nil { - return x.AdvertisedRoutes - } - return nil -} - -func (x *GetDeviceResponse) GetClientConnectivity() *ClientConnectivity { - if x != nil { - return x.ClientConnectivity - } - return nil -} - -type DeleteDeviceRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteDeviceRequest) Reset() { - *x = DeleteDeviceRequest{} - mi := &file_headscale_v1_device_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteDeviceRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteDeviceRequest) ProtoMessage() {} - -func (x *DeleteDeviceRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteDeviceRequest.ProtoReflect.Descriptor instead. -func (*DeleteDeviceRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{5} -} - -func (x *DeleteDeviceRequest) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -type DeleteDeviceResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteDeviceResponse) Reset() { - *x = DeleteDeviceResponse{} - mi := &file_headscale_v1_device_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteDeviceResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteDeviceResponse) ProtoMessage() {} - -func (x *DeleteDeviceResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[6] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteDeviceResponse.ProtoReflect.Descriptor instead. -func (*DeleteDeviceResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{6} -} - -type GetDeviceRoutesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetDeviceRoutesRequest) Reset() { - *x = GetDeviceRoutesRequest{} - mi := &file_headscale_v1_device_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetDeviceRoutesRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetDeviceRoutesRequest) ProtoMessage() {} - -func (x *GetDeviceRoutesRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[7] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetDeviceRoutesRequest.ProtoReflect.Descriptor instead. -func (*GetDeviceRoutesRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{7} -} - -func (x *GetDeviceRoutesRequest) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -type GetDeviceRoutesResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - EnabledRoutes []string `protobuf:"bytes,1,rep,name=enabled_routes,json=enabledRoutes,proto3" json:"enabled_routes,omitempty"` - AdvertisedRoutes []string `protobuf:"bytes,2,rep,name=advertised_routes,json=advertisedRoutes,proto3" json:"advertised_routes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetDeviceRoutesResponse) Reset() { - *x = GetDeviceRoutesResponse{} - mi := &file_headscale_v1_device_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetDeviceRoutesResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetDeviceRoutesResponse) ProtoMessage() {} - -func (x *GetDeviceRoutesResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[8] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetDeviceRoutesResponse.ProtoReflect.Descriptor instead. -func (*GetDeviceRoutesResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{8} -} - -func (x *GetDeviceRoutesResponse) GetEnabledRoutes() []string { - if x != nil { - return x.EnabledRoutes - } - return nil -} - -func (x *GetDeviceRoutesResponse) GetAdvertisedRoutes() []string { - if x != nil { - return x.AdvertisedRoutes - } - return nil -} - -type EnableDeviceRoutesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - Routes []string `protobuf:"bytes,2,rep,name=routes,proto3" json:"routes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *EnableDeviceRoutesRequest) Reset() { - *x = EnableDeviceRoutesRequest{} - mi := &file_headscale_v1_device_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *EnableDeviceRoutesRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*EnableDeviceRoutesRequest) ProtoMessage() {} - -func (x *EnableDeviceRoutesRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[9] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use EnableDeviceRoutesRequest.ProtoReflect.Descriptor instead. -func (*EnableDeviceRoutesRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{9} -} - -func (x *EnableDeviceRoutesRequest) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -func (x *EnableDeviceRoutesRequest) GetRoutes() []string { - if x != nil { - return x.Routes - } - return nil -} - -type EnableDeviceRoutesResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - EnabledRoutes []string `protobuf:"bytes,1,rep,name=enabled_routes,json=enabledRoutes,proto3" json:"enabled_routes,omitempty"` - AdvertisedRoutes []string `protobuf:"bytes,2,rep,name=advertised_routes,json=advertisedRoutes,proto3" json:"advertised_routes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *EnableDeviceRoutesResponse) Reset() { - *x = EnableDeviceRoutesResponse{} - mi := &file_headscale_v1_device_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *EnableDeviceRoutesResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*EnableDeviceRoutesResponse) ProtoMessage() {} - -func (x *EnableDeviceRoutesResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_device_proto_msgTypes[10] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use EnableDeviceRoutesResponse.ProtoReflect.Descriptor instead. -func (*EnableDeviceRoutesResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_device_proto_rawDescGZIP(), []int{10} -} - -func (x *EnableDeviceRoutesResponse) GetEnabledRoutes() []string { - if x != nil { - return x.EnabledRoutes - } - return nil -} - -func (x *EnableDeviceRoutesResponse) GetAdvertisedRoutes() []string { - if x != nil { - return x.AdvertisedRoutes - } - return nil -} - -var File_headscale_v1_device_proto protoreflect.FileDescriptor - -const file_headscale_v1_device_proto_rawDesc = "" + - "\n" + - "\x19headscale/v1/device.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"F\n" + - "\aLatency\x12\x1d\n" + - "\n" + - "latency_ms\x18\x01 \x01(\x02R\tlatencyMs\x12\x1c\n" + - "\tpreferred\x18\x02 \x01(\bR\tpreferred\"\x91\x01\n" + - "\x0eClientSupports\x12!\n" + - "\fhair_pinning\x18\x01 \x01(\bR\vhairPinning\x12\x12\n" + - "\x04ipv6\x18\x02 \x01(\bR\x04ipv6\x12\x10\n" + - "\x03pcp\x18\x03 \x01(\bR\x03pcp\x12\x10\n" + - "\x03pmp\x18\x04 \x01(\bR\x03pmp\x12\x10\n" + - "\x03udp\x18\x05 \x01(\bR\x03udp\x12\x12\n" + - "\x04upnp\x18\x06 \x01(\bR\x04upnp\"\xe3\x02\n" + - "\x12ClientConnectivity\x12\x1c\n" + - "\tendpoints\x18\x01 \x03(\tR\tendpoints\x12\x12\n" + - "\x04derp\x18\x02 \x01(\tR\x04derp\x128\n" + - "\x19mapping_varies_by_dest_ip\x18\x03 \x01(\bR\x15mappingVariesByDestIp\x12G\n" + - "\alatency\x18\x04 \x03(\v2-.headscale.v1.ClientConnectivity.LatencyEntryR\alatency\x12E\n" + - "\x0fclient_supports\x18\x05 \x01(\v2\x1c.headscale.v1.ClientSupportsR\x0eclientSupports\x1aQ\n" + - "\fLatencyEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12+\n" + - "\x05value\x18\x02 \x01(\v2\x15.headscale.v1.LatencyR\x05value:\x028\x01\"\"\n" + - "\x10GetDeviceRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"\xa0\x06\n" + - "\x11GetDeviceResponse\x12\x1c\n" + - "\taddresses\x18\x01 \x03(\tR\taddresses\x12\x0e\n" + - "\x02id\x18\x02 \x01(\tR\x02id\x12\x12\n" + - "\x04user\x18\x03 \x01(\tR\x04user\x12\x12\n" + - "\x04name\x18\x04 \x01(\tR\x04name\x12\x1a\n" + - "\bhostname\x18\x05 \x01(\tR\bhostname\x12%\n" + - "\x0eclient_version\x18\x06 \x01(\tR\rclientVersion\x12)\n" + - "\x10update_available\x18\a \x01(\bR\x0fupdateAvailable\x12\x0e\n" + - "\x02os\x18\b \x01(\tR\x02os\x124\n" + - "\acreated\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\acreated\x127\n" + - "\tlast_seen\x18\n" + - " \x01(\v2\x1a.google.protobuf.TimestampR\blastSeen\x12.\n" + - "\x13key_expiry_disabled\x18\v \x01(\bR\x11keyExpiryDisabled\x124\n" + - "\aexpires\x18\f \x01(\v2\x1a.google.protobuf.TimestampR\aexpires\x12\x1e\n" + - "\n" + - "authorized\x18\r \x01(\bR\n" + - "authorized\x12\x1f\n" + - "\vis_external\x18\x0e \x01(\bR\n" + - "isExternal\x12\x1f\n" + - "\vmachine_key\x18\x0f \x01(\tR\n" + - "machineKey\x12\x19\n" + - "\bnode_key\x18\x10 \x01(\tR\anodeKey\x12>\n" + - "\x1bblocks_incoming_connections\x18\x11 \x01(\bR\x19blocksIncomingConnections\x12%\n" + - "\x0eenabled_routes\x18\x12 \x03(\tR\renabledRoutes\x12+\n" + - "\x11advertised_routes\x18\x13 \x03(\tR\x10advertisedRoutes\x12Q\n" + - "\x13client_connectivity\x18\x14 \x01(\v2 .headscale.v1.ClientConnectivityR\x12clientConnectivity\"%\n" + - "\x13DeleteDeviceRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"\x16\n" + - "\x14DeleteDeviceResponse\"(\n" + - "\x16GetDeviceRoutesRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"m\n" + - "\x17GetDeviceRoutesResponse\x12%\n" + - "\x0eenabled_routes\x18\x01 \x03(\tR\renabledRoutes\x12+\n" + - "\x11advertised_routes\x18\x02 \x03(\tR\x10advertisedRoutes\"C\n" + - "\x19EnableDeviceRoutesRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x12\x16\n" + - "\x06routes\x18\x02 \x03(\tR\x06routes\"p\n" + - "\x1aEnableDeviceRoutesResponse\x12%\n" + - "\x0eenabled_routes\x18\x01 \x03(\tR\renabledRoutes\x12+\n" + - "\x11advertised_routes\x18\x02 \x03(\tR\x10advertisedRoutesB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3" - -var ( - file_headscale_v1_device_proto_rawDescOnce sync.Once - file_headscale_v1_device_proto_rawDescData []byte -) - -func file_headscale_v1_device_proto_rawDescGZIP() []byte { - file_headscale_v1_device_proto_rawDescOnce.Do(func() { - file_headscale_v1_device_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_device_proto_rawDesc), len(file_headscale_v1_device_proto_rawDesc))) - }) - return file_headscale_v1_device_proto_rawDescData -} - -var file_headscale_v1_device_proto_msgTypes = make([]protoimpl.MessageInfo, 12) -var file_headscale_v1_device_proto_goTypes = []any{ - (*Latency)(nil), // 0: headscale.v1.Latency - (*ClientSupports)(nil), // 1: headscale.v1.ClientSupports - (*ClientConnectivity)(nil), // 2: headscale.v1.ClientConnectivity - (*GetDeviceRequest)(nil), // 3: headscale.v1.GetDeviceRequest - (*GetDeviceResponse)(nil), // 4: headscale.v1.GetDeviceResponse - (*DeleteDeviceRequest)(nil), // 5: headscale.v1.DeleteDeviceRequest - (*DeleteDeviceResponse)(nil), // 6: headscale.v1.DeleteDeviceResponse - (*GetDeviceRoutesRequest)(nil), // 7: headscale.v1.GetDeviceRoutesRequest - (*GetDeviceRoutesResponse)(nil), // 8: headscale.v1.GetDeviceRoutesResponse - (*EnableDeviceRoutesRequest)(nil), // 9: headscale.v1.EnableDeviceRoutesRequest - (*EnableDeviceRoutesResponse)(nil), // 10: headscale.v1.EnableDeviceRoutesResponse - nil, // 11: headscale.v1.ClientConnectivity.LatencyEntry - (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp -} -var file_headscale_v1_device_proto_depIdxs = []int32{ - 11, // 0: headscale.v1.ClientConnectivity.latency:type_name -> headscale.v1.ClientConnectivity.LatencyEntry - 1, // 1: headscale.v1.ClientConnectivity.client_supports:type_name -> headscale.v1.ClientSupports - 12, // 2: headscale.v1.GetDeviceResponse.created:type_name -> google.protobuf.Timestamp - 12, // 3: headscale.v1.GetDeviceResponse.last_seen:type_name -> google.protobuf.Timestamp - 12, // 4: headscale.v1.GetDeviceResponse.expires:type_name -> google.protobuf.Timestamp - 2, // 5: headscale.v1.GetDeviceResponse.client_connectivity:type_name -> headscale.v1.ClientConnectivity - 0, // 6: headscale.v1.ClientConnectivity.LatencyEntry.value:type_name -> headscale.v1.Latency - 7, // [7:7] is the sub-list for method output_type - 7, // [7:7] is the sub-list for method input_type - 7, // [7:7] is the sub-list for extension type_name - 7, // [7:7] is the sub-list for extension extendee - 0, // [0:7] is the sub-list for field type_name -} - -func init() { file_headscale_v1_device_proto_init() } -func file_headscale_v1_device_proto_init() { - if File_headscale_v1_device_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_device_proto_rawDesc), len(file_headscale_v1_device_proto_rawDesc)), - NumEnums: 0, - NumMessages: 12, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_headscale_v1_device_proto_goTypes, - DependencyIndexes: file_headscale_v1_device_proto_depIdxs, - MessageInfos: file_headscale_v1_device_proto_msgTypes, - }.Build() - File_headscale_v1_device_proto = out.File - file_headscale_v1_device_proto_goTypes = nil - file_headscale_v1_device_proto_depIdxs = nil -} diff --git a/gen/go/headscale/v1/headscale.pb.go b/gen/go/headscale/v1/headscale.pb.go deleted file mode 100644 index 4a1875126..000000000 --- a/gen/go/headscale/v1/headscale.pb.go +++ /dev/null @@ -1,318 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: headscale/v1/headscale.proto - -package v1 - -import ( - _ "google.golang.org/genproto/googleapis/api/annotations" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type HealthRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *HealthRequest) Reset() { - *x = HealthRequest{} - mi := &file_headscale_v1_headscale_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *HealthRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*HealthRequest) ProtoMessage() {} - -func (x *HealthRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_headscale_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use HealthRequest.ProtoReflect.Descriptor instead. -func (*HealthRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_headscale_proto_rawDescGZIP(), []int{0} -} - -type HealthResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - DatabaseConnectivity bool `protobuf:"varint,1,opt,name=database_connectivity,json=databaseConnectivity,proto3" json:"database_connectivity,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *HealthResponse) Reset() { - *x = HealthResponse{} - mi := &file_headscale_v1_headscale_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *HealthResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*HealthResponse) ProtoMessage() {} - -func (x *HealthResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_headscale_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead. -func (*HealthResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_headscale_proto_rawDescGZIP(), []int{1} -} - -func (x *HealthResponse) GetDatabaseConnectivity() bool { - if x != nil { - return x.DatabaseConnectivity - } - return false -} - -var File_headscale_v1_headscale_proto protoreflect.FileDescriptor - -const file_headscale_v1_headscale_proto_rawDesc = "" + - "\n" + - "\x1cheadscale/v1/headscale.proto\x12\fheadscale.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17headscale/v1/user.proto\x1a\x1dheadscale/v1/preauthkey.proto\x1a\x17headscale/v1/node.proto\x1a\x19headscale/v1/apikey.proto\x1a\x17headscale/v1/auth.proto\x1a\x19headscale/v1/policy.proto\"\x0f\n" + - "\rHealthRequest\"E\n" + - "\x0eHealthResponse\x123\n" + - "\x15database_connectivity\x18\x01 \x01(\bR\x14databaseConnectivity2\xe0\x1a\n" + - "\x10HeadscaleService\x12h\n" + - "\n" + - "CreateUser\x12\x1f.headscale.v1.CreateUserRequest\x1a .headscale.v1.CreateUserResponse\"\x17\x82\xd3\xe4\x93\x02\x11:\x01*\"\f/api/v1/user\x12\x80\x01\n" + - "\n" + - "RenameUser\x12\x1f.headscale.v1.RenameUserRequest\x1a .headscale.v1.RenameUserResponse\"/\x82\xd3\xe4\x93\x02)\"'/api/v1/user/{old_id}/rename/{new_name}\x12j\n" + - "\n" + - "DeleteUser\x12\x1f.headscale.v1.DeleteUserRequest\x1a .headscale.v1.DeleteUserResponse\"\x19\x82\xd3\xe4\x93\x02\x13*\x11/api/v1/user/{id}\x12b\n" + - "\tListUsers\x12\x1e.headscale.v1.ListUsersRequest\x1a\x1f.headscale.v1.ListUsersResponse\"\x14\x82\xd3\xe4\x93\x02\x0e\x12\f/api/v1/user\x12\x80\x01\n" + - "\x10CreatePreAuthKey\x12%.headscale.v1.CreatePreAuthKeyRequest\x1a&.headscale.v1.CreatePreAuthKeyResponse\"\x1d\x82\xd3\xe4\x93\x02\x17:\x01*\"\x12/api/v1/preauthkey\x12\x87\x01\n" + - "\x10ExpirePreAuthKey\x12%.headscale.v1.ExpirePreAuthKeyRequest\x1a&.headscale.v1.ExpirePreAuthKeyResponse\"$\x82\xd3\xe4\x93\x02\x1e:\x01*\"\x19/api/v1/preauthkey/expire\x12}\n" + - "\x10DeletePreAuthKey\x12%.headscale.v1.DeletePreAuthKeyRequest\x1a&.headscale.v1.DeletePreAuthKeyResponse\"\x1a\x82\xd3\xe4\x93\x02\x14*\x12/api/v1/preauthkey\x12z\n" + - "\x0fListPreAuthKeys\x12$.headscale.v1.ListPreAuthKeysRequest\x1a%.headscale.v1.ListPreAuthKeysResponse\"\x1a\x82\xd3\xe4\x93\x02\x14\x12\x12/api/v1/preauthkey\x12}\n" + - "\x0fDebugCreateNode\x12$.headscale.v1.DebugCreateNodeRequest\x1a%.headscale.v1.DebugCreateNodeResponse\"\x1d\x82\xd3\xe4\x93\x02\x17:\x01*\"\x12/api/v1/debug/node\x12f\n" + - "\aGetNode\x12\x1c.headscale.v1.GetNodeRequest\x1a\x1d.headscale.v1.GetNodeResponse\"\x1e\x82\xd3\xe4\x93\x02\x18\x12\x16/api/v1/node/{node_id}\x12n\n" + - "\aSetTags\x12\x1c.headscale.v1.SetTagsRequest\x1a\x1d.headscale.v1.SetTagsResponse\"&\x82\xd3\xe4\x93\x02 :\x01*\"\x1b/api/v1/node/{node_id}/tags\x12\x96\x01\n" + - "\x11SetApprovedRoutes\x12&.headscale.v1.SetApprovedRoutesRequest\x1a'.headscale.v1.SetApprovedRoutesResponse\"0\x82\xd3\xe4\x93\x02*:\x01*\"%/api/v1/node/{node_id}/approve_routes\x12t\n" + - "\fRegisterNode\x12!.headscale.v1.RegisterNodeRequest\x1a\".headscale.v1.RegisterNodeResponse\"\x1d\x82\xd3\xe4\x93\x02\x17\"\x15/api/v1/node/register\x12o\n" + - "\n" + - "DeleteNode\x12\x1f.headscale.v1.DeleteNodeRequest\x1a .headscale.v1.DeleteNodeResponse\"\x1e\x82\xd3\xe4\x93\x02\x18*\x16/api/v1/node/{node_id}\x12v\n" + - "\n" + - "ExpireNode\x12\x1f.headscale.v1.ExpireNodeRequest\x1a .headscale.v1.ExpireNodeResponse\"%\x82\xd3\xe4\x93\x02\x1f\"\x1d/api/v1/node/{node_id}/expire\x12\x81\x01\n" + - "\n" + - "RenameNode\x12\x1f.headscale.v1.RenameNodeRequest\x1a .headscale.v1.RenameNodeResponse\"0\x82\xd3\xe4\x93\x02*\"(/api/v1/node/{node_id}/rename/{new_name}\x12b\n" + - "\tListNodes\x12\x1e.headscale.v1.ListNodesRequest\x1a\x1f.headscale.v1.ListNodesResponse\"\x14\x82\xd3\xe4\x93\x02\x0e\x12\f/api/v1/node\x12\x80\x01\n" + - "\x0fBackfillNodeIPs\x12$.headscale.v1.BackfillNodeIPsRequest\x1a%.headscale.v1.BackfillNodeIPsResponse\" \x82\xd3\xe4\x93\x02\x1a\"\x18/api/v1/node/backfillips\x12w\n" + - "\fAuthRegister\x12!.headscale.v1.AuthRegisterRequest\x1a\".headscale.v1.AuthRegisterResponse\" \x82\xd3\xe4\x93\x02\x1a:\x01*\"\x15/api/v1/auth/register\x12s\n" + - "\vAuthApprove\x12 .headscale.v1.AuthApproveRequest\x1a!.headscale.v1.AuthApproveResponse\"\x1f\x82\xd3\xe4\x93\x02\x19:\x01*\"\x14/api/v1/auth/approve\x12o\n" + - "\n" + - "AuthReject\x12\x1f.headscale.v1.AuthRejectRequest\x1a .headscale.v1.AuthRejectResponse\"\x1e\x82\xd3\xe4\x93\x02\x18:\x01*\"\x13/api/v1/auth/reject\x12p\n" + - "\fCreateApiKey\x12!.headscale.v1.CreateApiKeyRequest\x1a\".headscale.v1.CreateApiKeyResponse\"\x19\x82\xd3\xe4\x93\x02\x13:\x01*\"\x0e/api/v1/apikey\x12w\n" + - "\fExpireApiKey\x12!.headscale.v1.ExpireApiKeyRequest\x1a\".headscale.v1.ExpireApiKeyResponse\" \x82\xd3\xe4\x93\x02\x1a:\x01*\"\x15/api/v1/apikey/expire\x12j\n" + - "\vListApiKeys\x12 .headscale.v1.ListApiKeysRequest\x1a!.headscale.v1.ListApiKeysResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/apikey\x12v\n" + - "\fDeleteApiKey\x12!.headscale.v1.DeleteApiKeyRequest\x1a\".headscale.v1.DeleteApiKeyResponse\"\x1f\x82\xd3\xe4\x93\x02\x19*\x17/api/v1/apikey/{prefix}\x12d\n" + - "\tGetPolicy\x12\x1e.headscale.v1.GetPolicyRequest\x1a\x1f.headscale.v1.GetPolicyResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/policy\x12g\n" + - "\tSetPolicy\x12\x1e.headscale.v1.SetPolicyRequest\x1a\x1f.headscale.v1.SetPolicyResponse\"\x19\x82\xd3\xe4\x93\x02\x13:\x01*\x1a\x0e/api/v1/policy\x12s\n" + - "\vCheckPolicy\x12 .headscale.v1.CheckPolicyRequest\x1a!.headscale.v1.CheckPolicyResponse\"\x1f\x82\xd3\xe4\x93\x02\x19:\x01*\"\x14/api/v1/policy/check\x12[\n" + - "\x06Health\x12\x1b.headscale.v1.HealthRequest\x1a\x1c.headscale.v1.HealthResponse\"\x16\x82\xd3\xe4\x93\x02\x10\x12\x0e/api/v1/healthB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3" - -var ( - file_headscale_v1_headscale_proto_rawDescOnce sync.Once - file_headscale_v1_headscale_proto_rawDescData []byte -) - -func file_headscale_v1_headscale_proto_rawDescGZIP() []byte { - file_headscale_v1_headscale_proto_rawDescOnce.Do(func() { - file_headscale_v1_headscale_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_headscale_proto_rawDesc), len(file_headscale_v1_headscale_proto_rawDesc))) - }) - return file_headscale_v1_headscale_proto_rawDescData -} - -var file_headscale_v1_headscale_proto_msgTypes = make([]protoimpl.MessageInfo, 2) -var file_headscale_v1_headscale_proto_goTypes = []any{ - (*HealthRequest)(nil), // 0: headscale.v1.HealthRequest - (*HealthResponse)(nil), // 1: headscale.v1.HealthResponse - (*CreateUserRequest)(nil), // 2: headscale.v1.CreateUserRequest - (*RenameUserRequest)(nil), // 3: headscale.v1.RenameUserRequest - (*DeleteUserRequest)(nil), // 4: headscale.v1.DeleteUserRequest - (*ListUsersRequest)(nil), // 5: headscale.v1.ListUsersRequest - (*CreatePreAuthKeyRequest)(nil), // 6: headscale.v1.CreatePreAuthKeyRequest - (*ExpirePreAuthKeyRequest)(nil), // 7: headscale.v1.ExpirePreAuthKeyRequest - (*DeletePreAuthKeyRequest)(nil), // 8: headscale.v1.DeletePreAuthKeyRequest - (*ListPreAuthKeysRequest)(nil), // 9: headscale.v1.ListPreAuthKeysRequest - (*DebugCreateNodeRequest)(nil), // 10: headscale.v1.DebugCreateNodeRequest - (*GetNodeRequest)(nil), // 11: headscale.v1.GetNodeRequest - (*SetTagsRequest)(nil), // 12: headscale.v1.SetTagsRequest - (*SetApprovedRoutesRequest)(nil), // 13: headscale.v1.SetApprovedRoutesRequest - (*RegisterNodeRequest)(nil), // 14: headscale.v1.RegisterNodeRequest - (*DeleteNodeRequest)(nil), // 15: headscale.v1.DeleteNodeRequest - (*ExpireNodeRequest)(nil), // 16: headscale.v1.ExpireNodeRequest - (*RenameNodeRequest)(nil), // 17: headscale.v1.RenameNodeRequest - (*ListNodesRequest)(nil), // 18: headscale.v1.ListNodesRequest - (*BackfillNodeIPsRequest)(nil), // 19: headscale.v1.BackfillNodeIPsRequest - (*AuthRegisterRequest)(nil), // 20: headscale.v1.AuthRegisterRequest - (*AuthApproveRequest)(nil), // 21: headscale.v1.AuthApproveRequest - (*AuthRejectRequest)(nil), // 22: headscale.v1.AuthRejectRequest - (*CreateApiKeyRequest)(nil), // 23: headscale.v1.CreateApiKeyRequest - (*ExpireApiKeyRequest)(nil), // 24: headscale.v1.ExpireApiKeyRequest - (*ListApiKeysRequest)(nil), // 25: headscale.v1.ListApiKeysRequest - (*DeleteApiKeyRequest)(nil), // 26: headscale.v1.DeleteApiKeyRequest - (*GetPolicyRequest)(nil), // 27: headscale.v1.GetPolicyRequest - (*SetPolicyRequest)(nil), // 28: headscale.v1.SetPolicyRequest - (*CheckPolicyRequest)(nil), // 29: headscale.v1.CheckPolicyRequest - (*CreateUserResponse)(nil), // 30: headscale.v1.CreateUserResponse - (*RenameUserResponse)(nil), // 31: headscale.v1.RenameUserResponse - (*DeleteUserResponse)(nil), // 32: headscale.v1.DeleteUserResponse - (*ListUsersResponse)(nil), // 33: headscale.v1.ListUsersResponse - (*CreatePreAuthKeyResponse)(nil), // 34: headscale.v1.CreatePreAuthKeyResponse - (*ExpirePreAuthKeyResponse)(nil), // 35: headscale.v1.ExpirePreAuthKeyResponse - (*DeletePreAuthKeyResponse)(nil), // 36: headscale.v1.DeletePreAuthKeyResponse - (*ListPreAuthKeysResponse)(nil), // 37: headscale.v1.ListPreAuthKeysResponse - (*DebugCreateNodeResponse)(nil), // 38: headscale.v1.DebugCreateNodeResponse - (*GetNodeResponse)(nil), // 39: headscale.v1.GetNodeResponse - (*SetTagsResponse)(nil), // 40: headscale.v1.SetTagsResponse - (*SetApprovedRoutesResponse)(nil), // 41: headscale.v1.SetApprovedRoutesResponse - (*RegisterNodeResponse)(nil), // 42: headscale.v1.RegisterNodeResponse - (*DeleteNodeResponse)(nil), // 43: headscale.v1.DeleteNodeResponse - (*ExpireNodeResponse)(nil), // 44: headscale.v1.ExpireNodeResponse - (*RenameNodeResponse)(nil), // 45: headscale.v1.RenameNodeResponse - (*ListNodesResponse)(nil), // 46: headscale.v1.ListNodesResponse - (*BackfillNodeIPsResponse)(nil), // 47: headscale.v1.BackfillNodeIPsResponse - (*AuthRegisterResponse)(nil), // 48: headscale.v1.AuthRegisterResponse - (*AuthApproveResponse)(nil), // 49: headscale.v1.AuthApproveResponse - (*AuthRejectResponse)(nil), // 50: headscale.v1.AuthRejectResponse - (*CreateApiKeyResponse)(nil), // 51: headscale.v1.CreateApiKeyResponse - (*ExpireApiKeyResponse)(nil), // 52: headscale.v1.ExpireApiKeyResponse - (*ListApiKeysResponse)(nil), // 53: headscale.v1.ListApiKeysResponse - (*DeleteApiKeyResponse)(nil), // 54: headscale.v1.DeleteApiKeyResponse - (*GetPolicyResponse)(nil), // 55: headscale.v1.GetPolicyResponse - (*SetPolicyResponse)(nil), // 56: headscale.v1.SetPolicyResponse - (*CheckPolicyResponse)(nil), // 57: headscale.v1.CheckPolicyResponse -} -var file_headscale_v1_headscale_proto_depIdxs = []int32{ - 2, // 0: headscale.v1.HeadscaleService.CreateUser:input_type -> headscale.v1.CreateUserRequest - 3, // 1: headscale.v1.HeadscaleService.RenameUser:input_type -> headscale.v1.RenameUserRequest - 4, // 2: headscale.v1.HeadscaleService.DeleteUser:input_type -> headscale.v1.DeleteUserRequest - 5, // 3: headscale.v1.HeadscaleService.ListUsers:input_type -> headscale.v1.ListUsersRequest - 6, // 4: headscale.v1.HeadscaleService.CreatePreAuthKey:input_type -> headscale.v1.CreatePreAuthKeyRequest - 7, // 5: headscale.v1.HeadscaleService.ExpirePreAuthKey:input_type -> headscale.v1.ExpirePreAuthKeyRequest - 8, // 6: headscale.v1.HeadscaleService.DeletePreAuthKey:input_type -> headscale.v1.DeletePreAuthKeyRequest - 9, // 7: headscale.v1.HeadscaleService.ListPreAuthKeys:input_type -> headscale.v1.ListPreAuthKeysRequest - 10, // 8: headscale.v1.HeadscaleService.DebugCreateNode:input_type -> headscale.v1.DebugCreateNodeRequest - 11, // 9: headscale.v1.HeadscaleService.GetNode:input_type -> headscale.v1.GetNodeRequest - 12, // 10: headscale.v1.HeadscaleService.SetTags:input_type -> headscale.v1.SetTagsRequest - 13, // 11: headscale.v1.HeadscaleService.SetApprovedRoutes:input_type -> headscale.v1.SetApprovedRoutesRequest - 14, // 12: headscale.v1.HeadscaleService.RegisterNode:input_type -> headscale.v1.RegisterNodeRequest - 15, // 13: headscale.v1.HeadscaleService.DeleteNode:input_type -> headscale.v1.DeleteNodeRequest - 16, // 14: headscale.v1.HeadscaleService.ExpireNode:input_type -> headscale.v1.ExpireNodeRequest - 17, // 15: headscale.v1.HeadscaleService.RenameNode:input_type -> headscale.v1.RenameNodeRequest - 18, // 16: headscale.v1.HeadscaleService.ListNodes:input_type -> headscale.v1.ListNodesRequest - 19, // 17: headscale.v1.HeadscaleService.BackfillNodeIPs:input_type -> headscale.v1.BackfillNodeIPsRequest - 20, // 18: headscale.v1.HeadscaleService.AuthRegister:input_type -> headscale.v1.AuthRegisterRequest - 21, // 19: headscale.v1.HeadscaleService.AuthApprove:input_type -> headscale.v1.AuthApproveRequest - 22, // 20: headscale.v1.HeadscaleService.AuthReject:input_type -> headscale.v1.AuthRejectRequest - 23, // 21: headscale.v1.HeadscaleService.CreateApiKey:input_type -> headscale.v1.CreateApiKeyRequest - 24, // 22: headscale.v1.HeadscaleService.ExpireApiKey:input_type -> headscale.v1.ExpireApiKeyRequest - 25, // 23: headscale.v1.HeadscaleService.ListApiKeys:input_type -> headscale.v1.ListApiKeysRequest - 26, // 24: headscale.v1.HeadscaleService.DeleteApiKey:input_type -> headscale.v1.DeleteApiKeyRequest - 27, // 25: headscale.v1.HeadscaleService.GetPolicy:input_type -> headscale.v1.GetPolicyRequest - 28, // 26: headscale.v1.HeadscaleService.SetPolicy:input_type -> headscale.v1.SetPolicyRequest - 29, // 27: headscale.v1.HeadscaleService.CheckPolicy:input_type -> headscale.v1.CheckPolicyRequest - 0, // 28: headscale.v1.HeadscaleService.Health:input_type -> headscale.v1.HealthRequest - 30, // 29: headscale.v1.HeadscaleService.CreateUser:output_type -> headscale.v1.CreateUserResponse - 31, // 30: headscale.v1.HeadscaleService.RenameUser:output_type -> headscale.v1.RenameUserResponse - 32, // 31: headscale.v1.HeadscaleService.DeleteUser:output_type -> headscale.v1.DeleteUserResponse - 33, // 32: headscale.v1.HeadscaleService.ListUsers:output_type -> headscale.v1.ListUsersResponse - 34, // 33: headscale.v1.HeadscaleService.CreatePreAuthKey:output_type -> headscale.v1.CreatePreAuthKeyResponse - 35, // 34: headscale.v1.HeadscaleService.ExpirePreAuthKey:output_type -> headscale.v1.ExpirePreAuthKeyResponse - 36, // 35: headscale.v1.HeadscaleService.DeletePreAuthKey:output_type -> headscale.v1.DeletePreAuthKeyResponse - 37, // 36: headscale.v1.HeadscaleService.ListPreAuthKeys:output_type -> headscale.v1.ListPreAuthKeysResponse - 38, // 37: headscale.v1.HeadscaleService.DebugCreateNode:output_type -> headscale.v1.DebugCreateNodeResponse - 39, // 38: headscale.v1.HeadscaleService.GetNode:output_type -> headscale.v1.GetNodeResponse - 40, // 39: headscale.v1.HeadscaleService.SetTags:output_type -> headscale.v1.SetTagsResponse - 41, // 40: headscale.v1.HeadscaleService.SetApprovedRoutes:output_type -> headscale.v1.SetApprovedRoutesResponse - 42, // 41: headscale.v1.HeadscaleService.RegisterNode:output_type -> headscale.v1.RegisterNodeResponse - 43, // 42: headscale.v1.HeadscaleService.DeleteNode:output_type -> headscale.v1.DeleteNodeResponse - 44, // 43: headscale.v1.HeadscaleService.ExpireNode:output_type -> headscale.v1.ExpireNodeResponse - 45, // 44: headscale.v1.HeadscaleService.RenameNode:output_type -> headscale.v1.RenameNodeResponse - 46, // 45: headscale.v1.HeadscaleService.ListNodes:output_type -> headscale.v1.ListNodesResponse - 47, // 46: headscale.v1.HeadscaleService.BackfillNodeIPs:output_type -> headscale.v1.BackfillNodeIPsResponse - 48, // 47: headscale.v1.HeadscaleService.AuthRegister:output_type -> headscale.v1.AuthRegisterResponse - 49, // 48: headscale.v1.HeadscaleService.AuthApprove:output_type -> headscale.v1.AuthApproveResponse - 50, // 49: headscale.v1.HeadscaleService.AuthReject:output_type -> headscale.v1.AuthRejectResponse - 51, // 50: headscale.v1.HeadscaleService.CreateApiKey:output_type -> headscale.v1.CreateApiKeyResponse - 52, // 51: headscale.v1.HeadscaleService.ExpireApiKey:output_type -> headscale.v1.ExpireApiKeyResponse - 53, // 52: headscale.v1.HeadscaleService.ListApiKeys:output_type -> headscale.v1.ListApiKeysResponse - 54, // 53: headscale.v1.HeadscaleService.DeleteApiKey:output_type -> headscale.v1.DeleteApiKeyResponse - 55, // 54: headscale.v1.HeadscaleService.GetPolicy:output_type -> headscale.v1.GetPolicyResponse - 56, // 55: headscale.v1.HeadscaleService.SetPolicy:output_type -> headscale.v1.SetPolicyResponse - 57, // 56: headscale.v1.HeadscaleService.CheckPolicy:output_type -> headscale.v1.CheckPolicyResponse - 1, // 57: headscale.v1.HeadscaleService.Health:output_type -> headscale.v1.HealthResponse - 29, // [29:58] is the sub-list for method output_type - 0, // [0:29] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name -} - -func init() { file_headscale_v1_headscale_proto_init() } -func file_headscale_v1_headscale_proto_init() { - if File_headscale_v1_headscale_proto != nil { - return - } - file_headscale_v1_user_proto_init() - file_headscale_v1_preauthkey_proto_init() - file_headscale_v1_node_proto_init() - file_headscale_v1_apikey_proto_init() - file_headscale_v1_auth_proto_init() - file_headscale_v1_policy_proto_init() - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_headscale_proto_rawDesc), len(file_headscale_v1_headscale_proto_rawDesc)), - NumEnums: 0, - NumMessages: 2, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_headscale_v1_headscale_proto_goTypes, - DependencyIndexes: file_headscale_v1_headscale_proto_depIdxs, - MessageInfos: file_headscale_v1_headscale_proto_msgTypes, - }.Build() - File_headscale_v1_headscale_proto = out.File - file_headscale_v1_headscale_proto_goTypes = nil - file_headscale_v1_headscale_proto_depIdxs = nil -} diff --git a/gen/go/headscale/v1/headscale.pb.gw.go b/gen/go/headscale/v1/headscale.pb.gw.go deleted file mode 100644 index cbe1c9c31..000000000 --- a/gen/go/headscale/v1/headscale.pb.gw.go +++ /dev/null @@ -1,2201 +0,0 @@ -// Code generated by protoc-gen-grpc-gateway. DO NOT EDIT. -// source: headscale/v1/headscale.proto - -/* -Package v1 is a reverse proxy. - -It translates gRPC into RESTful JSON APIs. -*/ -package v1 - -import ( - "context" - "errors" - "io" - "net/http" - - "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" - "github.com/grpc-ecosystem/grpc-gateway/v2/utilities" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/grpclog" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/proto" -) - -// Suppress "imported and not used" errors -var ( - _ codes.Code - _ io.Reader - _ status.Status - _ = errors.New - _ = runtime.String - _ = utilities.NewDoubleArray - _ = metadata.Join -) - -func request_HeadscaleService_CreateUser_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq CreateUserRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.CreateUser(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_CreateUser_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq CreateUserRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.CreateUser(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_RenameUser_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RenameUserRequest - metadata runtime.ServerMetadata - err error - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - val, ok := pathParams["old_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "old_id") - } - protoReq.OldId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "old_id", err) - } - val, ok = pathParams["new_name"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "new_name") - } - protoReq.NewName, err = runtime.String(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "new_name", err) - } - msg, err := client.RenameUser(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_RenameUser_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RenameUserRequest - metadata runtime.ServerMetadata - err error - ) - val, ok := pathParams["old_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "old_id") - } - protoReq.OldId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "old_id", err) - } - val, ok = pathParams["new_name"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "new_name") - } - protoReq.NewName, err = runtime.String(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "new_name", err) - } - msg, err := server.RenameUser(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_DeleteUser_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DeleteUserRequest - metadata runtime.ServerMetadata - err error - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - val, ok := pathParams["id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id") - } - protoReq.Id, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err) - } - msg, err := client.DeleteUser(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_DeleteUser_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DeleteUserRequest - metadata runtime.ServerMetadata - err error - ) - val, ok := pathParams["id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "id") - } - protoReq.Id, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "id", err) - } - msg, err := server.DeleteUser(ctx, &protoReq) - return msg, metadata, err -} - -var filter_HeadscaleService_ListUsers_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} - -func request_HeadscaleService_ListUsers_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ListUsersRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_ListUsers_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := client.ListUsers(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_ListUsers_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ListUsersRequest - metadata runtime.ServerMetadata - ) - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_ListUsers_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.ListUsers(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_CreatePreAuthKey_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq CreatePreAuthKeyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.CreatePreAuthKey(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_CreatePreAuthKey_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq CreatePreAuthKeyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.CreatePreAuthKey(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_ExpirePreAuthKey_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ExpirePreAuthKeyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.ExpirePreAuthKey(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_ExpirePreAuthKey_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ExpirePreAuthKeyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.ExpirePreAuthKey(ctx, &protoReq) - return msg, metadata, err -} - -var filter_HeadscaleService_DeletePreAuthKey_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} - -func request_HeadscaleService_DeletePreAuthKey_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DeletePreAuthKeyRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_DeletePreAuthKey_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := client.DeletePreAuthKey(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_DeletePreAuthKey_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DeletePreAuthKeyRequest - metadata runtime.ServerMetadata - ) - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_DeletePreAuthKey_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.DeletePreAuthKey(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_ListPreAuthKeys_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ListPreAuthKeysRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.ListPreAuthKeys(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_ListPreAuthKeys_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ListPreAuthKeysRequest - metadata runtime.ServerMetadata - ) - msg, err := server.ListPreAuthKeys(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_DebugCreateNode_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DebugCreateNodeRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.DebugCreateNode(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_DebugCreateNode_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DebugCreateNodeRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.DebugCreateNode(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_GetNode_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq GetNodeRequest - metadata runtime.ServerMetadata - err error - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - msg, err := client.GetNode(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_GetNode_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq GetNodeRequest - metadata runtime.ServerMetadata - err error - ) - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - msg, err := server.GetNode(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_SetTags_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq SetTagsRequest - metadata runtime.ServerMetadata - err error - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - msg, err := client.SetTags(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_SetTags_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq SetTagsRequest - metadata runtime.ServerMetadata - err error - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - msg, err := server.SetTags(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_SetApprovedRoutes_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq SetApprovedRoutesRequest - metadata runtime.ServerMetadata - err error - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - msg, err := client.SetApprovedRoutes(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_SetApprovedRoutes_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq SetApprovedRoutesRequest - metadata runtime.ServerMetadata - err error - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - msg, err := server.SetApprovedRoutes(ctx, &protoReq) - return msg, metadata, err -} - -var filter_HeadscaleService_RegisterNode_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} - -func request_HeadscaleService_RegisterNode_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RegisterNodeRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_RegisterNode_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := client.RegisterNode(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_RegisterNode_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RegisterNodeRequest - metadata runtime.ServerMetadata - ) - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_RegisterNode_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.RegisterNode(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_DeleteNode_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DeleteNodeRequest - metadata runtime.ServerMetadata - err error - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - msg, err := client.DeleteNode(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_DeleteNode_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DeleteNodeRequest - metadata runtime.ServerMetadata - err error - ) - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - msg, err := server.DeleteNode(ctx, &protoReq) - return msg, metadata, err -} - -var filter_HeadscaleService_ExpireNode_0 = &utilities.DoubleArray{Encoding: map[string]int{"node_id": 0}, Base: []int{1, 1, 0}, Check: []int{0, 1, 2}} - -func request_HeadscaleService_ExpireNode_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ExpireNodeRequest - metadata runtime.ServerMetadata - err error - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_ExpireNode_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := client.ExpireNode(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_ExpireNode_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ExpireNodeRequest - metadata runtime.ServerMetadata - err error - ) - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_ExpireNode_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.ExpireNode(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_RenameNode_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RenameNodeRequest - metadata runtime.ServerMetadata - err error - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - val, ok = pathParams["new_name"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "new_name") - } - protoReq.NewName, err = runtime.String(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "new_name", err) - } - msg, err := client.RenameNode(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_RenameNode_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq RenameNodeRequest - metadata runtime.ServerMetadata - err error - ) - val, ok := pathParams["node_id"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "node_id") - } - protoReq.NodeId, err = runtime.Uint64(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "node_id", err) - } - val, ok = pathParams["new_name"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "new_name") - } - protoReq.NewName, err = runtime.String(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "new_name", err) - } - msg, err := server.RenameNode(ctx, &protoReq) - return msg, metadata, err -} - -var filter_HeadscaleService_ListNodes_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} - -func request_HeadscaleService_ListNodes_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ListNodesRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_ListNodes_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := client.ListNodes(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_ListNodes_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ListNodesRequest - metadata runtime.ServerMetadata - ) - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_ListNodes_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.ListNodes(ctx, &protoReq) - return msg, metadata, err -} - -var filter_HeadscaleService_BackfillNodeIPs_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} - -func request_HeadscaleService_BackfillNodeIPs_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq BackfillNodeIPsRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_BackfillNodeIPs_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := client.BackfillNodeIPs(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_BackfillNodeIPs_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq BackfillNodeIPsRequest - metadata runtime.ServerMetadata - ) - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_BackfillNodeIPs_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.BackfillNodeIPs(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_AuthRegister_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq AuthRegisterRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.AuthRegister(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_AuthRegister_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq AuthRegisterRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.AuthRegister(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_AuthApprove_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq AuthApproveRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.AuthApprove(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_AuthApprove_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq AuthApproveRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.AuthApprove(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_AuthReject_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq AuthRejectRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.AuthReject(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_AuthReject_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq AuthRejectRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.AuthReject(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_CreateApiKey_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq CreateApiKeyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.CreateApiKey(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_CreateApiKey_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq CreateApiKeyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.CreateApiKey(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_ExpireApiKey_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ExpireApiKeyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.ExpireApiKey(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_ExpireApiKey_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ExpireApiKeyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.ExpireApiKey(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_ListApiKeys_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ListApiKeysRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.ListApiKeys(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_ListApiKeys_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq ListApiKeysRequest - metadata runtime.ServerMetadata - ) - msg, err := server.ListApiKeys(ctx, &protoReq) - return msg, metadata, err -} - -var filter_HeadscaleService_DeleteApiKey_0 = &utilities.DoubleArray{Encoding: map[string]int{"prefix": 0}, Base: []int{1, 1, 0}, Check: []int{0, 1, 2}} - -func request_HeadscaleService_DeleteApiKey_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DeleteApiKeyRequest - metadata runtime.ServerMetadata - err error - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - val, ok := pathParams["prefix"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "prefix") - } - protoReq.Prefix, err = runtime.String(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "prefix", err) - } - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_DeleteApiKey_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := client.DeleteApiKey(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_DeleteApiKey_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq DeleteApiKeyRequest - metadata runtime.ServerMetadata - err error - ) - val, ok := pathParams["prefix"] - if !ok { - return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "prefix") - } - protoReq.Prefix, err = runtime.String(val) - if err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "prefix", err) - } - if err := req.ParseForm(); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_HeadscaleService_DeleteApiKey_0); err != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.DeleteApiKey(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_GetPolicy_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq GetPolicyRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.GetPolicy(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_GetPolicy_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq GetPolicyRequest - metadata runtime.ServerMetadata - ) - msg, err := server.GetPolicy(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_SetPolicy_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq SetPolicyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.SetPolicy(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_SetPolicy_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq SetPolicyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.SetPolicy(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_CheckPolicy_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq CheckPolicyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.CheckPolicy(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_CheckPolicy_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq CheckPolicyRequest - metadata runtime.ServerMetadata - ) - if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - msg, err := server.CheckPolicy(ctx, &protoReq) - return msg, metadata, err -} - -func request_HeadscaleService_Health_0(ctx context.Context, marshaler runtime.Marshaler, client HeadscaleServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq HealthRequest - metadata runtime.ServerMetadata - ) - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - } - msg, err := client.Health(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) - return msg, metadata, err -} - -func local_request_HeadscaleService_Health_0(ctx context.Context, marshaler runtime.Marshaler, server HeadscaleServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var ( - protoReq HealthRequest - metadata runtime.ServerMetadata - ) - msg, err := server.Health(ctx, &protoReq) - return msg, metadata, err -} - -// RegisterHeadscaleServiceHandlerServer registers the http handlers for service HeadscaleService to "mux". -// UnaryRPC :call HeadscaleServiceServer directly. -// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. -// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterHeadscaleServiceHandlerFromEndpoint instead. -// GRPC interceptors will not work for this type of registration. To use interceptors, you must use the "runtime.WithMiddlewares" option in the "runtime.NewServeMux" call. -func RegisterHeadscaleServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, server HeadscaleServiceServer) error { - mux.Handle(http.MethodPost, pattern_HeadscaleService_CreateUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CreateUser", runtime.WithHTTPPathPattern("/api/v1/user")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_CreateUser_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_CreateUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_RenameUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/RenameUser", runtime.WithHTTPPathPattern("/api/v1/user/{old_id}/rename/{new_name}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_RenameUser_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_RenameUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodDelete, pattern_HeadscaleService_DeleteUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DeleteUser", runtime.WithHTTPPathPattern("/api/v1/user/{id}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_DeleteUser_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DeleteUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_ListUsers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ListUsers", runtime.WithHTTPPathPattern("/api/v1/user")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_ListUsers_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ListUsers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_CreatePreAuthKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CreatePreAuthKey", runtime.WithHTTPPathPattern("/api/v1/preauthkey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_CreatePreAuthKey_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_CreatePreAuthKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_ExpirePreAuthKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ExpirePreAuthKey", runtime.WithHTTPPathPattern("/api/v1/preauthkey/expire")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_ExpirePreAuthKey_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ExpirePreAuthKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodDelete, pattern_HeadscaleService_DeletePreAuthKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DeletePreAuthKey", runtime.WithHTTPPathPattern("/api/v1/preauthkey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_DeletePreAuthKey_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DeletePreAuthKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_ListPreAuthKeys_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ListPreAuthKeys", runtime.WithHTTPPathPattern("/api/v1/preauthkey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_ListPreAuthKeys_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ListPreAuthKeys_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_DebugCreateNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DebugCreateNode", runtime.WithHTTPPathPattern("/api/v1/debug/node")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_DebugCreateNode_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DebugCreateNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_GetNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/GetNode", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_GetNode_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_GetNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_SetTags_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/SetTags", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}/tags")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_SetTags_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_SetTags_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_SetApprovedRoutes_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/SetApprovedRoutes", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}/approve_routes")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_SetApprovedRoutes_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_SetApprovedRoutes_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_RegisterNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/RegisterNode", runtime.WithHTTPPathPattern("/api/v1/node/register")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_RegisterNode_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_RegisterNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodDelete, pattern_HeadscaleService_DeleteNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DeleteNode", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_DeleteNode_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DeleteNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_ExpireNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ExpireNode", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}/expire")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_ExpireNode_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ExpireNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_RenameNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/RenameNode", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}/rename/{new_name}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_RenameNode_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_RenameNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_ListNodes_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ListNodes", runtime.WithHTTPPathPattern("/api/v1/node")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_ListNodes_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ListNodes_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_BackfillNodeIPs_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/BackfillNodeIPs", runtime.WithHTTPPathPattern("/api/v1/node/backfillips")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_BackfillNodeIPs_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_BackfillNodeIPs_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_AuthRegister_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/AuthRegister", runtime.WithHTTPPathPattern("/api/v1/auth/register")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_AuthRegister_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_AuthRegister_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_AuthApprove_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/AuthApprove", runtime.WithHTTPPathPattern("/api/v1/auth/approve")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_AuthApprove_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_AuthApprove_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_AuthReject_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/AuthReject", runtime.WithHTTPPathPattern("/api/v1/auth/reject")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_AuthReject_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_AuthReject_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_CreateApiKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CreateApiKey", runtime.WithHTTPPathPattern("/api/v1/apikey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_CreateApiKey_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_CreateApiKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_ExpireApiKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ExpireApiKey", runtime.WithHTTPPathPattern("/api/v1/apikey/expire")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_ExpireApiKey_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ExpireApiKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_ListApiKeys_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ListApiKeys", runtime.WithHTTPPathPattern("/api/v1/apikey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_ListApiKeys_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ListApiKeys_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodDelete, pattern_HeadscaleService_DeleteApiKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DeleteApiKey", runtime.WithHTTPPathPattern("/api/v1/apikey/{prefix}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_DeleteApiKey_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DeleteApiKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_GetPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/GetPolicy", runtime.WithHTTPPathPattern("/api/v1/policy")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_GetPolicy_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_GetPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPut, pattern_HeadscaleService_SetPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/SetPolicy", runtime.WithHTTPPathPattern("/api/v1/policy")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_SetPolicy_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_SetPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_CheckPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CheckPolicy", runtime.WithHTTPPathPattern("/api/v1/policy/check")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_CheckPolicy_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_CheckPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_Health_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - var stream runtime.ServerTransportStream - ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/headscale.v1.HeadscaleService/Health", runtime.WithHTTPPathPattern("/api/v1/health")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := local_request_HeadscaleService_Health_0(annotatedContext, inboundMarshaler, server, req, pathParams) - md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_Health_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - return nil -} - -// RegisterHeadscaleServiceHandlerFromEndpoint is same as RegisterHeadscaleServiceHandler but -// automatically dials to "endpoint" and closes the connection when "ctx" gets done. -func RegisterHeadscaleServiceHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) { - conn, err := grpc.NewClient(endpoint, opts...) - if err != nil { - return err - } - defer func() { - if err != nil { - if cerr := conn.Close(); cerr != nil { - grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) - } - return - } - go func() { - <-ctx.Done() - if cerr := conn.Close(); cerr != nil { - grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) - } - }() - }() - return RegisterHeadscaleServiceHandler(ctx, mux, conn) -} - -// RegisterHeadscaleServiceHandler registers the http handlers for service HeadscaleService to "mux". -// The handlers forward requests to the grpc endpoint over "conn". -func RegisterHeadscaleServiceHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error { - return RegisterHeadscaleServiceHandlerClient(ctx, mux, NewHeadscaleServiceClient(conn)) -} - -// RegisterHeadscaleServiceHandlerClient registers the http handlers for service HeadscaleService -// to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "HeadscaleServiceClient". -// Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "HeadscaleServiceClient" -// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in -// "HeadscaleServiceClient" to call the correct interceptors. This client ignores the HTTP middlewares. -func RegisterHeadscaleServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client HeadscaleServiceClient) error { - mux.Handle(http.MethodPost, pattern_HeadscaleService_CreateUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CreateUser", runtime.WithHTTPPathPattern("/api/v1/user")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_CreateUser_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_CreateUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_RenameUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/RenameUser", runtime.WithHTTPPathPattern("/api/v1/user/{old_id}/rename/{new_name}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_RenameUser_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_RenameUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodDelete, pattern_HeadscaleService_DeleteUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DeleteUser", runtime.WithHTTPPathPattern("/api/v1/user/{id}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_DeleteUser_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DeleteUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_ListUsers_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ListUsers", runtime.WithHTTPPathPattern("/api/v1/user")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_ListUsers_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ListUsers_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_CreatePreAuthKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CreatePreAuthKey", runtime.WithHTTPPathPattern("/api/v1/preauthkey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_CreatePreAuthKey_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_CreatePreAuthKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_ExpirePreAuthKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ExpirePreAuthKey", runtime.WithHTTPPathPattern("/api/v1/preauthkey/expire")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_ExpirePreAuthKey_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ExpirePreAuthKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodDelete, pattern_HeadscaleService_DeletePreAuthKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DeletePreAuthKey", runtime.WithHTTPPathPattern("/api/v1/preauthkey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_DeletePreAuthKey_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DeletePreAuthKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_ListPreAuthKeys_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ListPreAuthKeys", runtime.WithHTTPPathPattern("/api/v1/preauthkey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_ListPreAuthKeys_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ListPreAuthKeys_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_DebugCreateNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DebugCreateNode", runtime.WithHTTPPathPattern("/api/v1/debug/node")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_DebugCreateNode_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DebugCreateNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_GetNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/GetNode", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_GetNode_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_GetNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_SetTags_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/SetTags", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}/tags")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_SetTags_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_SetTags_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_SetApprovedRoutes_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/SetApprovedRoutes", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}/approve_routes")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_SetApprovedRoutes_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_SetApprovedRoutes_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_RegisterNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/RegisterNode", runtime.WithHTTPPathPattern("/api/v1/node/register")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_RegisterNode_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_RegisterNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodDelete, pattern_HeadscaleService_DeleteNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DeleteNode", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_DeleteNode_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DeleteNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_ExpireNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ExpireNode", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}/expire")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_ExpireNode_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ExpireNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_RenameNode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/RenameNode", runtime.WithHTTPPathPattern("/api/v1/node/{node_id}/rename/{new_name}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_RenameNode_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_RenameNode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_ListNodes_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ListNodes", runtime.WithHTTPPathPattern("/api/v1/node")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_ListNodes_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ListNodes_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_BackfillNodeIPs_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/BackfillNodeIPs", runtime.WithHTTPPathPattern("/api/v1/node/backfillips")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_BackfillNodeIPs_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_BackfillNodeIPs_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_AuthRegister_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/AuthRegister", runtime.WithHTTPPathPattern("/api/v1/auth/register")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_AuthRegister_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_AuthRegister_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_AuthApprove_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/AuthApprove", runtime.WithHTTPPathPattern("/api/v1/auth/approve")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_AuthApprove_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_AuthApprove_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_AuthReject_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/AuthReject", runtime.WithHTTPPathPattern("/api/v1/auth/reject")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_AuthReject_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_AuthReject_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_CreateApiKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CreateApiKey", runtime.WithHTTPPathPattern("/api/v1/apikey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_CreateApiKey_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_CreateApiKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_ExpireApiKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ExpireApiKey", runtime.WithHTTPPathPattern("/api/v1/apikey/expire")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_ExpireApiKey_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ExpireApiKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_ListApiKeys_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/ListApiKeys", runtime.WithHTTPPathPattern("/api/v1/apikey")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_ListApiKeys_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_ListApiKeys_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodDelete, pattern_HeadscaleService_DeleteApiKey_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/DeleteApiKey", runtime.WithHTTPPathPattern("/api/v1/apikey/{prefix}")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_DeleteApiKey_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_DeleteApiKey_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_GetPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/GetPolicy", runtime.WithHTTPPathPattern("/api/v1/policy")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_GetPolicy_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_GetPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPut, pattern_HeadscaleService_SetPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/SetPolicy", runtime.WithHTTPPathPattern("/api/v1/policy")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_SetPolicy_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_SetPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodPost, pattern_HeadscaleService_CheckPolicy_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/CheckPolicy", runtime.WithHTTPPathPattern("/api/v1/policy/check")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_CheckPolicy_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_CheckPolicy_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - mux.Handle(http.MethodGet, pattern_HeadscaleService_Health_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { - ctx, cancel := context.WithCancel(req.Context()) - defer cancel() - inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/headscale.v1.HeadscaleService/Health", runtime.WithHTTPPathPattern("/api/v1/health")) - if err != nil { - runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) - return - } - resp, md, err := request_HeadscaleService_Health_0(annotatedContext, inboundMarshaler, client, req, pathParams) - annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) - if err != nil { - runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) - return - } - forward_HeadscaleService_Health_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - return nil -} - -var ( - pattern_HeadscaleService_CreateUser_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "user"}, "")) - pattern_HeadscaleService_RenameUser_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4, 1, 0, 4, 1, 5, 5}, []string{"api", "v1", "user", "old_id", "rename", "new_name"}, "")) - pattern_HeadscaleService_DeleteUser_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "v1", "user", "id"}, "")) - pattern_HeadscaleService_ListUsers_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "user"}, "")) - pattern_HeadscaleService_CreatePreAuthKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "preauthkey"}, "")) - pattern_HeadscaleService_ExpirePreAuthKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "preauthkey", "expire"}, "")) - pattern_HeadscaleService_DeletePreAuthKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "preauthkey"}, "")) - pattern_HeadscaleService_ListPreAuthKeys_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "preauthkey"}, "")) - pattern_HeadscaleService_DebugCreateNode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "debug", "node"}, "")) - pattern_HeadscaleService_GetNode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "v1", "node", "node_id"}, "")) - pattern_HeadscaleService_SetTags_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4}, []string{"api", "v1", "node", "node_id", "tags"}, "")) - pattern_HeadscaleService_SetApprovedRoutes_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4}, []string{"api", "v1", "node", "node_id", "approve_routes"}, "")) - pattern_HeadscaleService_RegisterNode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "node", "register"}, "")) - pattern_HeadscaleService_DeleteNode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "v1", "node", "node_id"}, "")) - pattern_HeadscaleService_ExpireNode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4}, []string{"api", "v1", "node", "node_id", "expire"}, "")) - pattern_HeadscaleService_RenameNode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4, 1, 0, 4, 1, 5, 5}, []string{"api", "v1", "node", "node_id", "rename", "new_name"}, "")) - pattern_HeadscaleService_ListNodes_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "node"}, "")) - pattern_HeadscaleService_BackfillNodeIPs_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "node", "backfillips"}, "")) - pattern_HeadscaleService_AuthRegister_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "auth", "register"}, "")) - pattern_HeadscaleService_AuthApprove_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "auth", "approve"}, "")) - pattern_HeadscaleService_AuthReject_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "auth", "reject"}, "")) - pattern_HeadscaleService_CreateApiKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "apikey"}, "")) - pattern_HeadscaleService_ExpireApiKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "apikey", "expire"}, "")) - pattern_HeadscaleService_ListApiKeys_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "apikey"}, "")) - pattern_HeadscaleService_DeleteApiKey_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "v1", "apikey", "prefix"}, "")) - pattern_HeadscaleService_GetPolicy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "policy"}, "")) - pattern_HeadscaleService_SetPolicy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "policy"}, "")) - pattern_HeadscaleService_CheckPolicy_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "policy", "check"}, "")) - pattern_HeadscaleService_Health_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "health"}, "")) -) - -var ( - forward_HeadscaleService_CreateUser_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_RenameUser_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_DeleteUser_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_ListUsers_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_CreatePreAuthKey_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_ExpirePreAuthKey_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_DeletePreAuthKey_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_ListPreAuthKeys_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_DebugCreateNode_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_GetNode_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_SetTags_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_SetApprovedRoutes_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_RegisterNode_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_DeleteNode_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_ExpireNode_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_RenameNode_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_ListNodes_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_BackfillNodeIPs_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_AuthRegister_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_AuthApprove_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_AuthReject_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_CreateApiKey_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_ExpireApiKey_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_ListApiKeys_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_DeleteApiKey_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_GetPolicy_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_SetPolicy_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_CheckPolicy_0 = runtime.ForwardResponseMessage - forward_HeadscaleService_Health_0 = runtime.ForwardResponseMessage -) diff --git a/gen/go/headscale/v1/headscale_grpc.pb.go b/gen/go/headscale/v1/headscale_grpc.pb.go deleted file mode 100644 index 07e09b42b..000000000 --- a/gen/go/headscale/v1/headscale_grpc.pb.go +++ /dev/null @@ -1,1199 +0,0 @@ -// Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// versions: -// - protoc-gen-go-grpc v1.6.2 -// - protoc (unknown) -// source: headscale/v1/headscale.proto - -package v1 - -import ( - context "context" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" -) - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.64.0 or later. -const _ = grpc.SupportPackageIsVersion9 - -const ( - HeadscaleService_CreateUser_FullMethodName = "/headscale.v1.HeadscaleService/CreateUser" - HeadscaleService_RenameUser_FullMethodName = "/headscale.v1.HeadscaleService/RenameUser" - HeadscaleService_DeleteUser_FullMethodName = "/headscale.v1.HeadscaleService/DeleteUser" - HeadscaleService_ListUsers_FullMethodName = "/headscale.v1.HeadscaleService/ListUsers" - HeadscaleService_CreatePreAuthKey_FullMethodName = "/headscale.v1.HeadscaleService/CreatePreAuthKey" - HeadscaleService_ExpirePreAuthKey_FullMethodName = "/headscale.v1.HeadscaleService/ExpirePreAuthKey" - HeadscaleService_DeletePreAuthKey_FullMethodName = "/headscale.v1.HeadscaleService/DeletePreAuthKey" - HeadscaleService_ListPreAuthKeys_FullMethodName = "/headscale.v1.HeadscaleService/ListPreAuthKeys" - HeadscaleService_DebugCreateNode_FullMethodName = "/headscale.v1.HeadscaleService/DebugCreateNode" - HeadscaleService_GetNode_FullMethodName = "/headscale.v1.HeadscaleService/GetNode" - HeadscaleService_SetTags_FullMethodName = "/headscale.v1.HeadscaleService/SetTags" - HeadscaleService_SetApprovedRoutes_FullMethodName = "/headscale.v1.HeadscaleService/SetApprovedRoutes" - HeadscaleService_RegisterNode_FullMethodName = "/headscale.v1.HeadscaleService/RegisterNode" - HeadscaleService_DeleteNode_FullMethodName = "/headscale.v1.HeadscaleService/DeleteNode" - HeadscaleService_ExpireNode_FullMethodName = "/headscale.v1.HeadscaleService/ExpireNode" - HeadscaleService_RenameNode_FullMethodName = "/headscale.v1.HeadscaleService/RenameNode" - HeadscaleService_ListNodes_FullMethodName = "/headscale.v1.HeadscaleService/ListNodes" - HeadscaleService_BackfillNodeIPs_FullMethodName = "/headscale.v1.HeadscaleService/BackfillNodeIPs" - HeadscaleService_AuthRegister_FullMethodName = "/headscale.v1.HeadscaleService/AuthRegister" - HeadscaleService_AuthApprove_FullMethodName = "/headscale.v1.HeadscaleService/AuthApprove" - HeadscaleService_AuthReject_FullMethodName = "/headscale.v1.HeadscaleService/AuthReject" - HeadscaleService_CreateApiKey_FullMethodName = "/headscale.v1.HeadscaleService/CreateApiKey" - HeadscaleService_ExpireApiKey_FullMethodName = "/headscale.v1.HeadscaleService/ExpireApiKey" - HeadscaleService_ListApiKeys_FullMethodName = "/headscale.v1.HeadscaleService/ListApiKeys" - HeadscaleService_DeleteApiKey_FullMethodName = "/headscale.v1.HeadscaleService/DeleteApiKey" - HeadscaleService_GetPolicy_FullMethodName = "/headscale.v1.HeadscaleService/GetPolicy" - HeadscaleService_SetPolicy_FullMethodName = "/headscale.v1.HeadscaleService/SetPolicy" - HeadscaleService_CheckPolicy_FullMethodName = "/headscale.v1.HeadscaleService/CheckPolicy" - HeadscaleService_Health_FullMethodName = "/headscale.v1.HeadscaleService/Health" -) - -// HeadscaleServiceClient is the client API for HeadscaleService service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. -type HeadscaleServiceClient interface { - // --- User start --- - CreateUser(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*CreateUserResponse, error) - RenameUser(ctx context.Context, in *RenameUserRequest, opts ...grpc.CallOption) (*RenameUserResponse, error) - DeleteUser(ctx context.Context, in *DeleteUserRequest, opts ...grpc.CallOption) (*DeleteUserResponse, error) - ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (*ListUsersResponse, error) - // --- PreAuthKeys start --- - CreatePreAuthKey(ctx context.Context, in *CreatePreAuthKeyRequest, opts ...grpc.CallOption) (*CreatePreAuthKeyResponse, error) - ExpirePreAuthKey(ctx context.Context, in *ExpirePreAuthKeyRequest, opts ...grpc.CallOption) (*ExpirePreAuthKeyResponse, error) - DeletePreAuthKey(ctx context.Context, in *DeletePreAuthKeyRequest, opts ...grpc.CallOption) (*DeletePreAuthKeyResponse, error) - ListPreAuthKeys(ctx context.Context, in *ListPreAuthKeysRequest, opts ...grpc.CallOption) (*ListPreAuthKeysResponse, error) - // --- Node start --- - DebugCreateNode(ctx context.Context, in *DebugCreateNodeRequest, opts ...grpc.CallOption) (*DebugCreateNodeResponse, error) - GetNode(ctx context.Context, in *GetNodeRequest, opts ...grpc.CallOption) (*GetNodeResponse, error) - SetTags(ctx context.Context, in *SetTagsRequest, opts ...grpc.CallOption) (*SetTagsResponse, error) - SetApprovedRoutes(ctx context.Context, in *SetApprovedRoutesRequest, opts ...grpc.CallOption) (*SetApprovedRoutesResponse, error) - RegisterNode(ctx context.Context, in *RegisterNodeRequest, opts ...grpc.CallOption) (*RegisterNodeResponse, error) - DeleteNode(ctx context.Context, in *DeleteNodeRequest, opts ...grpc.CallOption) (*DeleteNodeResponse, error) - ExpireNode(ctx context.Context, in *ExpireNodeRequest, opts ...grpc.CallOption) (*ExpireNodeResponse, error) - RenameNode(ctx context.Context, in *RenameNodeRequest, opts ...grpc.CallOption) (*RenameNodeResponse, error) - ListNodes(ctx context.Context, in *ListNodesRequest, opts ...grpc.CallOption) (*ListNodesResponse, error) - BackfillNodeIPs(ctx context.Context, in *BackfillNodeIPsRequest, opts ...grpc.CallOption) (*BackfillNodeIPsResponse, error) - // --- Auth start --- - AuthRegister(ctx context.Context, in *AuthRegisterRequest, opts ...grpc.CallOption) (*AuthRegisterResponse, error) - AuthApprove(ctx context.Context, in *AuthApproveRequest, opts ...grpc.CallOption) (*AuthApproveResponse, error) - AuthReject(ctx context.Context, in *AuthRejectRequest, opts ...grpc.CallOption) (*AuthRejectResponse, error) - // --- ApiKeys start --- - CreateApiKey(ctx context.Context, in *CreateApiKeyRequest, opts ...grpc.CallOption) (*CreateApiKeyResponse, error) - ExpireApiKey(ctx context.Context, in *ExpireApiKeyRequest, opts ...grpc.CallOption) (*ExpireApiKeyResponse, error) - ListApiKeys(ctx context.Context, in *ListApiKeysRequest, opts ...grpc.CallOption) (*ListApiKeysResponse, error) - DeleteApiKey(ctx context.Context, in *DeleteApiKeyRequest, opts ...grpc.CallOption) (*DeleteApiKeyResponse, error) - // --- Policy start --- - GetPolicy(ctx context.Context, in *GetPolicyRequest, opts ...grpc.CallOption) (*GetPolicyResponse, error) - SetPolicy(ctx context.Context, in *SetPolicyRequest, opts ...grpc.CallOption) (*SetPolicyResponse, error) - CheckPolicy(ctx context.Context, in *CheckPolicyRequest, opts ...grpc.CallOption) (*CheckPolicyResponse, error) - // --- Health start --- - Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) -} - -type headscaleServiceClient struct { - cc grpc.ClientConnInterface -} - -func NewHeadscaleServiceClient(cc grpc.ClientConnInterface) HeadscaleServiceClient { - return &headscaleServiceClient{cc} -} - -func (c *headscaleServiceClient) CreateUser(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*CreateUserResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(CreateUserResponse) - err := c.cc.Invoke(ctx, HeadscaleService_CreateUser_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) RenameUser(ctx context.Context, in *RenameUserRequest, opts ...grpc.CallOption) (*RenameUserResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(RenameUserResponse) - err := c.cc.Invoke(ctx, HeadscaleService_RenameUser_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) DeleteUser(ctx context.Context, in *DeleteUserRequest, opts ...grpc.CallOption) (*DeleteUserResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(DeleteUserResponse) - err := c.cc.Invoke(ctx, HeadscaleService_DeleteUser_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) ListUsers(ctx context.Context, in *ListUsersRequest, opts ...grpc.CallOption) (*ListUsersResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ListUsersResponse) - err := c.cc.Invoke(ctx, HeadscaleService_ListUsers_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) CreatePreAuthKey(ctx context.Context, in *CreatePreAuthKeyRequest, opts ...grpc.CallOption) (*CreatePreAuthKeyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(CreatePreAuthKeyResponse) - err := c.cc.Invoke(ctx, HeadscaleService_CreatePreAuthKey_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) ExpirePreAuthKey(ctx context.Context, in *ExpirePreAuthKeyRequest, opts ...grpc.CallOption) (*ExpirePreAuthKeyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ExpirePreAuthKeyResponse) - err := c.cc.Invoke(ctx, HeadscaleService_ExpirePreAuthKey_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) DeletePreAuthKey(ctx context.Context, in *DeletePreAuthKeyRequest, opts ...grpc.CallOption) (*DeletePreAuthKeyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(DeletePreAuthKeyResponse) - err := c.cc.Invoke(ctx, HeadscaleService_DeletePreAuthKey_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) ListPreAuthKeys(ctx context.Context, in *ListPreAuthKeysRequest, opts ...grpc.CallOption) (*ListPreAuthKeysResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ListPreAuthKeysResponse) - err := c.cc.Invoke(ctx, HeadscaleService_ListPreAuthKeys_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) DebugCreateNode(ctx context.Context, in *DebugCreateNodeRequest, opts ...grpc.CallOption) (*DebugCreateNodeResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(DebugCreateNodeResponse) - err := c.cc.Invoke(ctx, HeadscaleService_DebugCreateNode_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) GetNode(ctx context.Context, in *GetNodeRequest, opts ...grpc.CallOption) (*GetNodeResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(GetNodeResponse) - err := c.cc.Invoke(ctx, HeadscaleService_GetNode_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) SetTags(ctx context.Context, in *SetTagsRequest, opts ...grpc.CallOption) (*SetTagsResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(SetTagsResponse) - err := c.cc.Invoke(ctx, HeadscaleService_SetTags_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) SetApprovedRoutes(ctx context.Context, in *SetApprovedRoutesRequest, opts ...grpc.CallOption) (*SetApprovedRoutesResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(SetApprovedRoutesResponse) - err := c.cc.Invoke(ctx, HeadscaleService_SetApprovedRoutes_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) RegisterNode(ctx context.Context, in *RegisterNodeRequest, opts ...grpc.CallOption) (*RegisterNodeResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(RegisterNodeResponse) - err := c.cc.Invoke(ctx, HeadscaleService_RegisterNode_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) DeleteNode(ctx context.Context, in *DeleteNodeRequest, opts ...grpc.CallOption) (*DeleteNodeResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(DeleteNodeResponse) - err := c.cc.Invoke(ctx, HeadscaleService_DeleteNode_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) ExpireNode(ctx context.Context, in *ExpireNodeRequest, opts ...grpc.CallOption) (*ExpireNodeResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ExpireNodeResponse) - err := c.cc.Invoke(ctx, HeadscaleService_ExpireNode_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) RenameNode(ctx context.Context, in *RenameNodeRequest, opts ...grpc.CallOption) (*RenameNodeResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(RenameNodeResponse) - err := c.cc.Invoke(ctx, HeadscaleService_RenameNode_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) ListNodes(ctx context.Context, in *ListNodesRequest, opts ...grpc.CallOption) (*ListNodesResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ListNodesResponse) - err := c.cc.Invoke(ctx, HeadscaleService_ListNodes_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) BackfillNodeIPs(ctx context.Context, in *BackfillNodeIPsRequest, opts ...grpc.CallOption) (*BackfillNodeIPsResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(BackfillNodeIPsResponse) - err := c.cc.Invoke(ctx, HeadscaleService_BackfillNodeIPs_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) AuthRegister(ctx context.Context, in *AuthRegisterRequest, opts ...grpc.CallOption) (*AuthRegisterResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(AuthRegisterResponse) - err := c.cc.Invoke(ctx, HeadscaleService_AuthRegister_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) AuthApprove(ctx context.Context, in *AuthApproveRequest, opts ...grpc.CallOption) (*AuthApproveResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(AuthApproveResponse) - err := c.cc.Invoke(ctx, HeadscaleService_AuthApprove_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) AuthReject(ctx context.Context, in *AuthRejectRequest, opts ...grpc.CallOption) (*AuthRejectResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(AuthRejectResponse) - err := c.cc.Invoke(ctx, HeadscaleService_AuthReject_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) CreateApiKey(ctx context.Context, in *CreateApiKeyRequest, opts ...grpc.CallOption) (*CreateApiKeyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(CreateApiKeyResponse) - err := c.cc.Invoke(ctx, HeadscaleService_CreateApiKey_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) ExpireApiKey(ctx context.Context, in *ExpireApiKeyRequest, opts ...grpc.CallOption) (*ExpireApiKeyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ExpireApiKeyResponse) - err := c.cc.Invoke(ctx, HeadscaleService_ExpireApiKey_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) ListApiKeys(ctx context.Context, in *ListApiKeysRequest, opts ...grpc.CallOption) (*ListApiKeysResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ListApiKeysResponse) - err := c.cc.Invoke(ctx, HeadscaleService_ListApiKeys_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) DeleteApiKey(ctx context.Context, in *DeleteApiKeyRequest, opts ...grpc.CallOption) (*DeleteApiKeyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(DeleteApiKeyResponse) - err := c.cc.Invoke(ctx, HeadscaleService_DeleteApiKey_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) GetPolicy(ctx context.Context, in *GetPolicyRequest, opts ...grpc.CallOption) (*GetPolicyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(GetPolicyResponse) - err := c.cc.Invoke(ctx, HeadscaleService_GetPolicy_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) SetPolicy(ctx context.Context, in *SetPolicyRequest, opts ...grpc.CallOption) (*SetPolicyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(SetPolicyResponse) - err := c.cc.Invoke(ctx, HeadscaleService_SetPolicy_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) CheckPolicy(ctx context.Context, in *CheckPolicyRequest, opts ...grpc.CallOption) (*CheckPolicyResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(CheckPolicyResponse) - err := c.cc.Invoke(ctx, HeadscaleService_CheckPolicy_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *headscaleServiceClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(HealthResponse) - err := c.cc.Invoke(ctx, HeadscaleService_Health_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -// HeadscaleServiceServer is the server API for HeadscaleService service. -// All implementations must embed UnimplementedHeadscaleServiceServer -// for forward compatibility. -type HeadscaleServiceServer interface { - // --- User start --- - CreateUser(context.Context, *CreateUserRequest) (*CreateUserResponse, error) - RenameUser(context.Context, *RenameUserRequest) (*RenameUserResponse, error) - DeleteUser(context.Context, *DeleteUserRequest) (*DeleteUserResponse, error) - ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error) - // --- PreAuthKeys start --- - CreatePreAuthKey(context.Context, *CreatePreAuthKeyRequest) (*CreatePreAuthKeyResponse, error) - ExpirePreAuthKey(context.Context, *ExpirePreAuthKeyRequest) (*ExpirePreAuthKeyResponse, error) - DeletePreAuthKey(context.Context, *DeletePreAuthKeyRequest) (*DeletePreAuthKeyResponse, error) - ListPreAuthKeys(context.Context, *ListPreAuthKeysRequest) (*ListPreAuthKeysResponse, error) - // --- Node start --- - DebugCreateNode(context.Context, *DebugCreateNodeRequest) (*DebugCreateNodeResponse, error) - GetNode(context.Context, *GetNodeRequest) (*GetNodeResponse, error) - SetTags(context.Context, *SetTagsRequest) (*SetTagsResponse, error) - SetApprovedRoutes(context.Context, *SetApprovedRoutesRequest) (*SetApprovedRoutesResponse, error) - RegisterNode(context.Context, *RegisterNodeRequest) (*RegisterNodeResponse, error) - DeleteNode(context.Context, *DeleteNodeRequest) (*DeleteNodeResponse, error) - ExpireNode(context.Context, *ExpireNodeRequest) (*ExpireNodeResponse, error) - RenameNode(context.Context, *RenameNodeRequest) (*RenameNodeResponse, error) - ListNodes(context.Context, *ListNodesRequest) (*ListNodesResponse, error) - BackfillNodeIPs(context.Context, *BackfillNodeIPsRequest) (*BackfillNodeIPsResponse, error) - // --- Auth start --- - AuthRegister(context.Context, *AuthRegisterRequest) (*AuthRegisterResponse, error) - AuthApprove(context.Context, *AuthApproveRequest) (*AuthApproveResponse, error) - AuthReject(context.Context, *AuthRejectRequest) (*AuthRejectResponse, error) - // --- ApiKeys start --- - CreateApiKey(context.Context, *CreateApiKeyRequest) (*CreateApiKeyResponse, error) - ExpireApiKey(context.Context, *ExpireApiKeyRequest) (*ExpireApiKeyResponse, error) - ListApiKeys(context.Context, *ListApiKeysRequest) (*ListApiKeysResponse, error) - DeleteApiKey(context.Context, *DeleteApiKeyRequest) (*DeleteApiKeyResponse, error) - // --- Policy start --- - GetPolicy(context.Context, *GetPolicyRequest) (*GetPolicyResponse, error) - SetPolicy(context.Context, *SetPolicyRequest) (*SetPolicyResponse, error) - CheckPolicy(context.Context, *CheckPolicyRequest) (*CheckPolicyResponse, error) - // --- Health start --- - Health(context.Context, *HealthRequest) (*HealthResponse, error) - mustEmbedUnimplementedHeadscaleServiceServer() -} - -// UnimplementedHeadscaleServiceServer must be embedded to have -// forward compatible implementations. -// -// NOTE: this should be embedded by value instead of pointer to avoid a nil -// pointer dereference when methods are called. -type UnimplementedHeadscaleServiceServer struct{} - -func (UnimplementedHeadscaleServiceServer) CreateUser(context.Context, *CreateUserRequest) (*CreateUserResponse, error) { - return nil, status.Error(codes.Unimplemented, "method CreateUser not implemented") -} -func (UnimplementedHeadscaleServiceServer) RenameUser(context.Context, *RenameUserRequest) (*RenameUserResponse, error) { - return nil, status.Error(codes.Unimplemented, "method RenameUser not implemented") -} -func (UnimplementedHeadscaleServiceServer) DeleteUser(context.Context, *DeleteUserRequest) (*DeleteUserResponse, error) { - return nil, status.Error(codes.Unimplemented, "method DeleteUser not implemented") -} -func (UnimplementedHeadscaleServiceServer) ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ListUsers not implemented") -} -func (UnimplementedHeadscaleServiceServer) CreatePreAuthKey(context.Context, *CreatePreAuthKeyRequest) (*CreatePreAuthKeyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method CreatePreAuthKey not implemented") -} -func (UnimplementedHeadscaleServiceServer) ExpirePreAuthKey(context.Context, *ExpirePreAuthKeyRequest) (*ExpirePreAuthKeyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ExpirePreAuthKey not implemented") -} -func (UnimplementedHeadscaleServiceServer) DeletePreAuthKey(context.Context, *DeletePreAuthKeyRequest) (*DeletePreAuthKeyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method DeletePreAuthKey not implemented") -} -func (UnimplementedHeadscaleServiceServer) ListPreAuthKeys(context.Context, *ListPreAuthKeysRequest) (*ListPreAuthKeysResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ListPreAuthKeys not implemented") -} -func (UnimplementedHeadscaleServiceServer) DebugCreateNode(context.Context, *DebugCreateNodeRequest) (*DebugCreateNodeResponse, error) { - return nil, status.Error(codes.Unimplemented, "method DebugCreateNode not implemented") -} -func (UnimplementedHeadscaleServiceServer) GetNode(context.Context, *GetNodeRequest) (*GetNodeResponse, error) { - return nil, status.Error(codes.Unimplemented, "method GetNode not implemented") -} -func (UnimplementedHeadscaleServiceServer) SetTags(context.Context, *SetTagsRequest) (*SetTagsResponse, error) { - return nil, status.Error(codes.Unimplemented, "method SetTags not implemented") -} -func (UnimplementedHeadscaleServiceServer) SetApprovedRoutes(context.Context, *SetApprovedRoutesRequest) (*SetApprovedRoutesResponse, error) { - return nil, status.Error(codes.Unimplemented, "method SetApprovedRoutes not implemented") -} -func (UnimplementedHeadscaleServiceServer) RegisterNode(context.Context, *RegisterNodeRequest) (*RegisterNodeResponse, error) { - return nil, status.Error(codes.Unimplemented, "method RegisterNode not implemented") -} -func (UnimplementedHeadscaleServiceServer) DeleteNode(context.Context, *DeleteNodeRequest) (*DeleteNodeResponse, error) { - return nil, status.Error(codes.Unimplemented, "method DeleteNode not implemented") -} -func (UnimplementedHeadscaleServiceServer) ExpireNode(context.Context, *ExpireNodeRequest) (*ExpireNodeResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ExpireNode not implemented") -} -func (UnimplementedHeadscaleServiceServer) RenameNode(context.Context, *RenameNodeRequest) (*RenameNodeResponse, error) { - return nil, status.Error(codes.Unimplemented, "method RenameNode not implemented") -} -func (UnimplementedHeadscaleServiceServer) ListNodes(context.Context, *ListNodesRequest) (*ListNodesResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ListNodes not implemented") -} -func (UnimplementedHeadscaleServiceServer) BackfillNodeIPs(context.Context, *BackfillNodeIPsRequest) (*BackfillNodeIPsResponse, error) { - return nil, status.Error(codes.Unimplemented, "method BackfillNodeIPs not implemented") -} -func (UnimplementedHeadscaleServiceServer) AuthRegister(context.Context, *AuthRegisterRequest) (*AuthRegisterResponse, error) { - return nil, status.Error(codes.Unimplemented, "method AuthRegister not implemented") -} -func (UnimplementedHeadscaleServiceServer) AuthApprove(context.Context, *AuthApproveRequest) (*AuthApproveResponse, error) { - return nil, status.Error(codes.Unimplemented, "method AuthApprove not implemented") -} -func (UnimplementedHeadscaleServiceServer) AuthReject(context.Context, *AuthRejectRequest) (*AuthRejectResponse, error) { - return nil, status.Error(codes.Unimplemented, "method AuthReject not implemented") -} -func (UnimplementedHeadscaleServiceServer) CreateApiKey(context.Context, *CreateApiKeyRequest) (*CreateApiKeyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method CreateApiKey not implemented") -} -func (UnimplementedHeadscaleServiceServer) ExpireApiKey(context.Context, *ExpireApiKeyRequest) (*ExpireApiKeyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ExpireApiKey not implemented") -} -func (UnimplementedHeadscaleServiceServer) ListApiKeys(context.Context, *ListApiKeysRequest) (*ListApiKeysResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ListApiKeys not implemented") -} -func (UnimplementedHeadscaleServiceServer) DeleteApiKey(context.Context, *DeleteApiKeyRequest) (*DeleteApiKeyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method DeleteApiKey not implemented") -} -func (UnimplementedHeadscaleServiceServer) GetPolicy(context.Context, *GetPolicyRequest) (*GetPolicyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method GetPolicy not implemented") -} -func (UnimplementedHeadscaleServiceServer) SetPolicy(context.Context, *SetPolicyRequest) (*SetPolicyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method SetPolicy not implemented") -} -func (UnimplementedHeadscaleServiceServer) CheckPolicy(context.Context, *CheckPolicyRequest) (*CheckPolicyResponse, error) { - return nil, status.Error(codes.Unimplemented, "method CheckPolicy not implemented") -} -func (UnimplementedHeadscaleServiceServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) { - return nil, status.Error(codes.Unimplemented, "method Health not implemented") -} -func (UnimplementedHeadscaleServiceServer) mustEmbedUnimplementedHeadscaleServiceServer() {} -func (UnimplementedHeadscaleServiceServer) testEmbeddedByValue() {} - -// UnsafeHeadscaleServiceServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to HeadscaleServiceServer will -// result in compilation errors. -type UnsafeHeadscaleServiceServer interface { - mustEmbedUnimplementedHeadscaleServiceServer() -} - -func RegisterHeadscaleServiceServer(s grpc.ServiceRegistrar, srv HeadscaleServiceServer) { - // If the following call panics, it indicates UnimplementedHeadscaleServiceServer was - // embedded by pointer and is nil. This will cause panics if an - // unimplemented method is ever invoked, so we test this at initialization - // time to prevent it from happening at runtime later due to I/O. - if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { - t.testEmbeddedByValue() - } - s.RegisterService(&HeadscaleService_ServiceDesc, srv) -} - -func _HeadscaleService_CreateUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(CreateUserRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).CreateUser(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_CreateUser_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).CreateUser(ctx, req.(*CreateUserRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_RenameUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(RenameUserRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).RenameUser(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_RenameUser_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).RenameUser(ctx, req.(*RenameUserRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_DeleteUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(DeleteUserRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).DeleteUser(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_DeleteUser_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).DeleteUser(ctx, req.(*DeleteUserRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_ListUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ListUsersRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).ListUsers(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_ListUsers_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).ListUsers(ctx, req.(*ListUsersRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_CreatePreAuthKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(CreatePreAuthKeyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).CreatePreAuthKey(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_CreatePreAuthKey_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).CreatePreAuthKey(ctx, req.(*CreatePreAuthKeyRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_ExpirePreAuthKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ExpirePreAuthKeyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).ExpirePreAuthKey(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_ExpirePreAuthKey_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).ExpirePreAuthKey(ctx, req.(*ExpirePreAuthKeyRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_DeletePreAuthKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(DeletePreAuthKeyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).DeletePreAuthKey(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_DeletePreAuthKey_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).DeletePreAuthKey(ctx, req.(*DeletePreAuthKeyRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_ListPreAuthKeys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ListPreAuthKeysRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).ListPreAuthKeys(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_ListPreAuthKeys_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).ListPreAuthKeys(ctx, req.(*ListPreAuthKeysRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_DebugCreateNode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(DebugCreateNodeRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).DebugCreateNode(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_DebugCreateNode_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).DebugCreateNode(ctx, req.(*DebugCreateNodeRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_GetNode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GetNodeRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).GetNode(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_GetNode_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).GetNode(ctx, req.(*GetNodeRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_SetTags_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(SetTagsRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).SetTags(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_SetTags_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).SetTags(ctx, req.(*SetTagsRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_SetApprovedRoutes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(SetApprovedRoutesRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).SetApprovedRoutes(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_SetApprovedRoutes_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).SetApprovedRoutes(ctx, req.(*SetApprovedRoutesRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_RegisterNode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(RegisterNodeRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).RegisterNode(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_RegisterNode_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).RegisterNode(ctx, req.(*RegisterNodeRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_DeleteNode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(DeleteNodeRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).DeleteNode(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_DeleteNode_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).DeleteNode(ctx, req.(*DeleteNodeRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_ExpireNode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ExpireNodeRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).ExpireNode(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_ExpireNode_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).ExpireNode(ctx, req.(*ExpireNodeRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_RenameNode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(RenameNodeRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).RenameNode(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_RenameNode_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).RenameNode(ctx, req.(*RenameNodeRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_ListNodes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ListNodesRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).ListNodes(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_ListNodes_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).ListNodes(ctx, req.(*ListNodesRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_BackfillNodeIPs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(BackfillNodeIPsRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).BackfillNodeIPs(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_BackfillNodeIPs_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).BackfillNodeIPs(ctx, req.(*BackfillNodeIPsRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_AuthRegister_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(AuthRegisterRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).AuthRegister(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_AuthRegister_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).AuthRegister(ctx, req.(*AuthRegisterRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_AuthApprove_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(AuthApproveRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).AuthApprove(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_AuthApprove_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).AuthApprove(ctx, req.(*AuthApproveRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_AuthReject_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(AuthRejectRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).AuthReject(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_AuthReject_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).AuthReject(ctx, req.(*AuthRejectRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_CreateApiKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(CreateApiKeyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).CreateApiKey(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_CreateApiKey_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).CreateApiKey(ctx, req.(*CreateApiKeyRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_ExpireApiKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ExpireApiKeyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).ExpireApiKey(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_ExpireApiKey_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).ExpireApiKey(ctx, req.(*ExpireApiKeyRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_ListApiKeys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ListApiKeysRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).ListApiKeys(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_ListApiKeys_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).ListApiKeys(ctx, req.(*ListApiKeysRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_DeleteApiKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(DeleteApiKeyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).DeleteApiKey(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_DeleteApiKey_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).DeleteApiKey(ctx, req.(*DeleteApiKeyRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_GetPolicy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GetPolicyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).GetPolicy(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_GetPolicy_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).GetPolicy(ctx, req.(*GetPolicyRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_SetPolicy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(SetPolicyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).SetPolicy(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_SetPolicy_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).SetPolicy(ctx, req.(*SetPolicyRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_CheckPolicy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(CheckPolicyRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).CheckPolicy(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_CheckPolicy_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).CheckPolicy(ctx, req.(*CheckPolicyRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _HeadscaleService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(HealthRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(HeadscaleServiceServer).Health(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: HeadscaleService_Health_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HeadscaleServiceServer).Health(ctx, req.(*HealthRequest)) - } - return interceptor(ctx, in, info, handler) -} - -// HeadscaleService_ServiceDesc is the grpc.ServiceDesc for HeadscaleService service. -// It's only intended for direct use with grpc.RegisterService, -// and not to be introspected or modified (even as a copy) -var HeadscaleService_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "headscale.v1.HeadscaleService", - HandlerType: (*HeadscaleServiceServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "CreateUser", - Handler: _HeadscaleService_CreateUser_Handler, - }, - { - MethodName: "RenameUser", - Handler: _HeadscaleService_RenameUser_Handler, - }, - { - MethodName: "DeleteUser", - Handler: _HeadscaleService_DeleteUser_Handler, - }, - { - MethodName: "ListUsers", - Handler: _HeadscaleService_ListUsers_Handler, - }, - { - MethodName: "CreatePreAuthKey", - Handler: _HeadscaleService_CreatePreAuthKey_Handler, - }, - { - MethodName: "ExpirePreAuthKey", - Handler: _HeadscaleService_ExpirePreAuthKey_Handler, - }, - { - MethodName: "DeletePreAuthKey", - Handler: _HeadscaleService_DeletePreAuthKey_Handler, - }, - { - MethodName: "ListPreAuthKeys", - Handler: _HeadscaleService_ListPreAuthKeys_Handler, - }, - { - MethodName: "DebugCreateNode", - Handler: _HeadscaleService_DebugCreateNode_Handler, - }, - { - MethodName: "GetNode", - Handler: _HeadscaleService_GetNode_Handler, - }, - { - MethodName: "SetTags", - Handler: _HeadscaleService_SetTags_Handler, - }, - { - MethodName: "SetApprovedRoutes", - Handler: _HeadscaleService_SetApprovedRoutes_Handler, - }, - { - MethodName: "RegisterNode", - Handler: _HeadscaleService_RegisterNode_Handler, - }, - { - MethodName: "DeleteNode", - Handler: _HeadscaleService_DeleteNode_Handler, - }, - { - MethodName: "ExpireNode", - Handler: _HeadscaleService_ExpireNode_Handler, - }, - { - MethodName: "RenameNode", - Handler: _HeadscaleService_RenameNode_Handler, - }, - { - MethodName: "ListNodes", - Handler: _HeadscaleService_ListNodes_Handler, - }, - { - MethodName: "BackfillNodeIPs", - Handler: _HeadscaleService_BackfillNodeIPs_Handler, - }, - { - MethodName: "AuthRegister", - Handler: _HeadscaleService_AuthRegister_Handler, - }, - { - MethodName: "AuthApprove", - Handler: _HeadscaleService_AuthApprove_Handler, - }, - { - MethodName: "AuthReject", - Handler: _HeadscaleService_AuthReject_Handler, - }, - { - MethodName: "CreateApiKey", - Handler: _HeadscaleService_CreateApiKey_Handler, - }, - { - MethodName: "ExpireApiKey", - Handler: _HeadscaleService_ExpireApiKey_Handler, - }, - { - MethodName: "ListApiKeys", - Handler: _HeadscaleService_ListApiKeys_Handler, - }, - { - MethodName: "DeleteApiKey", - Handler: _HeadscaleService_DeleteApiKey_Handler, - }, - { - MethodName: "GetPolicy", - Handler: _HeadscaleService_GetPolicy_Handler, - }, - { - MethodName: "SetPolicy", - Handler: _HeadscaleService_SetPolicy_Handler, - }, - { - MethodName: "CheckPolicy", - Handler: _HeadscaleService_CheckPolicy_Handler, - }, - { - MethodName: "Health", - Handler: _HeadscaleService_Health_Handler, - }, - }, - Streams: []grpc.StreamDesc{}, - Metadata: "headscale/v1/headscale.proto", -} diff --git a/gen/go/headscale/v1/node.pb.go b/gen/go/headscale/v1/node.pb.go deleted file mode 100644 index 32142ed8b..000000000 --- a/gen/go/headscale/v1/node.pb.go +++ /dev/null @@ -1,1379 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: headscale/v1/node.proto - -package v1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type RegisterMethod int32 - -const ( - RegisterMethod_REGISTER_METHOD_UNSPECIFIED RegisterMethod = 0 - RegisterMethod_REGISTER_METHOD_AUTH_KEY RegisterMethod = 1 - RegisterMethod_REGISTER_METHOD_CLI RegisterMethod = 2 - RegisterMethod_REGISTER_METHOD_OIDC RegisterMethod = 3 -) - -// Enum value maps for RegisterMethod. -var ( - RegisterMethod_name = map[int32]string{ - 0: "REGISTER_METHOD_UNSPECIFIED", - 1: "REGISTER_METHOD_AUTH_KEY", - 2: "REGISTER_METHOD_CLI", - 3: "REGISTER_METHOD_OIDC", - } - RegisterMethod_value = map[string]int32{ - "REGISTER_METHOD_UNSPECIFIED": 0, - "REGISTER_METHOD_AUTH_KEY": 1, - "REGISTER_METHOD_CLI": 2, - "REGISTER_METHOD_OIDC": 3, - } -) - -func (x RegisterMethod) Enum() *RegisterMethod { - p := new(RegisterMethod) - *p = x - return p -} - -func (x RegisterMethod) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (RegisterMethod) Descriptor() protoreflect.EnumDescriptor { - return file_headscale_v1_node_proto_enumTypes[0].Descriptor() -} - -func (RegisterMethod) Type() protoreflect.EnumType { - return &file_headscale_v1_node_proto_enumTypes[0] -} - -func (x RegisterMethod) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use RegisterMethod.Descriptor instead. -func (RegisterMethod) EnumDescriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{0} -} - -type Node struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` - MachineKey string `protobuf:"bytes,2,opt,name=machine_key,json=machineKey,proto3" json:"machine_key,omitempty"` - NodeKey string `protobuf:"bytes,3,opt,name=node_key,json=nodeKey,proto3" json:"node_key,omitempty"` - DiscoKey string `protobuf:"bytes,4,opt,name=disco_key,json=discoKey,proto3" json:"disco_key,omitempty"` - IpAddresses []string `protobuf:"bytes,5,rep,name=ip_addresses,json=ipAddresses,proto3" json:"ip_addresses,omitempty"` - Name string `protobuf:"bytes,6,opt,name=name,proto3" json:"name,omitempty"` - User *User `protobuf:"bytes,7,opt,name=user,proto3" json:"user,omitempty"` - LastSeen *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=last_seen,json=lastSeen,proto3" json:"last_seen,omitempty"` - Expiry *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=expiry,proto3" json:"expiry,omitempty"` - PreAuthKey *PreAuthKey `protobuf:"bytes,11,opt,name=pre_auth_key,json=preAuthKey,proto3" json:"pre_auth_key,omitempty"` - CreatedAt *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - RegisterMethod RegisterMethod `protobuf:"varint,13,opt,name=register_method,json=registerMethod,proto3,enum=headscale.v1.RegisterMethod" json:"register_method,omitempty"` - // Deprecated - // repeated string forced_tags = 18; - // repeated string invalid_tags = 19; - // repeated string valid_tags = 20; - GivenName string `protobuf:"bytes,21,opt,name=given_name,json=givenName,proto3" json:"given_name,omitempty"` - Online bool `protobuf:"varint,22,opt,name=online,proto3" json:"online,omitempty"` - ApprovedRoutes []string `protobuf:"bytes,23,rep,name=approved_routes,json=approvedRoutes,proto3" json:"approved_routes,omitempty"` - AvailableRoutes []string `protobuf:"bytes,24,rep,name=available_routes,json=availableRoutes,proto3" json:"available_routes,omitempty"` - SubnetRoutes []string `protobuf:"bytes,25,rep,name=subnet_routes,json=subnetRoutes,proto3" json:"subnet_routes,omitempty"` - Tags []string `protobuf:"bytes,26,rep,name=tags,proto3" json:"tags,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Node) Reset() { - *x = Node{} - mi := &file_headscale_v1_node_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Node) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Node) ProtoMessage() {} - -func (x *Node) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Node.ProtoReflect.Descriptor instead. -func (*Node) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{0} -} - -func (x *Node) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -func (x *Node) GetMachineKey() string { - if x != nil { - return x.MachineKey - } - return "" -} - -func (x *Node) GetNodeKey() string { - if x != nil { - return x.NodeKey - } - return "" -} - -func (x *Node) GetDiscoKey() string { - if x != nil { - return x.DiscoKey - } - return "" -} - -func (x *Node) GetIpAddresses() []string { - if x != nil { - return x.IpAddresses - } - return nil -} - -func (x *Node) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *Node) GetUser() *User { - if x != nil { - return x.User - } - return nil -} - -func (x *Node) GetLastSeen() *timestamppb.Timestamp { - if x != nil { - return x.LastSeen - } - return nil -} - -func (x *Node) GetExpiry() *timestamppb.Timestamp { - if x != nil { - return x.Expiry - } - return nil -} - -func (x *Node) GetPreAuthKey() *PreAuthKey { - if x != nil { - return x.PreAuthKey - } - return nil -} - -func (x *Node) GetCreatedAt() *timestamppb.Timestamp { - if x != nil { - return x.CreatedAt - } - return nil -} - -func (x *Node) GetRegisterMethod() RegisterMethod { - if x != nil { - return x.RegisterMethod - } - return RegisterMethod_REGISTER_METHOD_UNSPECIFIED -} - -func (x *Node) GetGivenName() string { - if x != nil { - return x.GivenName - } - return "" -} - -func (x *Node) GetOnline() bool { - if x != nil { - return x.Online - } - return false -} - -func (x *Node) GetApprovedRoutes() []string { - if x != nil { - return x.ApprovedRoutes - } - return nil -} - -func (x *Node) GetAvailableRoutes() []string { - if x != nil { - return x.AvailableRoutes - } - return nil -} - -func (x *Node) GetSubnetRoutes() []string { - if x != nil { - return x.SubnetRoutes - } - return nil -} - -func (x *Node) GetTags() []string { - if x != nil { - return x.Tags - } - return nil -} - -type RegisterNodeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` - Key string `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RegisterNodeRequest) Reset() { - *x = RegisterNodeRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RegisterNodeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RegisterNodeRequest) ProtoMessage() {} - -func (x *RegisterNodeRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RegisterNodeRequest.ProtoReflect.Descriptor instead. -func (*RegisterNodeRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{1} -} - -func (x *RegisterNodeRequest) GetUser() string { - if x != nil { - return x.User - } - return "" -} - -func (x *RegisterNodeRequest) GetKey() string { - if x != nil { - return x.Key - } - return "" -} - -type RegisterNodeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RegisterNodeResponse) Reset() { - *x = RegisterNodeResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RegisterNodeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RegisterNodeResponse) ProtoMessage() {} - -func (x *RegisterNodeResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RegisterNodeResponse.ProtoReflect.Descriptor instead. -func (*RegisterNodeResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{2} -} - -func (x *RegisterNodeResponse) GetNode() *Node { - if x != nil { - return x.Node - } - return nil -} - -type GetNodeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - NodeId uint64 `protobuf:"varint,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetNodeRequest) Reset() { - *x = GetNodeRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetNodeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetNodeRequest) ProtoMessage() {} - -func (x *GetNodeRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetNodeRequest.ProtoReflect.Descriptor instead. -func (*GetNodeRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{3} -} - -func (x *GetNodeRequest) GetNodeId() uint64 { - if x != nil { - return x.NodeId - } - return 0 -} - -type GetNodeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetNodeResponse) Reset() { - *x = GetNodeResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetNodeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetNodeResponse) ProtoMessage() {} - -func (x *GetNodeResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetNodeResponse.ProtoReflect.Descriptor instead. -func (*GetNodeResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{4} -} - -func (x *GetNodeResponse) GetNode() *Node { - if x != nil { - return x.Node - } - return nil -} - -type SetTagsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - NodeId uint64 `protobuf:"varint,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` - Tags []string `protobuf:"bytes,2,rep,name=tags,proto3" json:"tags,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SetTagsRequest) Reset() { - *x = SetTagsRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SetTagsRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SetTagsRequest) ProtoMessage() {} - -func (x *SetTagsRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SetTagsRequest.ProtoReflect.Descriptor instead. -func (*SetTagsRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{5} -} - -func (x *SetTagsRequest) GetNodeId() uint64 { - if x != nil { - return x.NodeId - } - return 0 -} - -func (x *SetTagsRequest) GetTags() []string { - if x != nil { - return x.Tags - } - return nil -} - -type SetTagsResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SetTagsResponse) Reset() { - *x = SetTagsResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SetTagsResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SetTagsResponse) ProtoMessage() {} - -func (x *SetTagsResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[6] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SetTagsResponse.ProtoReflect.Descriptor instead. -func (*SetTagsResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{6} -} - -func (x *SetTagsResponse) GetNode() *Node { - if x != nil { - return x.Node - } - return nil -} - -type SetApprovedRoutesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - NodeId uint64 `protobuf:"varint,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` - Routes []string `protobuf:"bytes,2,rep,name=routes,proto3" json:"routes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SetApprovedRoutesRequest) Reset() { - *x = SetApprovedRoutesRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SetApprovedRoutesRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SetApprovedRoutesRequest) ProtoMessage() {} - -func (x *SetApprovedRoutesRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[7] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SetApprovedRoutesRequest.ProtoReflect.Descriptor instead. -func (*SetApprovedRoutesRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{7} -} - -func (x *SetApprovedRoutesRequest) GetNodeId() uint64 { - if x != nil { - return x.NodeId - } - return 0 -} - -func (x *SetApprovedRoutesRequest) GetRoutes() []string { - if x != nil { - return x.Routes - } - return nil -} - -type SetApprovedRoutesResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SetApprovedRoutesResponse) Reset() { - *x = SetApprovedRoutesResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SetApprovedRoutesResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SetApprovedRoutesResponse) ProtoMessage() {} - -func (x *SetApprovedRoutesResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[8] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SetApprovedRoutesResponse.ProtoReflect.Descriptor instead. -func (*SetApprovedRoutesResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{8} -} - -func (x *SetApprovedRoutesResponse) GetNode() *Node { - if x != nil { - return x.Node - } - return nil -} - -type DeleteNodeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - NodeId uint64 `protobuf:"varint,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteNodeRequest) Reset() { - *x = DeleteNodeRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteNodeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteNodeRequest) ProtoMessage() {} - -func (x *DeleteNodeRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[9] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteNodeRequest.ProtoReflect.Descriptor instead. -func (*DeleteNodeRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{9} -} - -func (x *DeleteNodeRequest) GetNodeId() uint64 { - if x != nil { - return x.NodeId - } - return 0 -} - -type DeleteNodeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteNodeResponse) Reset() { - *x = DeleteNodeResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteNodeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteNodeResponse) ProtoMessage() {} - -func (x *DeleteNodeResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[10] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteNodeResponse.ProtoReflect.Descriptor instead. -func (*DeleteNodeResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{10} -} - -type ExpireNodeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - NodeId uint64 `protobuf:"varint,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` - Expiry *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=expiry,proto3" json:"expiry,omitempty"` - // When true, sets expiry to null (node will never expire). - DisableExpiry bool `protobuf:"varint,3,opt,name=disable_expiry,json=disableExpiry,proto3" json:"disable_expiry,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExpireNodeRequest) Reset() { - *x = ExpireNodeRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExpireNodeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExpireNodeRequest) ProtoMessage() {} - -func (x *ExpireNodeRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[11] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExpireNodeRequest.ProtoReflect.Descriptor instead. -func (*ExpireNodeRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{11} -} - -func (x *ExpireNodeRequest) GetNodeId() uint64 { - if x != nil { - return x.NodeId - } - return 0 -} - -func (x *ExpireNodeRequest) GetExpiry() *timestamppb.Timestamp { - if x != nil { - return x.Expiry - } - return nil -} - -func (x *ExpireNodeRequest) GetDisableExpiry() bool { - if x != nil { - return x.DisableExpiry - } - return false -} - -type ExpireNodeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExpireNodeResponse) Reset() { - *x = ExpireNodeResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExpireNodeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExpireNodeResponse) ProtoMessage() {} - -func (x *ExpireNodeResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[12] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExpireNodeResponse.ProtoReflect.Descriptor instead. -func (*ExpireNodeResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{12} -} - -func (x *ExpireNodeResponse) GetNode() *Node { - if x != nil { - return x.Node - } - return nil -} - -type RenameNodeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - NodeId uint64 `protobuf:"varint,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` - NewName string `protobuf:"bytes,2,opt,name=new_name,json=newName,proto3" json:"new_name,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RenameNodeRequest) Reset() { - *x = RenameNodeRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RenameNodeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RenameNodeRequest) ProtoMessage() {} - -func (x *RenameNodeRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[13] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RenameNodeRequest.ProtoReflect.Descriptor instead. -func (*RenameNodeRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{13} -} - -func (x *RenameNodeRequest) GetNodeId() uint64 { - if x != nil { - return x.NodeId - } - return 0 -} - -func (x *RenameNodeRequest) GetNewName() string { - if x != nil { - return x.NewName - } - return "" -} - -type RenameNodeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RenameNodeResponse) Reset() { - *x = RenameNodeResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RenameNodeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RenameNodeResponse) ProtoMessage() {} - -func (x *RenameNodeResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[14] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RenameNodeResponse.ProtoReflect.Descriptor instead. -func (*RenameNodeResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{14} -} - -func (x *RenameNodeResponse) GetNode() *Node { - if x != nil { - return x.Node - } - return nil -} - -type ListNodesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListNodesRequest) Reset() { - *x = ListNodesRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[15] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListNodesRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListNodesRequest) ProtoMessage() {} - -func (x *ListNodesRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[15] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListNodesRequest.ProtoReflect.Descriptor instead. -func (*ListNodesRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{15} -} - -func (x *ListNodesRequest) GetUser() string { - if x != nil { - return x.User - } - return "" -} - -type ListNodesResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Nodes []*Node `protobuf:"bytes,1,rep,name=nodes,proto3" json:"nodes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListNodesResponse) Reset() { - *x = ListNodesResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[16] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListNodesResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListNodesResponse) ProtoMessage() {} - -func (x *ListNodesResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[16] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListNodesResponse.ProtoReflect.Descriptor instead. -func (*ListNodesResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{16} -} - -func (x *ListNodesResponse) GetNodes() []*Node { - if x != nil { - return x.Nodes - } - return nil -} - -type DebugCreateNodeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` - Key string `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"` - Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` - Routes []string `protobuf:"bytes,4,rep,name=routes,proto3" json:"routes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DebugCreateNodeRequest) Reset() { - *x = DebugCreateNodeRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[17] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DebugCreateNodeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DebugCreateNodeRequest) ProtoMessage() {} - -func (x *DebugCreateNodeRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[17] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DebugCreateNodeRequest.ProtoReflect.Descriptor instead. -func (*DebugCreateNodeRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{17} -} - -func (x *DebugCreateNodeRequest) GetUser() string { - if x != nil { - return x.User - } - return "" -} - -func (x *DebugCreateNodeRequest) GetKey() string { - if x != nil { - return x.Key - } - return "" -} - -func (x *DebugCreateNodeRequest) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *DebugCreateNodeRequest) GetRoutes() []string { - if x != nil { - return x.Routes - } - return nil -} - -type DebugCreateNodeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DebugCreateNodeResponse) Reset() { - *x = DebugCreateNodeResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[18] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DebugCreateNodeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DebugCreateNodeResponse) ProtoMessage() {} - -func (x *DebugCreateNodeResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[18] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DebugCreateNodeResponse.ProtoReflect.Descriptor instead. -func (*DebugCreateNodeResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{18} -} - -func (x *DebugCreateNodeResponse) GetNode() *Node { - if x != nil { - return x.Node - } - return nil -} - -type BackfillNodeIPsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Confirmed bool `protobuf:"varint,1,opt,name=confirmed,proto3" json:"confirmed,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *BackfillNodeIPsRequest) Reset() { - *x = BackfillNodeIPsRequest{} - mi := &file_headscale_v1_node_proto_msgTypes[19] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *BackfillNodeIPsRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*BackfillNodeIPsRequest) ProtoMessage() {} - -func (x *BackfillNodeIPsRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[19] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use BackfillNodeIPsRequest.ProtoReflect.Descriptor instead. -func (*BackfillNodeIPsRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{19} -} - -func (x *BackfillNodeIPsRequest) GetConfirmed() bool { - if x != nil { - return x.Confirmed - } - return false -} - -type BackfillNodeIPsResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Changes []string `protobuf:"bytes,1,rep,name=changes,proto3" json:"changes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *BackfillNodeIPsResponse) Reset() { - *x = BackfillNodeIPsResponse{} - mi := &file_headscale_v1_node_proto_msgTypes[20] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *BackfillNodeIPsResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*BackfillNodeIPsResponse) ProtoMessage() {} - -func (x *BackfillNodeIPsResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_node_proto_msgTypes[20] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use BackfillNodeIPsResponse.ProtoReflect.Descriptor instead. -func (*BackfillNodeIPsResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_node_proto_rawDescGZIP(), []int{20} -} - -func (x *BackfillNodeIPsResponse) GetChanges() []string { - if x != nil { - return x.Changes - } - return nil -} - -var File_headscale_v1_node_proto protoreflect.FileDescriptor - -const file_headscale_v1_node_proto_rawDesc = "" + - "\n" + - "\x17headscale/v1/node.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1dheadscale/v1/preauthkey.proto\x1a\x17headscale/v1/user.proto\"\xc9\x05\n" + - "\x04Node\x12\x0e\n" + - "\x02id\x18\x01 \x01(\x04R\x02id\x12\x1f\n" + - "\vmachine_key\x18\x02 \x01(\tR\n" + - "machineKey\x12\x19\n" + - "\bnode_key\x18\x03 \x01(\tR\anodeKey\x12\x1b\n" + - "\tdisco_key\x18\x04 \x01(\tR\bdiscoKey\x12!\n" + - "\fip_addresses\x18\x05 \x03(\tR\vipAddresses\x12\x12\n" + - "\x04name\x18\x06 \x01(\tR\x04name\x12&\n" + - "\x04user\x18\a \x01(\v2\x12.headscale.v1.UserR\x04user\x127\n" + - "\tlast_seen\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\blastSeen\x122\n" + - "\x06expiry\x18\n" + - " \x01(\v2\x1a.google.protobuf.TimestampR\x06expiry\x12:\n" + - "\fpre_auth_key\x18\v \x01(\v2\x18.headscale.v1.PreAuthKeyR\n" + - "preAuthKey\x129\n" + - "\n" + - "created_at\x18\f \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12E\n" + - "\x0fregister_method\x18\r \x01(\x0e2\x1c.headscale.v1.RegisterMethodR\x0eregisterMethod\x12\x1d\n" + - "\n" + - "given_name\x18\x15 \x01(\tR\tgivenName\x12\x16\n" + - "\x06online\x18\x16 \x01(\bR\x06online\x12'\n" + - "\x0fapproved_routes\x18\x17 \x03(\tR\x0eapprovedRoutes\x12)\n" + - "\x10available_routes\x18\x18 \x03(\tR\x0favailableRoutes\x12#\n" + - "\rsubnet_routes\x18\x19 \x03(\tR\fsubnetRoutes\x12\x12\n" + - "\x04tags\x18\x1a \x03(\tR\x04tagsJ\x04\b\t\x10\n" + - "J\x04\b\x0e\x10\x15\";\n" + - "\x13RegisterNodeRequest\x12\x12\n" + - "\x04user\x18\x01 \x01(\tR\x04user\x12\x10\n" + - "\x03key\x18\x02 \x01(\tR\x03key\">\n" + - "\x14RegisterNodeResponse\x12&\n" + - "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\")\n" + - "\x0eGetNodeRequest\x12\x17\n" + - "\anode_id\x18\x01 \x01(\x04R\x06nodeId\"9\n" + - "\x0fGetNodeResponse\x12&\n" + - "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\"=\n" + - "\x0eSetTagsRequest\x12\x17\n" + - "\anode_id\x18\x01 \x01(\x04R\x06nodeId\x12\x12\n" + - "\x04tags\x18\x02 \x03(\tR\x04tags\"9\n" + - "\x0fSetTagsResponse\x12&\n" + - "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\"K\n" + - "\x18SetApprovedRoutesRequest\x12\x17\n" + - "\anode_id\x18\x01 \x01(\x04R\x06nodeId\x12\x16\n" + - "\x06routes\x18\x02 \x03(\tR\x06routes\"C\n" + - "\x19SetApprovedRoutesResponse\x12&\n" + - "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\",\n" + - "\x11DeleteNodeRequest\x12\x17\n" + - "\anode_id\x18\x01 \x01(\x04R\x06nodeId\"\x14\n" + - "\x12DeleteNodeResponse\"\x87\x01\n" + - "\x11ExpireNodeRequest\x12\x17\n" + - "\anode_id\x18\x01 \x01(\x04R\x06nodeId\x122\n" + - "\x06expiry\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x06expiry\x12%\n" + - "\x0edisable_expiry\x18\x03 \x01(\bR\rdisableExpiry\"<\n" + - "\x12ExpireNodeResponse\x12&\n" + - "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\"G\n" + - "\x11RenameNodeRequest\x12\x17\n" + - "\anode_id\x18\x01 \x01(\x04R\x06nodeId\x12\x19\n" + - "\bnew_name\x18\x02 \x01(\tR\anewName\"<\n" + - "\x12RenameNodeResponse\x12&\n" + - "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\"&\n" + - "\x10ListNodesRequest\x12\x12\n" + - "\x04user\x18\x01 \x01(\tR\x04user\"=\n" + - "\x11ListNodesResponse\x12(\n" + - "\x05nodes\x18\x01 \x03(\v2\x12.headscale.v1.NodeR\x05nodes\"j\n" + - "\x16DebugCreateNodeRequest\x12\x12\n" + - "\x04user\x18\x01 \x01(\tR\x04user\x12\x10\n" + - "\x03key\x18\x02 \x01(\tR\x03key\x12\x12\n" + - "\x04name\x18\x03 \x01(\tR\x04name\x12\x16\n" + - "\x06routes\x18\x04 \x03(\tR\x06routes\"A\n" + - "\x17DebugCreateNodeResponse\x12&\n" + - "\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\"6\n" + - "\x16BackfillNodeIPsRequest\x12\x1c\n" + - "\tconfirmed\x18\x01 \x01(\bR\tconfirmed\"3\n" + - "\x17BackfillNodeIPsResponse\x12\x18\n" + - "\achanges\x18\x01 \x03(\tR\achanges*\x82\x01\n" + - "\x0eRegisterMethod\x12\x1f\n" + - "\x1bREGISTER_METHOD_UNSPECIFIED\x10\x00\x12\x1c\n" + - "\x18REGISTER_METHOD_AUTH_KEY\x10\x01\x12\x17\n" + - "\x13REGISTER_METHOD_CLI\x10\x02\x12\x18\n" + - "\x14REGISTER_METHOD_OIDC\x10\x03B)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3" - -var ( - file_headscale_v1_node_proto_rawDescOnce sync.Once - file_headscale_v1_node_proto_rawDescData []byte -) - -func file_headscale_v1_node_proto_rawDescGZIP() []byte { - file_headscale_v1_node_proto_rawDescOnce.Do(func() { - file_headscale_v1_node_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_node_proto_rawDesc), len(file_headscale_v1_node_proto_rawDesc))) - }) - return file_headscale_v1_node_proto_rawDescData -} - -var file_headscale_v1_node_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_headscale_v1_node_proto_msgTypes = make([]protoimpl.MessageInfo, 21) -var file_headscale_v1_node_proto_goTypes = []any{ - (RegisterMethod)(0), // 0: headscale.v1.RegisterMethod - (*Node)(nil), // 1: headscale.v1.Node - (*RegisterNodeRequest)(nil), // 2: headscale.v1.RegisterNodeRequest - (*RegisterNodeResponse)(nil), // 3: headscale.v1.RegisterNodeResponse - (*GetNodeRequest)(nil), // 4: headscale.v1.GetNodeRequest - (*GetNodeResponse)(nil), // 5: headscale.v1.GetNodeResponse - (*SetTagsRequest)(nil), // 6: headscale.v1.SetTagsRequest - (*SetTagsResponse)(nil), // 7: headscale.v1.SetTagsResponse - (*SetApprovedRoutesRequest)(nil), // 8: headscale.v1.SetApprovedRoutesRequest - (*SetApprovedRoutesResponse)(nil), // 9: headscale.v1.SetApprovedRoutesResponse - (*DeleteNodeRequest)(nil), // 10: headscale.v1.DeleteNodeRequest - (*DeleteNodeResponse)(nil), // 11: headscale.v1.DeleteNodeResponse - (*ExpireNodeRequest)(nil), // 12: headscale.v1.ExpireNodeRequest - (*ExpireNodeResponse)(nil), // 13: headscale.v1.ExpireNodeResponse - (*RenameNodeRequest)(nil), // 14: headscale.v1.RenameNodeRequest - (*RenameNodeResponse)(nil), // 15: headscale.v1.RenameNodeResponse - (*ListNodesRequest)(nil), // 16: headscale.v1.ListNodesRequest - (*ListNodesResponse)(nil), // 17: headscale.v1.ListNodesResponse - (*DebugCreateNodeRequest)(nil), // 18: headscale.v1.DebugCreateNodeRequest - (*DebugCreateNodeResponse)(nil), // 19: headscale.v1.DebugCreateNodeResponse - (*BackfillNodeIPsRequest)(nil), // 20: headscale.v1.BackfillNodeIPsRequest - (*BackfillNodeIPsResponse)(nil), // 21: headscale.v1.BackfillNodeIPsResponse - (*User)(nil), // 22: headscale.v1.User - (*timestamppb.Timestamp)(nil), // 23: google.protobuf.Timestamp - (*PreAuthKey)(nil), // 24: headscale.v1.PreAuthKey -} -var file_headscale_v1_node_proto_depIdxs = []int32{ - 22, // 0: headscale.v1.Node.user:type_name -> headscale.v1.User - 23, // 1: headscale.v1.Node.last_seen:type_name -> google.protobuf.Timestamp - 23, // 2: headscale.v1.Node.expiry:type_name -> google.protobuf.Timestamp - 24, // 3: headscale.v1.Node.pre_auth_key:type_name -> headscale.v1.PreAuthKey - 23, // 4: headscale.v1.Node.created_at:type_name -> google.protobuf.Timestamp - 0, // 5: headscale.v1.Node.register_method:type_name -> headscale.v1.RegisterMethod - 1, // 6: headscale.v1.RegisterNodeResponse.node:type_name -> headscale.v1.Node - 1, // 7: headscale.v1.GetNodeResponse.node:type_name -> headscale.v1.Node - 1, // 8: headscale.v1.SetTagsResponse.node:type_name -> headscale.v1.Node - 1, // 9: headscale.v1.SetApprovedRoutesResponse.node:type_name -> headscale.v1.Node - 23, // 10: headscale.v1.ExpireNodeRequest.expiry:type_name -> google.protobuf.Timestamp - 1, // 11: headscale.v1.ExpireNodeResponse.node:type_name -> headscale.v1.Node - 1, // 12: headscale.v1.RenameNodeResponse.node:type_name -> headscale.v1.Node - 1, // 13: headscale.v1.ListNodesResponse.nodes:type_name -> headscale.v1.Node - 1, // 14: headscale.v1.DebugCreateNodeResponse.node:type_name -> headscale.v1.Node - 15, // [15:15] is the sub-list for method output_type - 15, // [15:15] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name -} - -func init() { file_headscale_v1_node_proto_init() } -func file_headscale_v1_node_proto_init() { - if File_headscale_v1_node_proto != nil { - return - } - file_headscale_v1_preauthkey_proto_init() - file_headscale_v1_user_proto_init() - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_node_proto_rawDesc), len(file_headscale_v1_node_proto_rawDesc)), - NumEnums: 1, - NumMessages: 21, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_headscale_v1_node_proto_goTypes, - DependencyIndexes: file_headscale_v1_node_proto_depIdxs, - EnumInfos: file_headscale_v1_node_proto_enumTypes, - MessageInfos: file_headscale_v1_node_proto_msgTypes, - }.Build() - File_headscale_v1_node_proto = out.File - file_headscale_v1_node_proto_goTypes = nil - file_headscale_v1_node_proto_depIdxs = nil -} diff --git a/gen/go/headscale/v1/policy.pb.go b/gen/go/headscale/v1/policy.pb.go deleted file mode 100644 index 7b58ac121..000000000 --- a/gen/go/headscale/v1/policy.pb.go +++ /dev/null @@ -1,363 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: headscale/v1/policy.proto - -package v1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type SetPolicyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Policy string `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SetPolicyRequest) Reset() { - *x = SetPolicyRequest{} - mi := &file_headscale_v1_policy_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SetPolicyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SetPolicyRequest) ProtoMessage() {} - -func (x *SetPolicyRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_policy_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SetPolicyRequest.ProtoReflect.Descriptor instead. -func (*SetPolicyRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_policy_proto_rawDescGZIP(), []int{0} -} - -func (x *SetPolicyRequest) GetPolicy() string { - if x != nil { - return x.Policy - } - return "" -} - -type SetPolicyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Policy string `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"` - UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SetPolicyResponse) Reset() { - *x = SetPolicyResponse{} - mi := &file_headscale_v1_policy_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SetPolicyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SetPolicyResponse) ProtoMessage() {} - -func (x *SetPolicyResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_policy_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SetPolicyResponse.ProtoReflect.Descriptor instead. -func (*SetPolicyResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_policy_proto_rawDescGZIP(), []int{1} -} - -func (x *SetPolicyResponse) GetPolicy() string { - if x != nil { - return x.Policy - } - return "" -} - -func (x *SetPolicyResponse) GetUpdatedAt() *timestamppb.Timestamp { - if x != nil { - return x.UpdatedAt - } - return nil -} - -type GetPolicyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetPolicyRequest) Reset() { - *x = GetPolicyRequest{} - mi := &file_headscale_v1_policy_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetPolicyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetPolicyRequest) ProtoMessage() {} - -func (x *GetPolicyRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_policy_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetPolicyRequest.ProtoReflect.Descriptor instead. -func (*GetPolicyRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_policy_proto_rawDescGZIP(), []int{2} -} - -type GetPolicyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Policy string `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"` - UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetPolicyResponse) Reset() { - *x = GetPolicyResponse{} - mi := &file_headscale_v1_policy_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetPolicyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetPolicyResponse) ProtoMessage() {} - -func (x *GetPolicyResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_policy_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetPolicyResponse.ProtoReflect.Descriptor instead. -func (*GetPolicyResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_policy_proto_rawDescGZIP(), []int{3} -} - -func (x *GetPolicyResponse) GetPolicy() string { - if x != nil { - return x.Policy - } - return "" -} - -func (x *GetPolicyResponse) GetUpdatedAt() *timestamppb.Timestamp { - if x != nil { - return x.UpdatedAt - } - return nil -} - -type CheckPolicyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Policy string `protobuf:"bytes,1,opt,name=policy,proto3" json:"policy,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CheckPolicyRequest) Reset() { - *x = CheckPolicyRequest{} - mi := &file_headscale_v1_policy_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CheckPolicyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CheckPolicyRequest) ProtoMessage() {} - -func (x *CheckPolicyRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_policy_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CheckPolicyRequest.ProtoReflect.Descriptor instead. -func (*CheckPolicyRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_policy_proto_rawDescGZIP(), []int{4} -} - -func (x *CheckPolicyRequest) GetPolicy() string { - if x != nil { - return x.Policy - } - return "" -} - -type CheckPolicyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CheckPolicyResponse) Reset() { - *x = CheckPolicyResponse{} - mi := &file_headscale_v1_policy_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CheckPolicyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CheckPolicyResponse) ProtoMessage() {} - -func (x *CheckPolicyResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_policy_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CheckPolicyResponse.ProtoReflect.Descriptor instead. -func (*CheckPolicyResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_policy_proto_rawDescGZIP(), []int{5} -} - -var File_headscale_v1_policy_proto protoreflect.FileDescriptor - -const file_headscale_v1_policy_proto_rawDesc = "" + - "\n" + - "\x19headscale/v1/policy.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"*\n" + - "\x10SetPolicyRequest\x12\x16\n" + - "\x06policy\x18\x01 \x01(\tR\x06policy\"f\n" + - "\x11SetPolicyResponse\x12\x16\n" + - "\x06policy\x18\x01 \x01(\tR\x06policy\x129\n" + - "\n" + - "updated_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\"\x12\n" + - "\x10GetPolicyRequest\"f\n" + - "\x11GetPolicyResponse\x12\x16\n" + - "\x06policy\x18\x01 \x01(\tR\x06policy\x129\n" + - "\n" + - "updated_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\",\n" + - "\x12CheckPolicyRequest\x12\x16\n" + - "\x06policy\x18\x01 \x01(\tR\x06policy\"\x15\n" + - "\x13CheckPolicyResponseB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3" - -var ( - file_headscale_v1_policy_proto_rawDescOnce sync.Once - file_headscale_v1_policy_proto_rawDescData []byte -) - -func file_headscale_v1_policy_proto_rawDescGZIP() []byte { - file_headscale_v1_policy_proto_rawDescOnce.Do(func() { - file_headscale_v1_policy_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_policy_proto_rawDesc), len(file_headscale_v1_policy_proto_rawDesc))) - }) - return file_headscale_v1_policy_proto_rawDescData -} - -var file_headscale_v1_policy_proto_msgTypes = make([]protoimpl.MessageInfo, 6) -var file_headscale_v1_policy_proto_goTypes = []any{ - (*SetPolicyRequest)(nil), // 0: headscale.v1.SetPolicyRequest - (*SetPolicyResponse)(nil), // 1: headscale.v1.SetPolicyResponse - (*GetPolicyRequest)(nil), // 2: headscale.v1.GetPolicyRequest - (*GetPolicyResponse)(nil), // 3: headscale.v1.GetPolicyResponse - (*CheckPolicyRequest)(nil), // 4: headscale.v1.CheckPolicyRequest - (*CheckPolicyResponse)(nil), // 5: headscale.v1.CheckPolicyResponse - (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp -} -var file_headscale_v1_policy_proto_depIdxs = []int32{ - 6, // 0: headscale.v1.SetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp - 6, // 1: headscale.v1.GetPolicyResponse.updated_at:type_name -> google.protobuf.Timestamp - 2, // [2:2] is the sub-list for method output_type - 2, // [2:2] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name -} - -func init() { file_headscale_v1_policy_proto_init() } -func file_headscale_v1_policy_proto_init() { - if File_headscale_v1_policy_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_policy_proto_rawDesc), len(file_headscale_v1_policy_proto_rawDesc)), - NumEnums: 0, - NumMessages: 6, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_headscale_v1_policy_proto_goTypes, - DependencyIndexes: file_headscale_v1_policy_proto_depIdxs, - MessageInfos: file_headscale_v1_policy_proto_msgTypes, - }.Build() - File_headscale_v1_policy_proto = out.File - file_headscale_v1_policy_proto_goTypes = nil - file_headscale_v1_policy_proto_depIdxs = nil -} diff --git a/gen/go/headscale/v1/preauthkey.pb.go b/gen/go/headscale/v1/preauthkey.pb.go deleted file mode 100644 index ff902d45c..000000000 --- a/gen/go/headscale/v1/preauthkey.pb.go +++ /dev/null @@ -1,596 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: headscale/v1/preauthkey.proto - -package v1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type PreAuthKey struct { - state protoimpl.MessageState `protogen:"open.v1"` - User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` - Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` - Key string `protobuf:"bytes,3,opt,name=key,proto3" json:"key,omitempty"` - Reusable bool `protobuf:"varint,4,opt,name=reusable,proto3" json:"reusable,omitempty"` - Ephemeral bool `protobuf:"varint,5,opt,name=ephemeral,proto3" json:"ephemeral,omitempty"` - Used bool `protobuf:"varint,6,opt,name=used,proto3" json:"used,omitempty"` - Expiration *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=expiration,proto3" json:"expiration,omitempty"` - CreatedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - AclTags []string `protobuf:"bytes,9,rep,name=acl_tags,json=aclTags,proto3" json:"acl_tags,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PreAuthKey) Reset() { - *x = PreAuthKey{} - mi := &file_headscale_v1_preauthkey_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PreAuthKey) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PreAuthKey) ProtoMessage() {} - -func (x *PreAuthKey) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_preauthkey_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use PreAuthKey.ProtoReflect.Descriptor instead. -func (*PreAuthKey) Descriptor() ([]byte, []int) { - return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{0} -} - -func (x *PreAuthKey) GetUser() *User { - if x != nil { - return x.User - } - return nil -} - -func (x *PreAuthKey) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -func (x *PreAuthKey) GetKey() string { - if x != nil { - return x.Key - } - return "" -} - -func (x *PreAuthKey) GetReusable() bool { - if x != nil { - return x.Reusable - } - return false -} - -func (x *PreAuthKey) GetEphemeral() bool { - if x != nil { - return x.Ephemeral - } - return false -} - -func (x *PreAuthKey) GetUsed() bool { - if x != nil { - return x.Used - } - return false -} - -func (x *PreAuthKey) GetExpiration() *timestamppb.Timestamp { - if x != nil { - return x.Expiration - } - return nil -} - -func (x *PreAuthKey) GetCreatedAt() *timestamppb.Timestamp { - if x != nil { - return x.CreatedAt - } - return nil -} - -func (x *PreAuthKey) GetAclTags() []string { - if x != nil { - return x.AclTags - } - return nil -} - -type CreatePreAuthKeyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - User uint64 `protobuf:"varint,1,opt,name=user,proto3" json:"user,omitempty"` - Reusable bool `protobuf:"varint,2,opt,name=reusable,proto3" json:"reusable,omitempty"` - Ephemeral bool `protobuf:"varint,3,opt,name=ephemeral,proto3" json:"ephemeral,omitempty"` - Expiration *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=expiration,proto3" json:"expiration,omitempty"` - AclTags []string `protobuf:"bytes,5,rep,name=acl_tags,json=aclTags,proto3" json:"acl_tags,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CreatePreAuthKeyRequest) Reset() { - *x = CreatePreAuthKeyRequest{} - mi := &file_headscale_v1_preauthkey_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CreatePreAuthKeyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CreatePreAuthKeyRequest) ProtoMessage() {} - -func (x *CreatePreAuthKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_preauthkey_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CreatePreAuthKeyRequest.ProtoReflect.Descriptor instead. -func (*CreatePreAuthKeyRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{1} -} - -func (x *CreatePreAuthKeyRequest) GetUser() uint64 { - if x != nil { - return x.User - } - return 0 -} - -func (x *CreatePreAuthKeyRequest) GetReusable() bool { - if x != nil { - return x.Reusable - } - return false -} - -func (x *CreatePreAuthKeyRequest) GetEphemeral() bool { - if x != nil { - return x.Ephemeral - } - return false -} - -func (x *CreatePreAuthKeyRequest) GetExpiration() *timestamppb.Timestamp { - if x != nil { - return x.Expiration - } - return nil -} - -func (x *CreatePreAuthKeyRequest) GetAclTags() []string { - if x != nil { - return x.AclTags - } - return nil -} - -type CreatePreAuthKeyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - PreAuthKey *PreAuthKey `protobuf:"bytes,1,opt,name=pre_auth_key,json=preAuthKey,proto3" json:"pre_auth_key,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CreatePreAuthKeyResponse) Reset() { - *x = CreatePreAuthKeyResponse{} - mi := &file_headscale_v1_preauthkey_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CreatePreAuthKeyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CreatePreAuthKeyResponse) ProtoMessage() {} - -func (x *CreatePreAuthKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_preauthkey_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CreatePreAuthKeyResponse.ProtoReflect.Descriptor instead. -func (*CreatePreAuthKeyResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{2} -} - -func (x *CreatePreAuthKeyResponse) GetPreAuthKey() *PreAuthKey { - if x != nil { - return x.PreAuthKey - } - return nil -} - -type ExpirePreAuthKeyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExpirePreAuthKeyRequest) Reset() { - *x = ExpirePreAuthKeyRequest{} - mi := &file_headscale_v1_preauthkey_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExpirePreAuthKeyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExpirePreAuthKeyRequest) ProtoMessage() {} - -func (x *ExpirePreAuthKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_preauthkey_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExpirePreAuthKeyRequest.ProtoReflect.Descriptor instead. -func (*ExpirePreAuthKeyRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{3} -} - -func (x *ExpirePreAuthKeyRequest) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -type ExpirePreAuthKeyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExpirePreAuthKeyResponse) Reset() { - *x = ExpirePreAuthKeyResponse{} - mi := &file_headscale_v1_preauthkey_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExpirePreAuthKeyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExpirePreAuthKeyResponse) ProtoMessage() {} - -func (x *ExpirePreAuthKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_preauthkey_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExpirePreAuthKeyResponse.ProtoReflect.Descriptor instead. -func (*ExpirePreAuthKeyResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{4} -} - -type DeletePreAuthKeyRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeletePreAuthKeyRequest) Reset() { - *x = DeletePreAuthKeyRequest{} - mi := &file_headscale_v1_preauthkey_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeletePreAuthKeyRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeletePreAuthKeyRequest) ProtoMessage() {} - -func (x *DeletePreAuthKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_preauthkey_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeletePreAuthKeyRequest.ProtoReflect.Descriptor instead. -func (*DeletePreAuthKeyRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{5} -} - -func (x *DeletePreAuthKeyRequest) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -type DeletePreAuthKeyResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeletePreAuthKeyResponse) Reset() { - *x = DeletePreAuthKeyResponse{} - mi := &file_headscale_v1_preauthkey_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeletePreAuthKeyResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeletePreAuthKeyResponse) ProtoMessage() {} - -func (x *DeletePreAuthKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_preauthkey_proto_msgTypes[6] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeletePreAuthKeyResponse.ProtoReflect.Descriptor instead. -func (*DeletePreAuthKeyResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{6} -} - -type ListPreAuthKeysRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListPreAuthKeysRequest) Reset() { - *x = ListPreAuthKeysRequest{} - mi := &file_headscale_v1_preauthkey_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListPreAuthKeysRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListPreAuthKeysRequest) ProtoMessage() {} - -func (x *ListPreAuthKeysRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_preauthkey_proto_msgTypes[7] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListPreAuthKeysRequest.ProtoReflect.Descriptor instead. -func (*ListPreAuthKeysRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{7} -} - -type ListPreAuthKeysResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - PreAuthKeys []*PreAuthKey `protobuf:"bytes,1,rep,name=pre_auth_keys,json=preAuthKeys,proto3" json:"pre_auth_keys,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListPreAuthKeysResponse) Reset() { - *x = ListPreAuthKeysResponse{} - mi := &file_headscale_v1_preauthkey_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListPreAuthKeysResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListPreAuthKeysResponse) ProtoMessage() {} - -func (x *ListPreAuthKeysResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_preauthkey_proto_msgTypes[8] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListPreAuthKeysResponse.ProtoReflect.Descriptor instead. -func (*ListPreAuthKeysResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_preauthkey_proto_rawDescGZIP(), []int{8} -} - -func (x *ListPreAuthKeysResponse) GetPreAuthKeys() []*PreAuthKey { - if x != nil { - return x.PreAuthKeys - } - return nil -} - -var File_headscale_v1_preauthkey_proto protoreflect.FileDescriptor - -const file_headscale_v1_preauthkey_proto_rawDesc = "" + - "\n" + - "\x1dheadscale/v1/preauthkey.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x17headscale/v1/user.proto\"\xb6\x02\n" + - "\n" + - "PreAuthKey\x12&\n" + - "\x04user\x18\x01 \x01(\v2\x12.headscale.v1.UserR\x04user\x12\x0e\n" + - "\x02id\x18\x02 \x01(\x04R\x02id\x12\x10\n" + - "\x03key\x18\x03 \x01(\tR\x03key\x12\x1a\n" + - "\breusable\x18\x04 \x01(\bR\breusable\x12\x1c\n" + - "\tephemeral\x18\x05 \x01(\bR\tephemeral\x12\x12\n" + - "\x04used\x18\x06 \x01(\bR\x04used\x12:\n" + - "\n" + - "expiration\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "expiration\x129\n" + - "\n" + - "created_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x19\n" + - "\bacl_tags\x18\t \x03(\tR\aaclTags\"\xbe\x01\n" + - "\x17CreatePreAuthKeyRequest\x12\x12\n" + - "\x04user\x18\x01 \x01(\x04R\x04user\x12\x1a\n" + - "\breusable\x18\x02 \x01(\bR\breusable\x12\x1c\n" + - "\tephemeral\x18\x03 \x01(\bR\tephemeral\x12:\n" + - "\n" + - "expiration\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "expiration\x12\x19\n" + - "\bacl_tags\x18\x05 \x03(\tR\aaclTags\"V\n" + - "\x18CreatePreAuthKeyResponse\x12:\n" + - "\fpre_auth_key\x18\x01 \x01(\v2\x18.headscale.v1.PreAuthKeyR\n" + - "preAuthKey\")\n" + - "\x17ExpirePreAuthKeyRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\x04R\x02id\"\x1a\n" + - "\x18ExpirePreAuthKeyResponse\")\n" + - "\x17DeletePreAuthKeyRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\x04R\x02id\"\x1a\n" + - "\x18DeletePreAuthKeyResponse\"\x18\n" + - "\x16ListPreAuthKeysRequest\"W\n" + - "\x17ListPreAuthKeysResponse\x12<\n" + - "\rpre_auth_keys\x18\x01 \x03(\v2\x18.headscale.v1.PreAuthKeyR\vpreAuthKeysB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3" - -var ( - file_headscale_v1_preauthkey_proto_rawDescOnce sync.Once - file_headscale_v1_preauthkey_proto_rawDescData []byte -) - -func file_headscale_v1_preauthkey_proto_rawDescGZIP() []byte { - file_headscale_v1_preauthkey_proto_rawDescOnce.Do(func() { - file_headscale_v1_preauthkey_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_preauthkey_proto_rawDesc), len(file_headscale_v1_preauthkey_proto_rawDesc))) - }) - return file_headscale_v1_preauthkey_proto_rawDescData -} - -var file_headscale_v1_preauthkey_proto_msgTypes = make([]protoimpl.MessageInfo, 9) -var file_headscale_v1_preauthkey_proto_goTypes = []any{ - (*PreAuthKey)(nil), // 0: headscale.v1.PreAuthKey - (*CreatePreAuthKeyRequest)(nil), // 1: headscale.v1.CreatePreAuthKeyRequest - (*CreatePreAuthKeyResponse)(nil), // 2: headscale.v1.CreatePreAuthKeyResponse - (*ExpirePreAuthKeyRequest)(nil), // 3: headscale.v1.ExpirePreAuthKeyRequest - (*ExpirePreAuthKeyResponse)(nil), // 4: headscale.v1.ExpirePreAuthKeyResponse - (*DeletePreAuthKeyRequest)(nil), // 5: headscale.v1.DeletePreAuthKeyRequest - (*DeletePreAuthKeyResponse)(nil), // 6: headscale.v1.DeletePreAuthKeyResponse - (*ListPreAuthKeysRequest)(nil), // 7: headscale.v1.ListPreAuthKeysRequest - (*ListPreAuthKeysResponse)(nil), // 8: headscale.v1.ListPreAuthKeysResponse - (*User)(nil), // 9: headscale.v1.User - (*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp -} -var file_headscale_v1_preauthkey_proto_depIdxs = []int32{ - 9, // 0: headscale.v1.PreAuthKey.user:type_name -> headscale.v1.User - 10, // 1: headscale.v1.PreAuthKey.expiration:type_name -> google.protobuf.Timestamp - 10, // 2: headscale.v1.PreAuthKey.created_at:type_name -> google.protobuf.Timestamp - 10, // 3: headscale.v1.CreatePreAuthKeyRequest.expiration:type_name -> google.protobuf.Timestamp - 0, // 4: headscale.v1.CreatePreAuthKeyResponse.pre_auth_key:type_name -> headscale.v1.PreAuthKey - 0, // 5: headscale.v1.ListPreAuthKeysResponse.pre_auth_keys:type_name -> headscale.v1.PreAuthKey - 6, // [6:6] is the sub-list for method output_type - 6, // [6:6] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name -} - -func init() { file_headscale_v1_preauthkey_proto_init() } -func file_headscale_v1_preauthkey_proto_init() { - if File_headscale_v1_preauthkey_proto != nil { - return - } - file_headscale_v1_user_proto_init() - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_preauthkey_proto_rawDesc), len(file_headscale_v1_preauthkey_proto_rawDesc)), - NumEnums: 0, - NumMessages: 9, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_headscale_v1_preauthkey_proto_goTypes, - DependencyIndexes: file_headscale_v1_preauthkey_proto_depIdxs, - MessageInfos: file_headscale_v1_preauthkey_proto_msgTypes, - }.Build() - File_headscale_v1_preauthkey_proto = out.File - file_headscale_v1_preauthkey_proto_goTypes = nil - file_headscale_v1_preauthkey_proto_depIdxs = nil -} diff --git a/gen/go/headscale/v1/user.pb.go b/gen/go/headscale/v1/user.pb.go deleted file mode 100644 index 5f05d0849..000000000 --- a/gen/go/headscale/v1/user.pb.go +++ /dev/null @@ -1,615 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: headscale/v1/user.proto - -package v1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type User struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - CreatedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - DisplayName string `protobuf:"bytes,4,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` - Email string `protobuf:"bytes,5,opt,name=email,proto3" json:"email,omitempty"` - ProviderId string `protobuf:"bytes,6,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` - Provider string `protobuf:"bytes,7,opt,name=provider,proto3" json:"provider,omitempty"` - ProfilePicUrl string `protobuf:"bytes,8,opt,name=profile_pic_url,json=profilePicUrl,proto3" json:"profile_pic_url,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *User) Reset() { - *x = User{} - mi := &file_headscale_v1_user_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *User) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*User) ProtoMessage() {} - -func (x *User) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_user_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use User.ProtoReflect.Descriptor instead. -func (*User) Descriptor() ([]byte, []int) { - return file_headscale_v1_user_proto_rawDescGZIP(), []int{0} -} - -func (x *User) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -func (x *User) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *User) GetCreatedAt() *timestamppb.Timestamp { - if x != nil { - return x.CreatedAt - } - return nil -} - -func (x *User) GetDisplayName() string { - if x != nil { - return x.DisplayName - } - return "" -} - -func (x *User) GetEmail() string { - if x != nil { - return x.Email - } - return "" -} - -func (x *User) GetProviderId() string { - if x != nil { - return x.ProviderId - } - return "" -} - -func (x *User) GetProvider() string { - if x != nil { - return x.Provider - } - return "" -} - -func (x *User) GetProfilePicUrl() string { - if x != nil { - return x.ProfilePicUrl - } - return "" -} - -type CreateUserRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` - Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"` - PictureUrl string `protobuf:"bytes,4,opt,name=picture_url,json=pictureUrl,proto3" json:"picture_url,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CreateUserRequest) Reset() { - *x = CreateUserRequest{} - mi := &file_headscale_v1_user_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CreateUserRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CreateUserRequest) ProtoMessage() {} - -func (x *CreateUserRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_user_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CreateUserRequest.ProtoReflect.Descriptor instead. -func (*CreateUserRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_user_proto_rawDescGZIP(), []int{1} -} - -func (x *CreateUserRequest) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *CreateUserRequest) GetDisplayName() string { - if x != nil { - return x.DisplayName - } - return "" -} - -func (x *CreateUserRequest) GetEmail() string { - if x != nil { - return x.Email - } - return "" -} - -func (x *CreateUserRequest) GetPictureUrl() string { - if x != nil { - return x.PictureUrl - } - return "" -} - -type CreateUserResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CreateUserResponse) Reset() { - *x = CreateUserResponse{} - mi := &file_headscale_v1_user_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CreateUserResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CreateUserResponse) ProtoMessage() {} - -func (x *CreateUserResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_user_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CreateUserResponse.ProtoReflect.Descriptor instead. -func (*CreateUserResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_user_proto_rawDescGZIP(), []int{2} -} - -func (x *CreateUserResponse) GetUser() *User { - if x != nil { - return x.User - } - return nil -} - -type RenameUserRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - OldId uint64 `protobuf:"varint,1,opt,name=old_id,json=oldId,proto3" json:"old_id,omitempty"` - NewName string `protobuf:"bytes,2,opt,name=new_name,json=newName,proto3" json:"new_name,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RenameUserRequest) Reset() { - *x = RenameUserRequest{} - mi := &file_headscale_v1_user_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RenameUserRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RenameUserRequest) ProtoMessage() {} - -func (x *RenameUserRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_user_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RenameUserRequest.ProtoReflect.Descriptor instead. -func (*RenameUserRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_user_proto_rawDescGZIP(), []int{3} -} - -func (x *RenameUserRequest) GetOldId() uint64 { - if x != nil { - return x.OldId - } - return 0 -} - -func (x *RenameUserRequest) GetNewName() string { - if x != nil { - return x.NewName - } - return "" -} - -type RenameUserResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RenameUserResponse) Reset() { - *x = RenameUserResponse{} - mi := &file_headscale_v1_user_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RenameUserResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RenameUserResponse) ProtoMessage() {} - -func (x *RenameUserResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_user_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RenameUserResponse.ProtoReflect.Descriptor instead. -func (*RenameUserResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_user_proto_rawDescGZIP(), []int{4} -} - -func (x *RenameUserResponse) GetUser() *User { - if x != nil { - return x.User - } - return nil -} - -type DeleteUserRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteUserRequest) Reset() { - *x = DeleteUserRequest{} - mi := &file_headscale_v1_user_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteUserRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteUserRequest) ProtoMessage() {} - -func (x *DeleteUserRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_user_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteUserRequest.ProtoReflect.Descriptor instead. -func (*DeleteUserRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_user_proto_rawDescGZIP(), []int{5} -} - -func (x *DeleteUserRequest) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -type DeleteUserResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DeleteUserResponse) Reset() { - *x = DeleteUserResponse{} - mi := &file_headscale_v1_user_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DeleteUserResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DeleteUserResponse) ProtoMessage() {} - -func (x *DeleteUserResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_user_proto_msgTypes[6] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DeleteUserResponse.ProtoReflect.Descriptor instead. -func (*DeleteUserResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_user_proto_rawDescGZIP(), []int{6} -} - -type ListUsersRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListUsersRequest) Reset() { - *x = ListUsersRequest{} - mi := &file_headscale_v1_user_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListUsersRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListUsersRequest) ProtoMessage() {} - -func (x *ListUsersRequest) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_user_proto_msgTypes[7] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListUsersRequest.ProtoReflect.Descriptor instead. -func (*ListUsersRequest) Descriptor() ([]byte, []int) { - return file_headscale_v1_user_proto_rawDescGZIP(), []int{7} -} - -func (x *ListUsersRequest) GetId() uint64 { - if x != nil { - return x.Id - } - return 0 -} - -func (x *ListUsersRequest) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *ListUsersRequest) GetEmail() string { - if x != nil { - return x.Email - } - return "" -} - -type ListUsersResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Users []*User `protobuf:"bytes,1,rep,name=users,proto3" json:"users,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListUsersResponse) Reset() { - *x = ListUsersResponse{} - mi := &file_headscale_v1_user_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ListUsersResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ListUsersResponse) ProtoMessage() {} - -func (x *ListUsersResponse) ProtoReflect() protoreflect.Message { - mi := &file_headscale_v1_user_proto_msgTypes[8] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ListUsersResponse.ProtoReflect.Descriptor instead. -func (*ListUsersResponse) Descriptor() ([]byte, []int) { - return file_headscale_v1_user_proto_rawDescGZIP(), []int{8} -} - -func (x *ListUsersResponse) GetUsers() []*User { - if x != nil { - return x.Users - } - return nil -} - -var File_headscale_v1_user_proto protoreflect.FileDescriptor - -const file_headscale_v1_user_proto_rawDesc = "" + - "\n" + - "\x17headscale/v1/user.proto\x12\fheadscale.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x83\x02\n" + - "\x04User\x12\x0e\n" + - "\x02id\x18\x01 \x01(\x04R\x02id\x12\x12\n" + - "\x04name\x18\x02 \x01(\tR\x04name\x129\n" + - "\n" + - "created_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12!\n" + - "\fdisplay_name\x18\x04 \x01(\tR\vdisplayName\x12\x14\n" + - "\x05email\x18\x05 \x01(\tR\x05email\x12\x1f\n" + - "\vprovider_id\x18\x06 \x01(\tR\n" + - "providerId\x12\x1a\n" + - "\bprovider\x18\a \x01(\tR\bprovider\x12&\n" + - "\x0fprofile_pic_url\x18\b \x01(\tR\rprofilePicUrl\"\x81\x01\n" + - "\x11CreateUserRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12!\n" + - "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12\x14\n" + - "\x05email\x18\x03 \x01(\tR\x05email\x12\x1f\n" + - "\vpicture_url\x18\x04 \x01(\tR\n" + - "pictureUrl\"<\n" + - "\x12CreateUserResponse\x12&\n" + - "\x04user\x18\x01 \x01(\v2\x12.headscale.v1.UserR\x04user\"E\n" + - "\x11RenameUserRequest\x12\x15\n" + - "\x06old_id\x18\x01 \x01(\x04R\x05oldId\x12\x19\n" + - "\bnew_name\x18\x02 \x01(\tR\anewName\"<\n" + - "\x12RenameUserResponse\x12&\n" + - "\x04user\x18\x01 \x01(\v2\x12.headscale.v1.UserR\x04user\"#\n" + - "\x11DeleteUserRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\x04R\x02id\"\x14\n" + - "\x12DeleteUserResponse\"L\n" + - "\x10ListUsersRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\x04R\x02id\x12\x12\n" + - "\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" + - "\x05email\x18\x03 \x01(\tR\x05email\"=\n" + - "\x11ListUsersResponse\x12(\n" + - "\x05users\x18\x01 \x03(\v2\x12.headscale.v1.UserR\x05usersB)Z'github.com/juanfont/headscale/gen/go/v1b\x06proto3" - -var ( - file_headscale_v1_user_proto_rawDescOnce sync.Once - file_headscale_v1_user_proto_rawDescData []byte -) - -func file_headscale_v1_user_proto_rawDescGZIP() []byte { - file_headscale_v1_user_proto_rawDescOnce.Do(func() { - file_headscale_v1_user_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_headscale_v1_user_proto_rawDesc), len(file_headscale_v1_user_proto_rawDesc))) - }) - return file_headscale_v1_user_proto_rawDescData -} - -var file_headscale_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 9) -var file_headscale_v1_user_proto_goTypes = []any{ - (*User)(nil), // 0: headscale.v1.User - (*CreateUserRequest)(nil), // 1: headscale.v1.CreateUserRequest - (*CreateUserResponse)(nil), // 2: headscale.v1.CreateUserResponse - (*RenameUserRequest)(nil), // 3: headscale.v1.RenameUserRequest - (*RenameUserResponse)(nil), // 4: headscale.v1.RenameUserResponse - (*DeleteUserRequest)(nil), // 5: headscale.v1.DeleteUserRequest - (*DeleteUserResponse)(nil), // 6: headscale.v1.DeleteUserResponse - (*ListUsersRequest)(nil), // 7: headscale.v1.ListUsersRequest - (*ListUsersResponse)(nil), // 8: headscale.v1.ListUsersResponse - (*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp -} -var file_headscale_v1_user_proto_depIdxs = []int32{ - 9, // 0: headscale.v1.User.created_at:type_name -> google.protobuf.Timestamp - 0, // 1: headscale.v1.CreateUserResponse.user:type_name -> headscale.v1.User - 0, // 2: headscale.v1.RenameUserResponse.user:type_name -> headscale.v1.User - 0, // 3: headscale.v1.ListUsersResponse.users:type_name -> headscale.v1.User - 4, // [4:4] is the sub-list for method output_type - 4, // [4:4] is the sub-list for method input_type - 4, // [4:4] is the sub-list for extension type_name - 4, // [4:4] is the sub-list for extension extendee - 0, // [0:4] is the sub-list for field type_name -} - -func init() { file_headscale_v1_user_proto_init() } -func file_headscale_v1_user_proto_init() { - if File_headscale_v1_user_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_headscale_v1_user_proto_rawDesc), len(file_headscale_v1_user_proto_rawDesc)), - NumEnums: 0, - NumMessages: 9, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_headscale_v1_user_proto_goTypes, - DependencyIndexes: file_headscale_v1_user_proto_depIdxs, - MessageInfos: file_headscale_v1_user_proto_msgTypes, - }.Build() - File_headscale_v1_user_proto = out.File - file_headscale_v1_user_proto_goTypes = nil - file_headscale_v1_user_proto_depIdxs = nil -} diff --git a/gen/openapiv2/headscale/v1/apikey.swagger.json b/gen/openapiv2/headscale/v1/apikey.swagger.json deleted file mode 100644 index 8c8596a95..000000000 --- a/gen/openapiv2/headscale/v1/apikey.swagger.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "title": "headscale/v1/apikey.proto", - "version": "version not set" - }, - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "paths": {}, - "definitions": { - "protobufAny": { - "type": "object", - "properties": { - "@type": { - "type": "string" - } - }, - "additionalProperties": {} - }, - "rpcStatus": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "details": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/protobufAny" - } - } - } - } - } -} diff --git a/gen/openapiv2/headscale/v1/auth.swagger.json b/gen/openapiv2/headscale/v1/auth.swagger.json deleted file mode 100644 index 2e99e1a75..000000000 --- a/gen/openapiv2/headscale/v1/auth.swagger.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "title": "headscale/v1/auth.proto", - "version": "version not set" - }, - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "paths": {}, - "definitions": { - "protobufAny": { - "type": "object", - "properties": { - "@type": { - "type": "string" - } - }, - "additionalProperties": {} - }, - "rpcStatus": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "details": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/protobufAny" - } - } - } - } - } -} diff --git a/gen/openapiv2/headscale/v1/device.swagger.json b/gen/openapiv2/headscale/v1/device.swagger.json deleted file mode 100644 index 99d20debf..000000000 --- a/gen/openapiv2/headscale/v1/device.swagger.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "title": "headscale/v1/device.proto", - "version": "version not set" - }, - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "paths": {}, - "definitions": { - "protobufAny": { - "type": "object", - "properties": { - "@type": { - "type": "string" - } - }, - "additionalProperties": {} - }, - "rpcStatus": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "details": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/protobufAny" - } - } - } - } - } -} diff --git a/gen/openapiv2/headscale/v1/headscale.swagger.json b/gen/openapiv2/headscale/v1/headscale.swagger.json deleted file mode 100644 index 545cf0b5b..000000000 --- a/gen/openapiv2/headscale/v1/headscale.swagger.json +++ /dev/null @@ -1,1535 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "title": "headscale/v1/headscale.proto", - "version": "version not set" - }, - "tags": [ - { - "name": "HeadscaleService" - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "paths": { - "/api/v1/apikey": { - "get": { - "operationId": "HeadscaleService_ListApiKeys", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1ListApiKeysResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "tags": [ - "HeadscaleService" - ] - }, - "post": { - "summary": "--- ApiKeys start ---", - "operationId": "HeadscaleService_CreateApiKey", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1CreateApiKeyResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1CreateApiKeyRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/apikey/expire": { - "post": { - "operationId": "HeadscaleService_ExpireApiKey", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1ExpireApiKeyResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1ExpireApiKeyRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/apikey/{prefix}": { - "delete": { - "operationId": "HeadscaleService_DeleteApiKey", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1DeleteApiKeyResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "prefix", - "in": "path", - "required": true, - "type": "string" - }, - { - "name": "id", - "in": "query", - "required": false, - "type": "string", - "format": "uint64" - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/auth/approve": { - "post": { - "operationId": "HeadscaleService_AuthApprove", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1AuthApproveResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1AuthApproveRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/auth/register": { - "post": { - "summary": "--- Auth start ---", - "operationId": "HeadscaleService_AuthRegister", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1AuthRegisterResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1AuthRegisterRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/auth/reject": { - "post": { - "operationId": "HeadscaleService_AuthReject", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1AuthRejectResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1AuthRejectRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/debug/node": { - "post": { - "summary": "--- Node start ---", - "operationId": "HeadscaleService_DebugCreateNode", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1DebugCreateNodeResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1DebugCreateNodeRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/health": { - "get": { - "summary": "--- Health start ---", - "operationId": "HeadscaleService_Health", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1HealthResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/node": { - "get": { - "operationId": "HeadscaleService_ListNodes", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1ListNodesResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "user", - "in": "query", - "required": false, - "type": "string" - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/node/backfillips": { - "post": { - "operationId": "HeadscaleService_BackfillNodeIPs", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1BackfillNodeIPsResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "confirmed", - "in": "query", - "required": false, - "type": "boolean" - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/node/register": { - "post": { - "operationId": "HeadscaleService_RegisterNode", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1RegisterNodeResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "user", - "in": "query", - "required": false, - "type": "string" - }, - { - "name": "key", - "in": "query", - "required": false, - "type": "string" - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/node/{nodeId}": { - "get": { - "operationId": "HeadscaleService_GetNode", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1GetNodeResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "nodeId", - "in": "path", - "required": true, - "type": "string", - "format": "uint64" - } - ], - "tags": [ - "HeadscaleService" - ] - }, - "delete": { - "operationId": "HeadscaleService_DeleteNode", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1DeleteNodeResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "nodeId", - "in": "path", - "required": true, - "type": "string", - "format": "uint64" - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/node/{nodeId}/approve_routes": { - "post": { - "operationId": "HeadscaleService_SetApprovedRoutes", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1SetApprovedRoutesResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "nodeId", - "in": "path", - "required": true, - "type": "string", - "format": "uint64" - }, - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/HeadscaleServiceSetApprovedRoutesBody" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/node/{nodeId}/expire": { - "post": { - "operationId": "HeadscaleService_ExpireNode", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1ExpireNodeResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "nodeId", - "in": "path", - "required": true, - "type": "string", - "format": "uint64" - }, - { - "name": "expiry", - "in": "query", - "required": false, - "type": "string", - "format": "date-time" - }, - { - "name": "disableExpiry", - "description": "When true, sets expiry to null (node will never expire).", - "in": "query", - "required": false, - "type": "boolean" - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/node/{nodeId}/rename/{newName}": { - "post": { - "operationId": "HeadscaleService_RenameNode", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1RenameNodeResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "nodeId", - "in": "path", - "required": true, - "type": "string", - "format": "uint64" - }, - { - "name": "newName", - "in": "path", - "required": true, - "type": "string" - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/node/{nodeId}/tags": { - "post": { - "operationId": "HeadscaleService_SetTags", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1SetTagsResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "nodeId", - "in": "path", - "required": true, - "type": "string", - "format": "uint64" - }, - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/HeadscaleServiceSetTagsBody" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/policy": { - "get": { - "summary": "--- Policy start ---", - "operationId": "HeadscaleService_GetPolicy", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1GetPolicyResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "tags": [ - "HeadscaleService" - ] - }, - "put": { - "operationId": "HeadscaleService_SetPolicy", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1SetPolicyResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1SetPolicyRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/policy/check": { - "post": { - "operationId": "HeadscaleService_CheckPolicy", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1CheckPolicyResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1CheckPolicyRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/preauthkey": { - "get": { - "operationId": "HeadscaleService_ListPreAuthKeys", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1ListPreAuthKeysResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "tags": [ - "HeadscaleService" - ] - }, - "delete": { - "operationId": "HeadscaleService_DeletePreAuthKey", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1DeletePreAuthKeyResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "id", - "in": "query", - "required": false, - "type": "string", - "format": "uint64" - } - ], - "tags": [ - "HeadscaleService" - ] - }, - "post": { - "summary": "--- PreAuthKeys start ---", - "operationId": "HeadscaleService_CreatePreAuthKey", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1CreatePreAuthKeyResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1CreatePreAuthKeyRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/preauthkey/expire": { - "post": { - "operationId": "HeadscaleService_ExpirePreAuthKey", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1ExpirePreAuthKeyResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1ExpirePreAuthKeyRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/user": { - "get": { - "operationId": "HeadscaleService_ListUsers", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1ListUsersResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "id", - "in": "query", - "required": false, - "type": "string", - "format": "uint64" - }, - { - "name": "name", - "in": "query", - "required": false, - "type": "string" - }, - { - "name": "email", - "in": "query", - "required": false, - "type": "string" - } - ], - "tags": [ - "HeadscaleService" - ] - }, - "post": { - "summary": "--- User start ---", - "operationId": "HeadscaleService_CreateUser", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1CreateUserResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "body", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/v1CreateUserRequest" - } - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/user/{id}": { - "delete": { - "operationId": "HeadscaleService_DeleteUser", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1DeleteUserResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "type": "string", - "format": "uint64" - } - ], - "tags": [ - "HeadscaleService" - ] - } - }, - "/api/v1/user/{oldId}/rename/{newName}": { - "post": { - "operationId": "HeadscaleService_RenameUser", - "responses": { - "200": { - "description": "A successful response.", - "schema": { - "$ref": "#/definitions/v1RenameUserResponse" - } - }, - "default": { - "description": "An unexpected error response.", - "schema": { - "$ref": "#/definitions/rpcStatus" - } - } - }, - "parameters": [ - { - "name": "oldId", - "in": "path", - "required": true, - "type": "string", - "format": "uint64" - }, - { - "name": "newName", - "in": "path", - "required": true, - "type": "string" - } - ], - "tags": [ - "HeadscaleService" - ] - } - } - }, - "definitions": { - "HeadscaleServiceSetApprovedRoutesBody": { - "type": "object", - "properties": { - "routes": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "HeadscaleServiceSetTagsBody": { - "type": "object", - "properties": { - "tags": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "protobufAny": { - "type": "object", - "properties": { - "@type": { - "type": "string" - } - }, - "additionalProperties": {} - }, - "rpcStatus": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "details": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/protobufAny" - } - } - } - }, - "v1ApiKey": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uint64" - }, - "prefix": { - "type": "string" - }, - "expiration": { - "type": "string", - "format": "date-time" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "lastSeen": { - "type": "string", - "format": "date-time" - } - } - }, - "v1AuthApproveRequest": { - "type": "object", - "properties": { - "authId": { - "type": "string" - } - } - }, - "v1AuthApproveResponse": { - "type": "object" - }, - "v1AuthRegisterRequest": { - "type": "object", - "properties": { - "user": { - "type": "string" - }, - "authId": { - "type": "string" - } - } - }, - "v1AuthRegisterResponse": { - "type": "object", - "properties": { - "node": { - "$ref": "#/definitions/v1Node" - } - } - }, - "v1AuthRejectRequest": { - "type": "object", - "properties": { - "authId": { - "type": "string" - } - } - }, - "v1AuthRejectResponse": { - "type": "object" - }, - "v1BackfillNodeIPsResponse": { - "type": "object", - "properties": { - "changes": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "v1CheckPolicyRequest": { - "type": "object", - "properties": { - "policy": { - "type": "string" - } - } - }, - "v1CheckPolicyResponse": { - "type": "object" - }, - "v1CreateApiKeyRequest": { - "type": "object", - "properties": { - "expiration": { - "type": "string", - "format": "date-time" - } - } - }, - "v1CreateApiKeyResponse": { - "type": "object", - "properties": { - "apiKey": { - "type": "string" - } - } - }, - "v1CreatePreAuthKeyRequest": { - "type": "object", - "properties": { - "user": { - "type": "string", - "format": "uint64" - }, - "reusable": { - "type": "boolean" - }, - "ephemeral": { - "type": "boolean" - }, - "expiration": { - "type": "string", - "format": "date-time" - }, - "aclTags": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "v1CreatePreAuthKeyResponse": { - "type": "object", - "properties": { - "preAuthKey": { - "$ref": "#/definitions/v1PreAuthKey" - } - } - }, - "v1CreateUserRequest": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "displayName": { - "type": "string" - }, - "email": { - "type": "string" - }, - "pictureUrl": { - "type": "string" - } - } - }, - "v1CreateUserResponse": { - "type": "object", - "properties": { - "user": { - "$ref": "#/definitions/v1User" - } - } - }, - "v1DebugCreateNodeRequest": { - "type": "object", - "properties": { - "user": { - "type": "string" - }, - "key": { - "type": "string" - }, - "name": { - "type": "string" - }, - "routes": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "v1DebugCreateNodeResponse": { - "type": "object", - "properties": { - "node": { - "$ref": "#/definitions/v1Node" - } - } - }, - "v1DeleteApiKeyResponse": { - "type": "object" - }, - "v1DeleteNodeResponse": { - "type": "object" - }, - "v1DeletePreAuthKeyResponse": { - "type": "object" - }, - "v1DeleteUserResponse": { - "type": "object" - }, - "v1ExpireApiKeyRequest": { - "type": "object", - "properties": { - "prefix": { - "type": "string" - }, - "id": { - "type": "string", - "format": "uint64" - } - } - }, - "v1ExpireApiKeyResponse": { - "type": "object" - }, - "v1ExpireNodeResponse": { - "type": "object", - "properties": { - "node": { - "$ref": "#/definitions/v1Node" - } - } - }, - "v1ExpirePreAuthKeyRequest": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uint64" - } - } - }, - "v1ExpirePreAuthKeyResponse": { - "type": "object" - }, - "v1GetNodeResponse": { - "type": "object", - "properties": { - "node": { - "$ref": "#/definitions/v1Node" - } - } - }, - "v1GetPolicyResponse": { - "type": "object", - "properties": { - "policy": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "v1HealthResponse": { - "type": "object", - "properties": { - "databaseConnectivity": { - "type": "boolean" - } - } - }, - "v1ListApiKeysResponse": { - "type": "object", - "properties": { - "apiKeys": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1ApiKey" - } - } - } - }, - "v1ListNodesResponse": { - "type": "object", - "properties": { - "nodes": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1Node" - } - } - } - }, - "v1ListPreAuthKeysResponse": { - "type": "object", - "properties": { - "preAuthKeys": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1PreAuthKey" - } - } - } - }, - "v1ListUsersResponse": { - "type": "object", - "properties": { - "users": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/v1User" - } - } - } - }, - "v1Node": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uint64" - }, - "machineKey": { - "type": "string" - }, - "nodeKey": { - "type": "string" - }, - "discoKey": { - "type": "string" - }, - "ipAddresses": { - "type": "array", - "items": { - "type": "string" - } - }, - "name": { - "type": "string" - }, - "user": { - "$ref": "#/definitions/v1User" - }, - "lastSeen": { - "type": "string", - "format": "date-time" - }, - "expiry": { - "type": "string", - "format": "date-time" - }, - "preAuthKey": { - "$ref": "#/definitions/v1PreAuthKey" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "registerMethod": { - "$ref": "#/definitions/v1RegisterMethod" - }, - "givenName": { - "type": "string", - "title": "Deprecated\nrepeated string forced_tags = 18;\nrepeated string invalid_tags = 19;\nrepeated string valid_tags = 20;" - }, - "online": { - "type": "boolean" - }, - "approvedRoutes": { - "type": "array", - "items": { - "type": "string" - } - }, - "availableRoutes": { - "type": "array", - "items": { - "type": "string" - } - }, - "subnetRoutes": { - "type": "array", - "items": { - "type": "string" - } - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "v1PreAuthKey": { - "type": "object", - "properties": { - "user": { - "$ref": "#/definitions/v1User" - }, - "id": { - "type": "string", - "format": "uint64" - }, - "key": { - "type": "string" - }, - "reusable": { - "type": "boolean" - }, - "ephemeral": { - "type": "boolean" - }, - "used": { - "type": "boolean" - }, - "expiration": { - "type": "string", - "format": "date-time" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "aclTags": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "v1RegisterMethod": { - "type": "string", - "enum": [ - "REGISTER_METHOD_UNSPECIFIED", - "REGISTER_METHOD_AUTH_KEY", - "REGISTER_METHOD_CLI", - "REGISTER_METHOD_OIDC" - ], - "default": "REGISTER_METHOD_UNSPECIFIED" - }, - "v1RegisterNodeResponse": { - "type": "object", - "properties": { - "node": { - "$ref": "#/definitions/v1Node" - } - } - }, - "v1RenameNodeResponse": { - "type": "object", - "properties": { - "node": { - "$ref": "#/definitions/v1Node" - } - } - }, - "v1RenameUserResponse": { - "type": "object", - "properties": { - "user": { - "$ref": "#/definitions/v1User" - } - } - }, - "v1SetApprovedRoutesResponse": { - "type": "object", - "properties": { - "node": { - "$ref": "#/definitions/v1Node" - } - } - }, - "v1SetPolicyRequest": { - "type": "object", - "properties": { - "policy": { - "type": "string" - } - } - }, - "v1SetPolicyResponse": { - "type": "object", - "properties": { - "policy": { - "type": "string" - }, - "updatedAt": { - "type": "string", - "format": "date-time" - } - } - }, - "v1SetTagsResponse": { - "type": "object", - "properties": { - "node": { - "$ref": "#/definitions/v1Node" - } - } - }, - "v1User": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uint64" - }, - "name": { - "type": "string" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "displayName": { - "type": "string" - }, - "email": { - "type": "string" - }, - "providerId": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "profilePicUrl": { - "type": "string" - } - } - } - } -} diff --git a/gen/openapiv2/headscale/v1/node.swagger.json b/gen/openapiv2/headscale/v1/node.swagger.json deleted file mode 100644 index 163213470..000000000 --- a/gen/openapiv2/headscale/v1/node.swagger.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "title": "headscale/v1/node.proto", - "version": "version not set" - }, - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "paths": {}, - "definitions": { - "protobufAny": { - "type": "object", - "properties": { - "@type": { - "type": "string" - } - }, - "additionalProperties": {} - }, - "rpcStatus": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "details": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/protobufAny" - } - } - } - } - } -} diff --git a/gen/openapiv2/headscale/v1/policy.swagger.json b/gen/openapiv2/headscale/v1/policy.swagger.json deleted file mode 100644 index 63057ed0e..000000000 --- a/gen/openapiv2/headscale/v1/policy.swagger.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "title": "headscale/v1/policy.proto", - "version": "version not set" - }, - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "paths": {}, - "definitions": { - "protobufAny": { - "type": "object", - "properties": { - "@type": { - "type": "string" - } - }, - "additionalProperties": {} - }, - "rpcStatus": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "details": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/protobufAny" - } - } - } - } - } -} diff --git a/gen/openapiv2/headscale/v1/preauthkey.swagger.json b/gen/openapiv2/headscale/v1/preauthkey.swagger.json deleted file mode 100644 index 17a2be1a7..000000000 --- a/gen/openapiv2/headscale/v1/preauthkey.swagger.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "title": "headscale/v1/preauthkey.proto", - "version": "version not set" - }, - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "paths": {}, - "definitions": { - "protobufAny": { - "type": "object", - "properties": { - "@type": { - "type": "string" - } - }, - "additionalProperties": {} - }, - "rpcStatus": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "details": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/protobufAny" - } - } - } - } - } -} diff --git a/gen/openapiv2/headscale/v1/user.swagger.json b/gen/openapiv2/headscale/v1/user.swagger.json deleted file mode 100644 index 008ca3e85..000000000 --- a/gen/openapiv2/headscale/v1/user.swagger.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "title": "headscale/v1/user.proto", - "version": "version not set" - }, - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "paths": {}, - "definitions": { - "protobufAny": { - "type": "object", - "properties": { - "@type": { - "type": "string" - } - }, - "additionalProperties": {} - }, - "rpcStatus": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "details": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/protobufAny" - } - } - } - } - } -} diff --git a/hscontrol/auth_tags_test.go b/hscontrol/auth_tags_test.go index 3c55c6671..3cea0cb0c 100644 --- a/hscontrol/auth_tags_test.go +++ b/hscontrol/auth_tags_test.go @@ -179,9 +179,8 @@ func TestReAuthDoesNotReapplyTags(t *testing.T) { assert.Equal(t, 1, allNodes.Len(), "Should have exactly one node") } -// NOTE: TestSetTagsOnUserOwnedNode functionality is covered by gRPC tests in grpcv1_test.go -// which properly handle ACL policy setup. The test verifies that [headscaleV1APIServer.SetTags] can convert -// user-owned nodes to tagged nodes while preserving UserID. +// NOTE: Converting user-owned nodes to tagged nodes while preserving UserID +// is covered by the SetTags API tests, which properly handle ACL policy setup. // TestCannotRemoveAllTags tests that attempting to remove all tags from a // tagged node fails with ErrCannotRemoveAllTags. Once a node is tagged, diff --git a/hscontrol/db/preauth_keys_test.go b/hscontrol/db/preauth_keys_test.go index c152959c5..5835758f3 100644 --- a/hscontrol/db/preauth_keys_test.go +++ b/hscontrol/db/preauth_keys_test.go @@ -96,7 +96,7 @@ func TestPreAuthKeyACLTags(t *testing.T) { require.NoError(t, err) require.Len(t, listedPaks, 1) - gotTags := listedPaks[0].Proto().GetAclTags() + gotTags := slices.Clone(listedPaks[0].Tags) slices.Sort(gotTags) assert.Equal(t, expectedTags, gotTags) }, diff --git a/hscontrol/grpcv1.go b/hscontrol/grpcv1.go deleted file mode 100644 index fbb4f5912..000000000 --- a/hscontrol/grpcv1.go +++ /dev/null @@ -1,944 +0,0 @@ -//go:generate buf generate --template ../buf.gen.yaml -o .. ../proto - -// nolint -package hscontrol - -import ( - "cmp" - "context" - "errors" - "fmt" - "io" - "net/netip" - "os" - "slices" - "strings" - "time" - - "github.com/rs/zerolog/log" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/timestamppb" - "gorm.io/gorm" - "tailscale.com/net/tsaddr" - "tailscale.com/tailcfg" - "tailscale.com/types/key" - "tailscale.com/types/views" - - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" - policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" - "github.com/juanfont/headscale/hscontrol/state" - "github.com/juanfont/headscale/hscontrol/types" - "github.com/juanfont/headscale/hscontrol/util" - "github.com/juanfont/headscale/hscontrol/util/zlog/zf" -) - -type headscaleV1APIServer struct { // v1.HeadscaleServiceServer - v1.UnimplementedHeadscaleServiceServer - h *Headscale -} - -func newHeadscaleV1APIServer(h *Headscale) v1.HeadscaleServiceServer { - return headscaleV1APIServer{ - h: h, - } -} - -// sortByID sorts a slice of proto messages by ascending Id. -func sortByID[T interface{ GetId() uint64 }](s []T) { - slices.SortFunc(s, func(a, b T) int { - return cmp.Compare(a.GetId(), b.GetId()) - }) -} - -func (api headscaleV1APIServer) CreateUser( - ctx context.Context, - request *v1.CreateUserRequest, -) (*v1.CreateUserResponse, error) { - newUser := types.User{ - Name: request.GetName(), - DisplayName: request.GetDisplayName(), - Email: request.GetEmail(), - ProfilePicURL: request.GetPictureUrl(), - } - user, policyChanged, err := api.h.state.CreateUser(newUser) - if err != nil { - return nil, status.Errorf(codes.Internal, "creating user: %s", err) - } - - // [state.State.CreateUser] returns a policy change response if the user creation affected policy. - // This triggers a full policy re-evaluation for all connected nodes. - api.h.Change(policyChanged) - - return &v1.CreateUserResponse{User: user.Proto()}, nil -} - -func (api headscaleV1APIServer) RenameUser( - ctx context.Context, - request *v1.RenameUserRequest, -) (*v1.RenameUserResponse, error) { - oldUser, err := api.h.state.GetUserByID(types.UserID(request.GetOldId())) - if err != nil { - return nil, err - } - - _, c, err := api.h.state.RenameUser(types.UserID(oldUser.ID), request.GetNewName()) - if err != nil { - return nil, err - } - - // Send policy update notifications if needed - api.h.Change(c) - - newUser, err := api.h.state.GetUserByName(request.GetNewName()) - if err != nil { - return nil, err - } - - return &v1.RenameUserResponse{User: newUser.Proto()}, nil -} - -func (api headscaleV1APIServer) DeleteUser( - ctx context.Context, - request *v1.DeleteUserRequest, -) (*v1.DeleteUserResponse, error) { - user, err := api.h.state.GetUserByID(types.UserID(request.GetId())) - if err != nil { - return nil, err - } - - policyChanged, err := api.h.state.DeleteUser(types.UserID(user.ID)) - if err != nil { - return nil, err - } - - // Use the change returned from [state.State.DeleteUser] which includes proper policy updates - api.h.Change(policyChanged) - - return &v1.DeleteUserResponse{}, nil -} - -func (api headscaleV1APIServer) ListUsers( - ctx context.Context, - request *v1.ListUsersRequest, -) (*v1.ListUsersResponse, error) { - var err error - var users []types.User - - switch { - case request.GetName() != "": - users, err = api.h.state.ListUsersWithFilter(&types.User{Name: request.GetName()}) - case request.GetEmail() != "": - users, err = api.h.state.ListUsersWithFilter(&types.User{Email: request.GetEmail()}) - case request.GetId() != 0: - users, err = api.h.state.ListUsersWithFilter(&types.User{Model: gorm.Model{ID: uint(request.GetId())}}) - default: - users, err = api.h.state.ListAllUsers() - } - if err != nil { - return nil, err - } - - response := make([]*v1.User, len(users)) - for index, user := range users { - response[index] = user.Proto() - } - - sortByID(response) - - return &v1.ListUsersResponse{Users: response}, nil -} - -func (api headscaleV1APIServer) CreatePreAuthKey( - ctx context.Context, - request *v1.CreatePreAuthKeyRequest, -) (*v1.CreatePreAuthKeyResponse, error) { - var expiration time.Time - if request.GetExpiration() != nil { - expiration = request.GetExpiration().AsTime() - } - - for _, tag := range request.AclTags { - err := validateTag(tag) - if err != nil { - return &v1.CreatePreAuthKeyResponse{ - PreAuthKey: nil, - }, status.Error(codes.InvalidArgument, err.Error()) - } - } - - var userID *types.UserID - if request.GetUser() != 0 { - user, err := api.h.state.GetUserByID(types.UserID(request.GetUser())) - if err != nil { - return nil, err - } - userID = user.TypedID() - } - - preAuthKey, err := api.h.state.CreatePreAuthKey( - userID, - request.GetReusable(), - request.GetEphemeral(), - &expiration, - request.AclTags, - ) - if err != nil { - return nil, err - } - - return &v1.CreatePreAuthKeyResponse{PreAuthKey: preAuthKey.Proto()}, nil -} - -func (api headscaleV1APIServer) ExpirePreAuthKey( - ctx context.Context, - request *v1.ExpirePreAuthKeyRequest, -) (*v1.ExpirePreAuthKeyResponse, error) { - err := api.h.state.ExpirePreAuthKey(request.GetId()) - if err != nil { - return nil, err - } - - return &v1.ExpirePreAuthKeyResponse{}, nil -} - -func (api headscaleV1APIServer) DeletePreAuthKey( - ctx context.Context, - request *v1.DeletePreAuthKeyRequest, -) (*v1.DeletePreAuthKeyResponse, error) { - err := api.h.state.DeletePreAuthKey(request.GetId()) - if err != nil { - return nil, err - } - - return &v1.DeletePreAuthKeyResponse{}, nil -} - -func (api headscaleV1APIServer) ListPreAuthKeys( - ctx context.Context, - request *v1.ListPreAuthKeysRequest, -) (*v1.ListPreAuthKeysResponse, error) { - preAuthKeys, err := api.h.state.ListPreAuthKeys() - if err != nil { - return nil, err - } - - response := make([]*v1.PreAuthKey, len(preAuthKeys)) - for index, key := range preAuthKeys { - response[index] = key.Proto() - } - - sortByID(response) - - return &v1.ListPreAuthKeysResponse{PreAuthKeys: response}, nil -} - -func (api headscaleV1APIServer) RegisterNode( - ctx context.Context, - request *v1.RegisterNodeRequest, -) (*v1.RegisterNodeResponse, error) { - // Generate ephemeral registration key for tracking this registration flow in logs - registrationKey, err := util.GenerateRegistrationKey() - if err != nil { - log.Warn().Err(err).Msg("failed to generate registration key") - registrationKey = "" // Continue without key if generation fails - } - - log.Trace(). - Caller(). - Str(zf.UserName, request.GetUser()). - Str(zf.RegistrationID, request.GetKey()). - Str(zf.RegistrationKey, registrationKey). - Msg("registering node") - - registrationId, err := types.AuthIDFromString(request.GetKey()) - if err != nil { - return nil, err - } - - user, err := api.h.state.GetUserByName(request.GetUser()) - if err != nil { - return nil, fmt.Errorf("looking up user: %w", err) - } - - node, nodeChange, err := api.h.state.HandleNodeFromAuthPath( - registrationId, - types.UserID(user.ID), - nil, - util.RegisterMethodCLI, - ) - if err != nil { - log.Error(). - Str(zf.RegistrationKey, registrationKey). - Err(err). - Msg("failed to register node") - return nil, err - } - - log.Info(). - Str(zf.RegistrationKey, registrationKey). - EmbedObject(node). - Msg("node registered successfully") - - // This is a bit of a back and forth, but we have a bit of a chicken and egg - // dependency here. - // Because the way the policy manager works, we need to have the node - // in the database, then add it to the policy manager and then we can - // approve the route. This means we get this dance where the node is - // first added to the database, then we add it to the policy manager via - // SaveNode (which automatically updates the policy manager) and then we can auto approve the routes. - // As that only approves the struct object, we need to save it again and - // ensure we send an update. - // This works, but might be another good candidate for doing some sort of - // eventbus. - routeChange, err := api.h.state.AutoApproveRoutes(node) - if err != nil { - return nil, fmt.Errorf("auto approving routes: %w", err) - } - - // Send both changes. Empty changes are ignored by [Headscale.Change]. - api.h.Change(nodeChange, routeChange) - - return &v1.RegisterNodeResponse{Node: node.Proto()}, nil -} - -func (api headscaleV1APIServer) GetNode( - ctx context.Context, - request *v1.GetNodeRequest, -) (*v1.GetNodeResponse, error) { - node, ok := api.h.state.GetNodeByID(types.NodeID(request.GetNodeId())) - if !ok { - return nil, status.Errorf(codes.NotFound, "node not found") - } - - resp := node.Proto() - - return &v1.GetNodeResponse{Node: resp}, nil -} - -func (api headscaleV1APIServer) SetTags( - ctx context.Context, - request *v1.SetTagsRequest, -) (*v1.SetTagsResponse, error) { - // Validate tags not empty - tagged nodes must have at least one tag - if len(request.GetTags()) == 0 { - return &v1.SetTagsResponse{ - Node: nil, - }, status.Error( - codes.InvalidArgument, - "cannot remove all tags from a node - tagged nodes must have at least one tag", - ) - } - - // Validate tag format - for _, tag := range request.GetTags() { - err := validateTag(tag) - if err != nil { - return nil, err - } - } - - // User XOR Tags: nodes are either tagged or user-owned, never both. - // Setting tags on a user-owned node converts it to a tagged node. - // Once tagged, a node cannot be converted back to user-owned. - _, found := api.h.state.GetNodeByID(types.NodeID(request.GetNodeId())) - if !found { - return &v1.SetTagsResponse{ - Node: nil, - }, status.Error(codes.NotFound, "node not found") - } - - node, nodeChange, err := api.h.state.SetNodeTags(types.NodeID(request.GetNodeId()), request.GetTags()) - if err != nil { - return &v1.SetTagsResponse{ - Node: nil, - }, status.Error(codes.InvalidArgument, err.Error()) - } - - api.h.Change(nodeChange) - - log.Trace(). - Caller(). - EmbedObject(node). - Strs("tags", request.GetTags()). - Msg("changing tags of node") - - return &v1.SetTagsResponse{Node: node.Proto()}, nil -} - -func (api headscaleV1APIServer) SetApprovedRoutes( - ctx context.Context, - request *v1.SetApprovedRoutesRequest, -) (*v1.SetApprovedRoutesResponse, error) { - log.Debug(). - Caller(). - Uint64(zf.NodeID, request.GetNodeId()). - Strs("requestedRoutes", request.GetRoutes()). - Msg("gRPC SetApprovedRoutes called") - - var newApproved []netip.Prefix - for _, route := range request.GetRoutes() { - prefix, err := netip.ParsePrefix(route) - if err != nil { - return nil, fmt.Errorf("parsing route: %w", err) - } - - // If the prefix is an exit route, add both. The client expect both - // to annotate the node as an exit node. - if prefix == tsaddr.AllIPv4() || prefix == tsaddr.AllIPv6() { - newApproved = append(newApproved, tsaddr.AllIPv4(), tsaddr.AllIPv6()) - } else { - newApproved = append(newApproved, prefix) - } - } - slices.SortFunc(newApproved, netip.Prefix.Compare) - newApproved = slices.Compact(newApproved) - - node, nodeChange, err := api.h.state.SetApprovedRoutes(types.NodeID(request.GetNodeId()), newApproved) - if err != nil { - return nil, status.Error(codes.InvalidArgument, err.Error()) - } - - // Always propagate node changes from [state.State.SetApprovedRoutes] - api.h.Change(nodeChange) - - proto := node.Proto() - // Populate [types.Node.SubnetRoutes] with [tailcfg.Node.PrimaryRoutes] to ensure it includes only the - // routes that are actively served from the node (per architectural requirement in types/node.go) - primaryRoutes := api.h.state.GetNodePrimaryRoutes(node.ID()) - proto.SubnetRoutes = util.PrefixesToString(primaryRoutes) - - log.Debug(). - Caller(). - EmbedObject(node). - Strs("approvedRoutes", util.PrefixesToString(node.ApprovedRoutes().AsSlice())). - Strs("primaryRoutes", util.PrefixesToString(primaryRoutes)). - Strs("finalSubnetRoutes", proto.SubnetRoutes). - Msg("gRPC SetApprovedRoutes completed") - - return &v1.SetApprovedRoutesResponse{Node: proto}, nil -} - -func validateTag(tag string) error { - if !strings.HasPrefix(tag, "tag:") { - return errors.New("tag must start with the string 'tag:'") - } - if strings.ToLower(tag) != tag { - return errors.New("tag should be lowercase") - } - if len(strings.Fields(tag)) > 1 { - return errors.New("tags must not contain spaces") - } - return nil -} - -func (api headscaleV1APIServer) DeleteNode( - ctx context.Context, - request *v1.DeleteNodeRequest, -) (*v1.DeleteNodeResponse, error) { - node, ok := api.h.state.GetNodeByID(types.NodeID(request.GetNodeId())) - if !ok { - return nil, status.Errorf(codes.NotFound, "node not found") - } - - nodeChange, err := api.h.state.DeleteNode(node) - if err != nil { - return nil, err - } - - api.h.Change(nodeChange) - - return &v1.DeleteNodeResponse{}, nil -} - -func (api headscaleV1APIServer) ExpireNode( - ctx context.Context, - request *v1.ExpireNodeRequest, -) (*v1.ExpireNodeResponse, error) { - if request.GetDisableExpiry() && request.GetExpiry() != nil { - return nil, status.Error( - codes.InvalidArgument, - "cannot set both disable_expiry and expiry", - ) - } - - // Handle disable expiry request - node will never expire. - if request.GetDisableExpiry() { - node, nodeChange, err := api.h.state.SetNodeExpiry( - types.NodeID(request.GetNodeId()), nil, - ) - if err != nil { - return nil, err - } - - api.h.Change(nodeChange) - - log.Trace(). - Caller(). - EmbedObject(node). - Msg("node expiry disabled") - - return &v1.ExpireNodeResponse{Node: node.Proto()}, nil - } - - expiry := time.Now() - if request.GetExpiry() != nil { - expiry = request.GetExpiry().AsTime() - } - - node, nodeChange, err := api.h.state.SetNodeExpiry( - types.NodeID(request.GetNodeId()), &expiry, - ) - if err != nil { - return nil, err - } - - // TODO(kradalby): Ensure that both the selfupdate and peer updates are sent - api.h.Change(nodeChange) - - log.Trace(). - Caller(). - EmbedObject(node). - Time(zf.ExpiresAt, expiry). - Msg("node expired") - - return &v1.ExpireNodeResponse{Node: node.Proto()}, nil -} - -func (api headscaleV1APIServer) RenameNode( - ctx context.Context, - request *v1.RenameNodeRequest, -) (*v1.RenameNodeResponse, error) { - node, nodeChange, err := api.h.state.RenameNode(types.NodeID(request.GetNodeId()), request.GetNewName()) - if err != nil { - return nil, err - } - - // TODO(kradalby): investigate if we need selfupdate - api.h.Change(nodeChange) - - log.Trace(). - Caller(). - EmbedObject(node). - Str(zf.NewName, request.GetNewName()). - Msg("node renamed") - - return &v1.RenameNodeResponse{Node: node.Proto()}, nil -} - -func (api headscaleV1APIServer) ListNodes( - ctx context.Context, - request *v1.ListNodesRequest, -) (*v1.ListNodesResponse, error) { - // TODO(kradalby): This should be done in one tx. - var nodes views.Slice[types.NodeView] - if request.GetUser() != "" { - user, err := api.h.state.GetUserByName(request.GetUser()) - if err != nil { - return nil, err - } - - nodes = api.h.state.ListNodesByUser(types.UserID(user.ID)) - } else { - nodes = api.h.state.ListNodes() - } - - response := nodesToProto(api.h.state, nodes) - return &v1.ListNodesResponse{Nodes: response}, nil -} - -func nodesToProto(state *state.State, nodes views.Slice[types.NodeView]) []*v1.Node { - response := make([]*v1.Node, nodes.Len()) - for index, node := range nodes.All() { - resp := node.Proto() - - // Tags-as-identity: tagged nodes show as [types.TaggedDevices] user in API responses - // (UserID may be set internally for "created by" tracking) - if node.IsTagged() { - resp.User = types.TaggedDevices.Proto() - } - - resp.SubnetRoutes = util.PrefixesToString(append(state.GetNodePrimaryRoutes(node.ID()), node.ExitRoutes()...)) - response[index] = resp - } - - sortByID(response) - - return response -} - -func (api headscaleV1APIServer) BackfillNodeIPs( - ctx context.Context, - request *v1.BackfillNodeIPsRequest, -) (*v1.BackfillNodeIPsResponse, error) { - log.Trace().Caller().Msg("backfill called") - - if !request.Confirmed { - return nil, errors.New("not confirmed, aborting") - } - - changes, err := api.h.state.BackfillNodeIPs() - if err != nil { - return nil, err - } - - return &v1.BackfillNodeIPsResponse{Changes: changes}, nil -} - -func (api headscaleV1APIServer) CreateApiKey( - ctx context.Context, - request *v1.CreateApiKeyRequest, -) (*v1.CreateApiKeyResponse, error) { - var expiration time.Time - if request.GetExpiration() != nil { - expiration = request.GetExpiration().AsTime() - } - - apiKey, _, err := api.h.state.CreateAPIKey(&expiration) - if err != nil { - return nil, err - } - - return &v1.CreateApiKeyResponse{ApiKey: apiKey}, nil -} - -// apiKeyIdentifier is implemented by requests that identify an API key. -type apiKeyIdentifier interface { - GetId() uint64 - GetPrefix() string -} - -// getAPIKey retrieves an API key by ID or prefix from the request. -// Returns InvalidArgument if neither or both are provided. -func (api headscaleV1APIServer) getAPIKey(req apiKeyIdentifier) (*types.APIKey, error) { - hasID := req.GetId() != 0 - hasPrefix := req.GetPrefix() != "" - - switch { - case hasID && hasPrefix: - return nil, status.Error(codes.InvalidArgument, "provide either id or prefix, not both") - case hasID: - return api.h.state.GetAPIKeyByID(req.GetId()) - case hasPrefix: - return api.h.state.GetAPIKey(req.GetPrefix()) - default: - return nil, status.Error(codes.InvalidArgument, "must provide id or prefix") - } -} - -func (api headscaleV1APIServer) ExpireApiKey( - ctx context.Context, - request *v1.ExpireApiKeyRequest, -) (*v1.ExpireApiKeyResponse, error) { - apiKey, err := api.getAPIKey(request) - if err != nil { - return nil, err - } - - err = api.h.state.ExpireAPIKey(apiKey) - if err != nil { - return nil, err - } - - return &v1.ExpireApiKeyResponse{}, nil -} - -func (api headscaleV1APIServer) ListApiKeys( - ctx context.Context, - request *v1.ListApiKeysRequest, -) (*v1.ListApiKeysResponse, error) { - apiKeys, err := api.h.state.ListAPIKeys() - if err != nil { - return nil, err - } - - response := make([]*v1.ApiKey, len(apiKeys)) - for index, key := range apiKeys { - response[index] = key.Proto() - } - - sortByID(response) - - return &v1.ListApiKeysResponse{ApiKeys: response}, nil -} - -func (api headscaleV1APIServer) DeleteApiKey( - ctx context.Context, - request *v1.DeleteApiKeyRequest, -) (*v1.DeleteApiKeyResponse, error) { - apiKey, err := api.getAPIKey(request) - if err != nil { - return nil, err - } - - if err := api.h.state.DestroyAPIKey(*apiKey); err != nil { - return nil, err - } - - return &v1.DeleteApiKeyResponse{}, nil -} - -func (api headscaleV1APIServer) GetPolicy( - _ context.Context, - _ *v1.GetPolicyRequest, -) (*v1.GetPolicyResponse, error) { - switch api.h.cfg.Policy.Mode { - case types.PolicyModeDB: - p, err := api.h.state.GetPolicy() - if err != nil { - return nil, fmt.Errorf("loading ACL from database: %w", err) - } - - return &v1.GetPolicyResponse{ - Policy: p.Data, - UpdatedAt: timestamppb.New(p.UpdatedAt), - }, nil - case types.PolicyModeFile: - // Read the file and return the contents as-is. - absPath := util.AbsolutePathFromConfigPath(api.h.cfg.Policy.Path) - f, err := os.Open(absPath) - if err != nil { - return nil, fmt.Errorf("reading policy from path %q: %w", absPath, err) - } - - defer f.Close() - - b, err := io.ReadAll(f) - if err != nil { - return nil, fmt.Errorf("reading policy from file: %w", err) - } - - return &v1.GetPolicyResponse{Policy: string(b)}, nil - } - - return nil, fmt.Errorf("no supported policy mode found in configuration, policy.mode: %q", api.h.cfg.Policy.Mode) -} - -func (api headscaleV1APIServer) SetPolicy( - _ context.Context, - request *v1.SetPolicyRequest, -) (*v1.SetPolicyResponse, error) { - if api.h.cfg.Policy.Mode != types.PolicyModeDB { - return nil, types.ErrPolicyUpdateIsDisabled - } - - p := request.GetPolicy() - - // Validate and reject configuration that would error when applied - // when creating a map response. This requires nodes, so there is still - // a scenario where they might be allowed if the server has no nodes - // yet, but it should help for the general case and for hot reloading - // configurations. - nodes := api.h.state.ListNodes() - - _, err := api.h.state.SetPolicy([]byte(p)) - if err != nil { - return nil, fmt.Errorf("setting policy: %w", err) - } - - if nodes.Len() > 0 { - _, err = api.h.state.SSHPolicy(nodes.At(0)) - if err != nil { - return nil, fmt.Errorf("verifying SSH rules: %w", err) - } - } - - updated, err := api.h.state.SetPolicyInDB(p) - if err != nil { - return nil, err - } - - // Always reload policy to ensure route re-evaluation, even if policy content hasn't changed. - // This ensures that routes are re-evaluated for auto-approval in cases where routes - // were manually disabled but could now be auto-approved with the current policy. - cs, err := api.h.state.ReloadPolicy() - if err != nil { - return nil, fmt.Errorf("reloading policy: %w", err) - } - - if len(cs) > 0 { - api.h.Change(cs...) - } else { - log.Debug(). - Caller(). - Msg("No policy changes to distribute because ReloadPolicy returned empty changeset") - } - - response := &v1.SetPolicyResponse{ - Policy: updated.Data, - UpdatedAt: timestamppb.New(updated.UpdatedAt), - } - - log.Debug(). - Caller(). - Msg("gRPC SetPolicy completed successfully because response prepared") - - return response, nil -} - -// CheckPolicy validates the given policy against the server's live users -// and nodes, running its `tests` block as a sandbox. Nothing is persisted -// and the live PolicyManager is not touched. Works regardless of -// policy.mode so operators can validate a policy file before storing it. -func (api headscaleV1APIServer) CheckPolicy( - _ context.Context, - request *v1.CheckPolicyRequest, -) (*v1.CheckPolicyResponse, error) { - polB := []byte(request.GetPolicy()) - - users, err := api.h.state.ListAllUsers() - if err != nil { - return nil, status.Errorf(codes.Internal, "loading users: %s", err) - } - - nodes := api.h.state.ListNodes() - - pm, err := policyv2.NewPolicyManager(polB, users, nodes) - if err != nil { - return nil, status.Error(codes.InvalidArgument, err.Error()) - } - - if _, err := pm.SetPolicy(polB); err != nil { - return nil, status.Error(codes.InvalidArgument, err.Error()) - } - - return &v1.CheckPolicyResponse{}, nil -} - -// The following service calls are for testing and debugging -func (api headscaleV1APIServer) DebugCreateNode( - ctx context.Context, - request *v1.DebugCreateNodeRequest, -) (*v1.DebugCreateNodeResponse, error) { - user, err := api.h.state.GetUserByName(request.GetUser()) - if err != nil { - return nil, err - } - - routes, err := util.StringToIPPrefix(request.GetRoutes()) - if err != nil { - return nil, err - } - - log.Trace(). - Caller(). - Interface("route-prefix", routes). - Interface("route-str", request.GetRoutes()). - Msg("Creating routes for node") - - registrationId, err := types.AuthIDFromString(request.GetKey()) - if err != nil { - return nil, err - } - - regData := &types.RegistrationData{ - NodeKey: key.NewNode().Public(), - MachineKey: key.NewMachine().Public(), - Hostname: request.GetName(), - Expiry: &time.Time{}, // zero time, not nil — preserves proto JSON round-trip semantics - } - - log.Debug(). - Caller(). - Str("registration_id", registrationId.String()). - Msg("adding debug machine via CLI, appending to registration cache") - - authRegReq := types.NewRegisterAuthRequest(regData) - api.h.state.SetAuthCacheEntry(registrationId, authRegReq) - - // Echo back a synthetic [types.Node] so the debug response surface stays - // stable. The actual node is created later by [headscaleV1APIServer.AuthApprove] via - // [state.State.HandleNodeFromAuthPath] using the cached [types.RegistrationData]. - echoNode := types.Node{ - NodeKey: regData.NodeKey, - MachineKey: regData.MachineKey, - Hostname: regData.Hostname, - User: user, - Expiry: &time.Time{}, - LastSeen: &time.Time{}, - Hostinfo: &tailcfg.Hostinfo{ - Hostname: request.GetName(), - OS: "TestOS", - RoutableIPs: routes, - }, - } - - return &v1.DebugCreateNodeResponse{Node: echoNode.Proto()}, nil -} - -func (api headscaleV1APIServer) Health( - ctx context.Context, - request *v1.HealthRequest, -) (*v1.HealthResponse, error) { - var healthErr error - response := &v1.HealthResponse{} - - if err := api.h.state.PingDB(ctx); err != nil { - healthErr = fmt.Errorf("pinging database: %w", err) - } else { - response.DatabaseConnectivity = true - } - - if healthErr != nil { - log.Error().Err(healthErr).Msg("health check failed") - } - - return response, healthErr -} - -func (api headscaleV1APIServer) AuthRegister( - ctx context.Context, - request *v1.AuthRegisterRequest, -) (*v1.AuthRegisterResponse, error) { - resp, err := api.RegisterNode(ctx, &v1.RegisterNodeRequest{ - Key: request.GetAuthId(), - User: request.GetUser(), - }) - if err != nil { - return nil, err - } - - return &v1.AuthRegisterResponse{Node: resp.GetNode()}, nil -} - -func (api headscaleV1APIServer) AuthApprove( - ctx context.Context, - request *v1.AuthApproveRequest, -) (*v1.AuthApproveResponse, error) { - authID, err := types.AuthIDFromString(request.GetAuthId()) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "invalid auth_id: %v", err) - } - - authReq, ok := api.h.state.GetAuthCacheEntry(authID) - if !ok { - return nil, status.Errorf(codes.NotFound, "no pending auth session for auth_id %s", authID) - } - - authReq.FinishAuth(types.AuthVerdict{}) - - return &v1.AuthApproveResponse{}, nil -} - -func (api headscaleV1APIServer) AuthReject( - ctx context.Context, - request *v1.AuthRejectRequest, -) (*v1.AuthRejectResponse, error) { - authID, err := types.AuthIDFromString(request.GetAuthId()) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "invalid auth_id: %v", err) - } - - authReq, ok := api.h.state.GetAuthCacheEntry(authID) - if !ok { - return nil, status.Errorf(codes.NotFound, "no pending auth session for auth_id %s", authID) - } - - authReq.FinishAuth(types.AuthVerdict{ - Err: errors.New("auth request rejected"), - }) - - return &v1.AuthRejectResponse{}, nil -} - -func (api headscaleV1APIServer) mustEmbedUnimplementedHeadscaleServiceServer() {} diff --git a/hscontrol/grpcv1_test.go b/hscontrol/grpcv1_test.go deleted file mode 100644 index 1bb4cbe2d..000000000 --- a/hscontrol/grpcv1_test.go +++ /dev/null @@ -1,819 +0,0 @@ -package hscontrol - -import ( - "context" - "testing" - "time" - - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" - "github.com/juanfont/headscale/hscontrol/types" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "tailscale.com/tailcfg" - "tailscale.com/types/key" -) - -func Test_validateTag(t *testing.T) { - type args struct { - tag string - } - - tests := []struct { - name string - args args - wantErr bool - }{ - { - name: "valid tag", - args: args{tag: "tag:test"}, - wantErr: false, - }, - { - name: "tag without tag prefix", - args: args{tag: "test"}, - wantErr: true, - }, - { - name: "uppercase tag", - args: args{tag: "tag:tEST"}, - wantErr: true, - }, - { - name: "tag that contains space", - args: args{tag: "tag:this is a spaced tag"}, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateTag(tt.args.tag) - if (err != nil) != tt.wantErr { - t.Errorf("validateTag() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -// TestSetTags_Conversion tests the conversion of user-owned nodes to tagged nodes. -// The tags-as-identity model allows one-way conversion from user-owned to tagged. -// Tag authorization is checked via the policy manager - unauthorized tags are rejected. -func TestSetTags_Conversion(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - - // Create test user and nodes - user := app.state.CreateUserForTest("test-user") - - // Create a pre-auth key WITHOUT tags for user-owned node - pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil) - require.NoError(t, err) - - machineKey1 := key.NewMachine() - nodeKey1 := key.NewNode() - - // Register a user-owned node (via untagged PreAuthKey) - userOwnedReq := tailcfg.RegisterRequest{ - Auth: &tailcfg.RegisterResponseAuth{ - AuthKey: pak.Key, - }, - NodeKey: nodeKey1.Public(), - Hostinfo: &tailcfg.Hostinfo{ - Hostname: "user-owned-node", - }, - } - _, err = app.handleRegisterWithAuthKey(userOwnedReq, machineKey1.Public()) - require.NoError(t, err) - - // Get the created node - userOwnedNode, found := app.state.GetNodeByNodeKey(nodeKey1.Public()) - require.True(t, found) - - // Create API server instance - apiServer := newHeadscaleV1APIServer(app) - - tests := []struct { - name string - nodeID uint64 - tags []string - wantErr bool - wantCode codes.Code - wantErrMessage string - }{ - { - // Conversion is allowed, but tag authorization fails without tagOwners - name: "reject unauthorized tags on user-owned node", - nodeID: uint64(userOwnedNode.ID()), - tags: []string{"tag:server"}, - wantErr: true, - wantCode: codes.InvalidArgument, - wantErrMessage: "requested tags", - }, - { - // Conversion is allowed, but tag authorization fails without tagOwners - name: "reject multiple unauthorized tags", - nodeID: uint64(userOwnedNode.ID()), - tags: []string{"tag:server", "tag:database"}, - wantErr: true, - wantCode: codes.InvalidArgument, - wantErrMessage: "requested tags", - }, - { - name: "reject non-existent node", - nodeID: 99999, - tags: []string{"tag:server"}, - wantErr: true, - wantCode: codes.NotFound, - wantErrMessage: "node not found", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - resp, err := apiServer.SetTags(context.Background(), &v1.SetTagsRequest{ - NodeId: tt.nodeID, - Tags: tt.tags, - }) - - if tt.wantErr { - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok, "error should be a gRPC status error") - assert.Equal(t, tt.wantCode, st.Code()) - assert.Contains(t, st.Message(), tt.wantErrMessage) - assert.Nil(t, resp.GetNode()) - } else { - require.NoError(t, err) - assert.NotNil(t, resp) - assert.NotNil(t, resp.GetNode()) - } - }) - } -} - -// TestSetTags_TaggedNode tests that [headscaleV1APIServer.SetTags] correctly identifies tagged nodes -// and doesn't reject them with the "user-owned nodes" error. -// Note: This test doesn't validate ACL tag authorization - that's tested elsewhere. -func TestSetTags_TaggedNode(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - - // Create test user and tagged pre-auth key - user := app.state.CreateUserForTest("test-user") - pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, []string{"tag:initial"}) - require.NoError(t, err) - - machineKey := key.NewMachine() - nodeKey := key.NewNode() - - // Register a tagged node (via tagged PreAuthKey) - taggedReq := tailcfg.RegisterRequest{ - Auth: &tailcfg.RegisterResponseAuth{ - AuthKey: pak.Key, - }, - NodeKey: nodeKey.Public(), - Hostinfo: &tailcfg.Hostinfo{ - Hostname: "tagged-node", - }, - } - _, err = app.handleRegisterWithAuthKey(taggedReq, machineKey.Public()) - require.NoError(t, err) - - // Get the created node - taggedNode, found := app.state.GetNodeByNodeKey(nodeKey.Public()) - require.True(t, found) - assert.True(t, taggedNode.IsTagged(), "Node should be tagged") - assert.False(t, taggedNode.UserID().Valid(), "Tagged node should not have UserID") - - // Create API server instance - apiServer := newHeadscaleV1APIServer(app) - - // Test: [headscaleV1APIServer.SetTags] should work on tagged nodes. - resp, err := apiServer.SetTags(context.Background(), &v1.SetTagsRequest{ - NodeId: uint64(taggedNode.ID()), - Tags: []string{"tag:initial"}, // Keep existing tag to avoid ACL validation issues - }) - - // The call should NOT fail with "cannot set tags on user-owned nodes" - if err != nil { - st, ok := status.FromError(err) - require.True(t, ok) - // If error is about unauthorized tags, that's fine - ACL validation is working - // If error is about user-owned nodes, that's the bug we're testing for - assert.NotContains(t, st.Message(), "user-owned nodes", "Should not reject tagged nodes as user-owned") - } else { - // Success is also fine - assert.NotNil(t, resp) - } -} - -// TestSetTags_CannotRemoveAllTags tests that [headscaleV1APIServer.SetTags] rejects attempts to remove -// all tags from a tagged node, enforcing Tailscale's requirement that tagged -// nodes must have at least one tag. -func TestSetTags_CannotRemoveAllTags(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - - // Create test user and tagged pre-auth key - user := app.state.CreateUserForTest("test-user") - pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, []string{"tag:server"}) - require.NoError(t, err) - - machineKey := key.NewMachine() - nodeKey := key.NewNode() - - // Register a tagged node - taggedReq := tailcfg.RegisterRequest{ - Auth: &tailcfg.RegisterResponseAuth{ - AuthKey: pak.Key, - }, - NodeKey: nodeKey.Public(), - Hostinfo: &tailcfg.Hostinfo{ - Hostname: "tagged-node", - }, - } - _, err = app.handleRegisterWithAuthKey(taggedReq, machineKey.Public()) - require.NoError(t, err) - - // Get the created node - taggedNode, found := app.state.GetNodeByNodeKey(nodeKey.Public()) - require.True(t, found) - assert.True(t, taggedNode.IsTagged()) - - // Create API server instance - apiServer := newHeadscaleV1APIServer(app) - - // Attempt to remove all tags (empty array) - resp, err := apiServer.SetTags(context.Background(), &v1.SetTagsRequest{ - NodeId: uint64(taggedNode.ID()), - Tags: []string{}, // Empty - attempting to remove all tags - }) - - // Should fail with InvalidArgument error - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok, "error should be a gRPC status error") - assert.Equal(t, codes.InvalidArgument, st.Code()) - assert.Contains(t, st.Message(), "cannot remove all tags") - assert.Nil(t, resp.GetNode()) -} - -// TestSetTags_ClearsUserIDInDatabase tests that converting a user-owned node -// to a tagged node via [headscaleV1APIServer.SetTags] correctly persists user_id = NULL in the -// database, not just in-memory. -func TestSetTags_ClearsUserIDInDatabase(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - - user := app.state.CreateUserForTest("tag-owner") - err := app.state.UpdatePolicyManagerUsersForTest() - require.NoError(t, err) - - _, err = app.state.SetPolicy([]byte(`{ - "tagOwners": {"tag:server": ["tag-owner@"]}, - "acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}] - }`)) - require.NoError(t, err) - - // Register a user-owned node (untagged PreAuthKey). - pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil) - require.NoError(t, err) - - machineKey := key.NewMachine() - nodeKey := key.NewNode() - - regReq := tailcfg.RegisterRequest{ - Auth: &tailcfg.RegisterResponseAuth{ - AuthKey: pak.Key, - }, - NodeKey: nodeKey.Public(), - Hostinfo: &tailcfg.Hostinfo{ - Hostname: "user-owned-node", - }, - } - _, err = app.handleRegisterWithAuthKey(regReq, machineKey.Public()) - require.NoError(t, err) - - node, found := app.state.GetNodeByNodeKey(nodeKey.Public()) - require.True(t, found) - require.False(t, node.IsTagged(), "node should start as user-owned") - require.True(t, node.UserID().Valid(), "user-owned node must have UserID") - - nodeID := node.ID() - - // Convert to tagged via [headscaleV1APIServer.SetTags] API. - apiServer := newHeadscaleV1APIServer(app) - _, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{ - NodeId: uint64(nodeID), - Tags: []string{"tag:server"}, - }) - require.NoError(t, err) - - // Verify in-memory state is correct. - nsNode, found := app.state.GetNodeByID(nodeID) - require.True(t, found) - assert.True(t, nsNode.IsTagged(), "NodeStore: node should be tagged") - assert.False(t, nsNode.UserID().Valid(), - "NodeStore: UserID should be nil for tagged node") - - // THE CRITICAL CHECK: verify database has user_id = NULL. - dbNode, err := app.state.DB().GetNodeByID(nodeID) - require.NoError(t, err) - assert.Nil(t, dbNode.UserID, - "Database: user_id must be NULL after converting to tagged node") - assert.True(t, dbNode.IsTagged(), - "Database: tags must be set") -} - -// TestSetTags_NodeDisappearsFromUserListing tests issue #3161: -// after converting a user-owned node to tagged, it must no longer appear -// when listing nodes filtered by the original user. -// https://github.com/juanfont/headscale/issues/3161 -func TestSetTags_NodeDisappearsFromUserListing(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - - user := app.state.CreateUserForTest("list-user") - err := app.state.UpdatePolicyManagerUsersForTest() - require.NoError(t, err) - - _, err = app.state.SetPolicy([]byte(`{ - "tagOwners": {"tag:web": ["list-user@"]}, - "acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}] - }`)) - require.NoError(t, err) - - // Register a user-owned node. - pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil) - require.NoError(t, err) - - machineKey := key.NewMachine() - nodeKey := key.NewNode() - - regReq := tailcfg.RegisterRequest{ - Auth: &tailcfg.RegisterResponseAuth{ - AuthKey: pak.Key, - }, - NodeKey: nodeKey.Public(), - Hostinfo: &tailcfg.Hostinfo{ - Hostname: "web-server", - }, - } - _, err = app.handleRegisterWithAuthKey(regReq, machineKey.Public()) - require.NoError(t, err) - - node, found := app.state.GetNodeByNodeKey(nodeKey.Public()) - require.True(t, found) - - // Verify node appears under user before tagging. - apiServer := newHeadscaleV1APIServer(app) - resp, err := apiServer.ListNodes(context.Background(), &v1.ListNodesRequest{ - User: "list-user", - }) - require.NoError(t, err) - assert.Len(t, resp.GetNodes(), 1, "user-owned node should appear under user") - - // Convert to tagged. - _, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{ - NodeId: uint64(node.ID()), - Tags: []string{"tag:web"}, - }) - require.NoError(t, err) - - // Node must NOT appear when listing by original user. - resp, err = apiServer.ListNodes(context.Background(), &v1.ListNodesRequest{ - User: "list-user", - }) - require.NoError(t, err) - assert.Empty(t, resp.GetNodes(), - "tagged node must not appear when listing nodes for original user") - - // Node must still appear in unfiltered listing. - allResp, err := apiServer.ListNodes(context.Background(), &v1.ListNodesRequest{}) - require.NoError(t, err) - require.Len(t, allResp.GetNodes(), 1) - assert.Contains(t, allResp.GetNodes()[0].GetTags(), "tag:web") -} - -// TestSetTags_NodeStoreAndDBConsistency verifies that after [headscaleV1APIServer.SetTags], the -// in-memory [state.NodeStore] and the database agree on the node's ownership state. -func TestSetTags_NodeStoreAndDBConsistency(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - - user := app.state.CreateUserForTest("consistency-user") - err := app.state.UpdatePolicyManagerUsersForTest() - require.NoError(t, err) - - _, err = app.state.SetPolicy([]byte(`{ - "tagOwners": {"tag:db": ["consistency-user@"]}, - "acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}] - }`)) - require.NoError(t, err) - - pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil) - require.NoError(t, err) - - machineKey := key.NewMachine() - nodeKey := key.NewNode() - - regReq := tailcfg.RegisterRequest{ - Auth: &tailcfg.RegisterResponseAuth{ - AuthKey: pak.Key, - }, - NodeKey: nodeKey.Public(), - Hostinfo: &tailcfg.Hostinfo{ - Hostname: "db-node", - }, - } - _, err = app.handleRegisterWithAuthKey(regReq, machineKey.Public()) - require.NoError(t, err) - - node, found := app.state.GetNodeByNodeKey(nodeKey.Public()) - require.True(t, found) - - nodeID := node.ID() - - // Convert to tagged. - apiServer := newHeadscaleV1APIServer(app) - _, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{ - NodeId: uint64(nodeID), - Tags: []string{"tag:db"}, - }) - require.NoError(t, err) - - // In-memory state. - nsNode, found := app.state.GetNodeByID(nodeID) - require.True(t, found) - - // Database state. - dbNode, err := app.state.DB().GetNodeByID(nodeID) - require.NoError(t, err) - - // Both must agree: tagged, no UserID. - assert.True(t, nsNode.IsTagged(), "NodeStore: should be tagged") - assert.True(t, dbNode.IsTagged(), "Database: should be tagged") - - assert.False(t, nsNode.UserID().Valid(), - "NodeStore: UserID should be nil") - assert.Nil(t, dbNode.UserID, - "Database: user_id should be NULL") - - assert.Equal(t, - nsNode.UserID().Valid(), - dbNode.UserID != nil, - "NodeStore and database must agree on UserID state") -} - -// TestSetTags_UserDeletionDoesNotCascadeToTaggedNode tests that deleting the -// original user does not cascade-delete a node that was converted to tagged -// via [headscaleV1APIServer.SetTags]. This catches the real-world consequence of stale user_id: -// ON DELETE CASCADE would destroy the tagged node. -func TestSetTags_UserDeletionDoesNotCascadeToTaggedNode(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - - user := app.state.CreateUserForTest("doomed-user") - err := app.state.UpdatePolicyManagerUsersForTest() - require.NoError(t, err) - - _, err = app.state.SetPolicy([]byte(`{ - "tagOwners": {"tag:survivor": ["doomed-user@"]}, - "acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}] - }`)) - require.NoError(t, err) - - pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, nil) - require.NoError(t, err) - - machineKey := key.NewMachine() - nodeKey := key.NewNode() - - regReq := tailcfg.RegisterRequest{ - Auth: &tailcfg.RegisterResponseAuth{ - AuthKey: pak.Key, - }, - NodeKey: nodeKey.Public(), - Hostinfo: &tailcfg.Hostinfo{ - Hostname: "survivor-node", - }, - } - _, err = app.handleRegisterWithAuthKey(regReq, machineKey.Public()) - require.NoError(t, err) - - node, found := app.state.GetNodeByNodeKey(nodeKey.Public()) - require.True(t, found) - - nodeID := node.ID() - - // Convert to tagged. - apiServer := newHeadscaleV1APIServer(app) - _, err = apiServer.SetTags(context.Background(), &v1.SetTagsRequest{ - NodeId: uint64(nodeID), - Tags: []string{"tag:survivor"}, - }) - require.NoError(t, err) - - // Delete the original user. - _, err = app.state.DeleteUser(*user.TypedID()) - require.NoError(t, err) - - // The tagged node must survive in both [state.NodeStore] and database. - nsNode, found := app.state.GetNodeByID(nodeID) - require.True(t, found, "tagged node must survive user deletion in NodeStore") - assert.True(t, nsNode.IsTagged()) - - dbNode, err := app.state.DB().GetNodeByID(nodeID) - require.NoError(t, err, "tagged node must survive user deletion in database") - assert.True(t, dbNode.IsTagged()) - assert.Nil(t, dbNode.UserID) -} - -// TestDeleteUser_ReturnsProperChangeSignal tests issue #2967 fix: -// When a user is deleted, the state should return a non-empty change signal -// to ensure policy manager is updated and clients are notified immediately. -func TestDeleteUser_ReturnsProperChangeSignal(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - - // Create a user - user := app.state.CreateUserForTest("test-user-to-delete") - require.NotNil(t, user) - - // Delete the user and verify a non-empty change is returned - // Without the fix, [state.State.DeleteUser] returned an empty change, - // causing stale policy state until another user operation triggered an update. - changeSignal, err := app.state.DeleteUser(*user.TypedID()) - require.NoError(t, err, "DeleteUser should succeed") - assert.False(t, changeSignal.IsEmpty(), "DeleteUser should return a non-empty change signal (issue #2967)") -} - -// TestDeleteUser_TaggedNodeSurvives tests that deleting a user succeeds when -// the user's only nodes are tagged, and that those nodes remain in the -// [state.NodeStore] with nil UserID. -func TestDeleteUser_TaggedNodeSurvives(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - - user := app.state.CreateUserForTest("legacy-user") - - // Register a tagged node via the full auth flow. - tags := []string{"tag:server"} - pak, err := app.state.CreatePreAuthKey(user.TypedID(), true, false, nil, tags) - require.NoError(t, err) - - machineKey := key.NewMachine() - nodeKey := key.NewNode() - - regReq := tailcfg.RegisterRequest{ - Auth: &tailcfg.RegisterResponseAuth{ - AuthKey: pak.Key, - }, - NodeKey: nodeKey.Public(), - Hostinfo: &tailcfg.Hostinfo{ - Hostname: "tagged-server", - }, - Expiry: time.Now().Add(24 * time.Hour), - } - - resp, err := app.handleRegisterWithAuthKey(regReq, machineKey.Public()) - require.NoError(t, err) - require.True(t, resp.MachineAuthorized) - - // Verify the registered node has nil UserID (enforced at registration). - node, found := app.state.GetNodeByNodeKey(nodeKey.Public()) - require.True(t, found) - require.True(t, node.IsTagged()) - assert.False(t, node.UserID().Valid(), - "tagged node should have nil UserID after registration") - - nodeID := node.ID() - - // [state.NodeStore] should not list the tagged node under any user. - nodesForUser := app.state.ListNodesByUser(types.UserID(user.ID)) - assert.Equal(t, 0, nodesForUser.Len(), - "tagged nodes should not appear in nodesByUser index") - - // Delete the user. - changeSignal, err := app.state.DeleteUser(*user.TypedID()) - require.NoError(t, err) - assert.False(t, changeSignal.IsEmpty()) - - // Tagged node survives in the [state.NodeStore]. - nodeAfter, found := app.state.GetNodeByID(nodeID) - require.True(t, found, "tagged node should survive user deletion") - assert.True(t, nodeAfter.IsTagged()) - assert.False(t, nodeAfter.UserID().Valid()) - - // Tagged node appears in the global list. - allNodes := app.state.ListNodes() - foundInAll := false - - for _, n := range allNodes.All() { - if n.ID() == nodeID { - foundInAll = true - - break - } - } - - assert.True(t, foundInAll, "tagged node should appear in the global node list") -} - -// TestExpireApiKey_ByID tests that API keys can be expired by ID. -func TestExpireApiKey_ByID(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - apiServer := newHeadscaleV1APIServer(app) - - // Create an API key - createResp, err := apiServer.CreateApiKey(context.Background(), &v1.CreateApiKeyRequest{}) - require.NoError(t, err) - require.NotEmpty(t, createResp.GetApiKey()) - - // List keys to get the ID - listResp, err := apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{}) - require.NoError(t, err) - require.Len(t, listResp.GetApiKeys(), 1) - - keyID := listResp.GetApiKeys()[0].GetId() - - // Expire by ID - _, err = apiServer.ExpireApiKey(context.Background(), &v1.ExpireApiKeyRequest{ - Id: keyID, - }) - require.NoError(t, err) - - // Verify key is expired (expiration is set to now or in the past) - listResp, err = apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{}) - require.NoError(t, err) - require.Len(t, listResp.GetApiKeys(), 1) - assert.NotNil(t, listResp.GetApiKeys()[0].GetExpiration(), "expiration should be set") -} - -// TestExpireApiKey_ByPrefix tests that API keys can still be expired by prefix. -func TestExpireApiKey_ByPrefix(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - apiServer := newHeadscaleV1APIServer(app) - - // Create an API key - createResp, err := apiServer.CreateApiKey(context.Background(), &v1.CreateApiKeyRequest{}) - require.NoError(t, err) - require.NotEmpty(t, createResp.GetApiKey()) - - // List keys to get the prefix - listResp, err := apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{}) - require.NoError(t, err) - require.Len(t, listResp.GetApiKeys(), 1) - - keyPrefix := listResp.GetApiKeys()[0].GetPrefix() - - // Expire by prefix - _, err = apiServer.ExpireApiKey(context.Background(), &v1.ExpireApiKeyRequest{ - Prefix: keyPrefix, - }) - require.NoError(t, err) -} - -// TestDeleteApiKey_ByID tests that API keys can be deleted by ID. -func TestDeleteApiKey_ByID(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - apiServer := newHeadscaleV1APIServer(app) - - // Create an API key - createResp, err := apiServer.CreateApiKey(context.Background(), &v1.CreateApiKeyRequest{}) - require.NoError(t, err) - require.NotEmpty(t, createResp.GetApiKey()) - - // List keys to get the ID - listResp, err := apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{}) - require.NoError(t, err) - require.Len(t, listResp.GetApiKeys(), 1) - - keyID := listResp.GetApiKeys()[0].GetId() - - // Delete by ID - _, err = apiServer.DeleteApiKey(context.Background(), &v1.DeleteApiKeyRequest{ - Id: keyID, - }) - require.NoError(t, err) - - // Verify key is deleted - listResp, err = apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{}) - require.NoError(t, err) - assert.Empty(t, listResp.GetApiKeys()) -} - -// TestDeleteApiKey_ByPrefix tests that API keys can still be deleted by prefix. -func TestDeleteApiKey_ByPrefix(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - apiServer := newHeadscaleV1APIServer(app) - - // Create an API key - createResp, err := apiServer.CreateApiKey(context.Background(), &v1.CreateApiKeyRequest{}) - require.NoError(t, err) - require.NotEmpty(t, createResp.GetApiKey()) - - // List keys to get the prefix - listResp, err := apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{}) - require.NoError(t, err) - require.Len(t, listResp.GetApiKeys(), 1) - - keyPrefix := listResp.GetApiKeys()[0].GetPrefix() - - // Delete by prefix - _, err = apiServer.DeleteApiKey(context.Background(), &v1.DeleteApiKeyRequest{ - Prefix: keyPrefix, - }) - require.NoError(t, err) - - // Verify key is deleted - listResp, err = apiServer.ListApiKeys(context.Background(), &v1.ListApiKeysRequest{}) - require.NoError(t, err) - assert.Empty(t, listResp.GetApiKeys()) -} - -// TestExpireApiKey_NoIdentifier tests that an error is returned when neither ID nor prefix is provided. -func TestExpireApiKey_NoIdentifier(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - apiServer := newHeadscaleV1APIServer(app) - - _, err := apiServer.ExpireApiKey(context.Background(), &v1.ExpireApiKeyRequest{}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok, "error should be a gRPC status error") - assert.Equal(t, codes.InvalidArgument, st.Code()) - assert.Contains(t, st.Message(), "must provide id or prefix") -} - -// TestDeleteApiKey_NoIdentifier tests that an error is returned when neither ID nor prefix is provided. -func TestDeleteApiKey_NoIdentifier(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - apiServer := newHeadscaleV1APIServer(app) - - _, err := apiServer.DeleteApiKey(context.Background(), &v1.DeleteApiKeyRequest{}) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok, "error should be a gRPC status error") - assert.Equal(t, codes.InvalidArgument, st.Code()) - assert.Contains(t, st.Message(), "must provide id or prefix") -} - -// TestExpireApiKey_BothIdentifiers tests that an error is returned when both ID and prefix are provided. -func TestExpireApiKey_BothIdentifiers(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - apiServer := newHeadscaleV1APIServer(app) - - _, err := apiServer.ExpireApiKey(context.Background(), &v1.ExpireApiKeyRequest{ - Id: 1, - Prefix: "test", - }) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok, "error should be a gRPC status error") - assert.Equal(t, codes.InvalidArgument, st.Code()) - assert.Contains(t, st.Message(), "provide either id or prefix, not both") -} - -// TestDeleteApiKey_BothIdentifiers tests that an error is returned when both ID and prefix are provided. -func TestDeleteApiKey_BothIdentifiers(t *testing.T) { - t.Parallel() - - app := createTestApp(t) - apiServer := newHeadscaleV1APIServer(app) - - _, err := apiServer.DeleteApiKey(context.Background(), &v1.DeleteApiKeyRequest{ - Id: 1, - Prefix: "test", - }) - require.Error(t, err) - st, ok := status.FromError(err) - require.True(t, ok, "error should be a gRPC status error") - assert.Equal(t, codes.InvalidArgument, st.Code()) - assert.Contains(t, st.Message(), "provide either id or prefix, not both") -} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index fc78beb3a..1069d8de8 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -1793,7 +1793,7 @@ func (s *State) createAndSaveNewNode(params newNodeParams) (types.NodeView, erro if params.PreAuthKey.IsTagged() { // Tagged nodes are owned by their tags, not a user. // UserID is intentionally left nil. - nodeToRegister.Tags = params.PreAuthKey.Proto().GetAclTags() + nodeToRegister.Tags = params.PreAuthKey.Tags // Tagged nodes have key expiry disabled. nodeToRegister.Expiry = nil @@ -2427,7 +2427,7 @@ func (s *State) HandleNodeFromPreAuthKey( // user-less and never expire). Only update AuthKey reference // otherwise. if pak.IsTagged() && !node.IsTagged() { - node.Tags = pak.Proto().GetAclTags() + node.Tags = pak.Tags node.UserID = nil node.User = nil node.Expiry = nil diff --git a/hscontrol/types/api_key.go b/hscontrol/types/api_key.go index 3ad3450ed..17854b44d 100644 --- a/hscontrol/types/api_key.go +++ b/hscontrol/types/api_key.go @@ -3,10 +3,8 @@ package types import ( "time" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" "github.com/juanfont/headscale/hscontrol/util/zlog/zf" "github.com/rs/zerolog" - "google.golang.org/protobuf/types/known/timestamppb" ) // NewAPIKeyPrefixLength is the length of the prefix for new API keys. @@ -24,28 +22,6 @@ type APIKey struct { LastSeen *time.Time } -func (key *APIKey) Proto() *v1.ApiKey { - protoKey := v1.ApiKey{ - Id: key.ID, - } - - protoKey.Prefix = key.maskedPrefix() - - if key.Expiration != nil { - protoKey.Expiration = timestamppb.New(*key.Expiration) - } - - if key.CreatedAt != nil { - protoKey.CreatedAt = timestamppb.New(*key.CreatedAt) - } - - if key.LastSeen != nil { - protoKey.LastSeen = timestamppb.New(*key.LastSeen) - } - - return &protoKey -} - // maskedPrefix returns the API key prefix in masked format for safe logging. // SECURITY: Never log the full key or hash, only the masked prefix. func (k *APIKey) maskedPrefix() string { diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 18fcd939d..852c2b625 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -98,8 +98,6 @@ type Config struct { ServerURL string Addr string MetricsAddr string - GRPCAddr string - GRPCAllowInsecure bool TrustedProxies []netip.Prefix Node NodeConfig PrefixV4 *netip.Prefix @@ -418,9 +416,6 @@ func LoadConfig(path string, isFile bool) error { viper.SetDefault("unix_socket", "/var/run/headscale/headscale.sock") viper.SetDefault("unix_socket_permission", "0o770") - viper.SetDefault("grpc_listen_addr", ":50443") - viper.SetDefault("grpc_allow_insecure", false) - viper.SetDefault("cli.timeout", "5s") viper.SetDefault("cli.insecure", false) @@ -1182,8 +1177,6 @@ func LoadServerConfig() (*Config, error) { ServerURL: serverURL, Addr: viper.GetString("listen_addr"), MetricsAddr: viper.GetString("metrics_listen_addr"), - GRPCAddr: viper.GetString("grpc_listen_addr"), - GRPCAllowInsecure: viper.GetBool("grpc_allow_insecure"), TrustedProxies: trusted, DisableUpdateCheck: false, diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index b487c5813..af29bd4bf 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -10,13 +10,11 @@ import ( "strings" "time" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" "github.com/juanfont/headscale/hscontrol/policy/matcher" "github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/hscontrol/util/zlog/zf" "github.com/rs/zerolog" "go4.org/netipx" - "google.golang.org/protobuf/types/known/timestamppb" "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" "tailscale.com/types/key" @@ -483,55 +481,6 @@ func (nodes Nodes) ContainsNodeKey(nodeKey key.NodePublic) bool { return false } -func (node *Node) Proto() *v1.Node { - nodeProto := &v1.Node{ - Id: uint64(node.ID), - MachineKey: node.MachineKey.String(), - - NodeKey: node.NodeKey.String(), - DiscoKey: node.DiscoKey.String(), - - // TODO(kradalby): replace list with v4, v6 field? - IpAddresses: node.IPsAsString(), - Name: node.Hostname, - GivenName: node.GivenName, - User: nil, // Will be set below based on node type - Tags: node.Tags, - Online: node.IsOnline != nil && *node.IsOnline, - - // Only ApprovedRoutes and AvailableRoutes is set here. SubnetRoutes has - // to be populated manually with PrimaryRoute, to ensure it includes the - // routes that are actively served from the node. - ApprovedRoutes: util.PrefixesToString(node.ApprovedRoutes), - AvailableRoutes: util.PrefixesToString(node.AnnouncedRoutes()), - - RegisterMethod: node.RegisterMethodToV1Enum(), - - CreatedAt: timestamppb.New(node.CreatedAt), - } - - // Set User field based on node ownership - // Note: User will be set to [TaggedDevices] in the gRPC layer (grpcv1.go) - // for proper [tailcfg.MapResponse] formatting - if node.User != nil { - nodeProto.User = node.User.Proto() - } - - if node.AuthKey != nil { - nodeProto.PreAuthKey = node.AuthKey.Proto() - } - - if node.LastSeen != nil { - nodeProto.LastSeen = timestamppb.New(*node.LastSeen) - } - - if node.Expiry != nil { - nodeProto.Expiry = timestamppb.New(*node.Expiry) - } - - return nodeProto -} - func (node *Node) GetFQDN(baseDomain string) (string, error) { if node.GivenName == "" { return "", fmt.Errorf("creating valid FQDN: %w", ErrNodeHasNoGivenName) @@ -574,11 +523,9 @@ func (node *Node) AnnouncedRoutes() []netip.Prefix { // announces and are approved. Also used by [Node.CanAccess] and [Node.CanAccessRoute] as part // of the subnet-router-as-source identity. // -// IMPORTANT: This method is used for internal data structures and should NOT be -// used for the gRPC Proto conversion. For Proto, SubnetRoutes must be populated -// manually with PrimaryRoutes to ensure it includes only routes actively served -// by the node. See the comment in [Node.Proto] method and the implementation in -// grpcv1.go/nodesToProto. +// IMPORTANT: This method reflects announced-and-approved routes. API +// responses that need the routes actively served by the node must +// populate SubnetRoutes from the primary-route election instead. func (node *Node) SubnetRoutes() []netip.Prefix { var routes []netip.Prefix @@ -684,19 +631,6 @@ func EndpointsChanged(oldEndpoints, newEndpoints []netip.AddrPort) bool { return !equalUnordered(oldEndpoints, newEndpoints, netip.AddrPort.Compare) } -func (node *Node) RegisterMethodToV1Enum() v1.RegisterMethod { - switch node.RegisterMethod { - case "authkey": - return v1.RegisterMethod_REGISTER_METHOD_AUTH_KEY - case "oidc": - return v1.RegisterMethod_REGISTER_METHOD_OIDC - case "cli": - return v1.RegisterMethod_REGISTER_METHOD_CLI - default: - return v1.RegisterMethod_REGISTER_METHOD_UNSPECIFIED - } -} - // ApplyPeerChange takes a [tailcfg.PeerChange] struct and updates the node. func (node *Node) ApplyPeerChange(change *tailcfg.PeerChange) { if change.Key != nil { @@ -998,15 +932,6 @@ func (nv NodeView) RequestTags() []string { return nv.Hostinfo().RequestTags().AsSlice() } -// Proto converts the [NodeView] to a protobuf representation. -func (nv NodeView) Proto() *v1.Node { - if !nv.Valid() { - return nil - } - - return nv.ж.Proto() -} - // HasIP reports if a node has a given IP address. func (nv NodeView) HasIP(i netip.Addr) bool { if !nv.Valid() { diff --git a/hscontrol/types/node_test.go b/hscontrol/types/node_test.go index 0bec4aeff..3c8a5ba8b 100644 --- a/hscontrol/types/node_test.go +++ b/hscontrol/types/node_test.go @@ -8,7 +8,6 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" "github.com/juanfont/headscale/hscontrol/policy/matcher" "github.com/juanfont/headscale/hscontrol/util" "tailscale.com/tailcfg" @@ -657,56 +656,6 @@ func TestApplyPeerChange(t *testing.T) { } } -func TestNodeRegisterMethodToV1Enum(t *testing.T) { - tests := []struct { - name string - node Node - want v1.RegisterMethod - }{ - { - name: "authkey", - node: Node{ - ID: 1, - RegisterMethod: util.RegisterMethodAuthKey, - }, - want: v1.RegisterMethod_REGISTER_METHOD_AUTH_KEY, - }, - { - name: "oidc", - node: Node{ - ID: 1, - RegisterMethod: util.RegisterMethodOIDC, - }, - want: v1.RegisterMethod_REGISTER_METHOD_OIDC, - }, - { - name: "cli", - node: Node{ - ID: 1, - RegisterMethod: util.RegisterMethodCLI, - }, - want: v1.RegisterMethod_REGISTER_METHOD_CLI, - }, - { - name: "unknown", - node: Node{ - ID: 0, - }, - want: v1.RegisterMethod_REGISTER_METHOD_UNSPECIFIED, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.node.RegisterMethodToV1Enum() - - if diff := cmp.Diff(tt.want, got); diff != "" { - t.Errorf("RegisterMethodToV1Enum() unexpected result (-want +got):\n%s", diff) - } - }) - } -} - // TestHasNetworkChanges tests the [NodeView] method for detecting // when a node's network properties have changed. func TestHasNetworkChanges(t *testing.T) { diff --git a/hscontrol/types/preauth_key.go b/hscontrol/types/preauth_key.go index 3f789fdf1..cf8d178a5 100644 --- a/hscontrol/types/preauth_key.go +++ b/hscontrol/types/preauth_key.go @@ -3,11 +3,9 @@ package types import ( "time" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" "github.com/juanfont/headscale/hscontrol/util/zlog/zf" "github.com/rs/zerolog" "github.com/rs/zerolog/log" - "google.golang.org/protobuf/types/known/timestamppb" ) type PAKError string @@ -56,66 +54,6 @@ type PreAuthKeyNew struct { User *User // Can be nil for system-created tagged keys } -func (key *PreAuthKeyNew) Proto() *v1.PreAuthKey { - protoKey := v1.PreAuthKey{ - Id: key.ID, - Key: key.Key, - User: nil, // Will be set below if not nil - Reusable: key.Reusable, - Ephemeral: key.Ephemeral, - AclTags: key.Tags, - } - - if key.User != nil { - protoKey.User = key.User.Proto() - } - - if key.Expiration != nil { - protoKey.Expiration = timestamppb.New(*key.Expiration) - } - - if key.CreatedAt != nil { - protoKey.CreatedAt = timestamppb.New(*key.CreatedAt) - } - - return &protoKey -} - -func (key *PreAuthKey) Proto() *v1.PreAuthKey { - protoKey := v1.PreAuthKey{ - User: nil, // Will be set below if not nil - Id: key.ID, - Ephemeral: key.Ephemeral, - Reusable: key.Reusable, - Used: key.Used, - AclTags: key.Tags, - } - - if key.User != nil { - protoKey.User = key.User.Proto() - } - - // For new keys (with prefix/hash), show the prefix so users can identify the key - // For legacy keys (with plaintext key), show the full key for backwards compatibility - if masked := key.maskedPrefix(); masked != "" { - protoKey.Key = masked - } else if key.Key != "" { - // Legacy key - show full key for backwards compatibility - // TODO: Consider hiding this in a future major version - protoKey.Key = key.Key - } - - if key.Expiration != nil { - protoKey.Expiration = timestamppb.New(*key.Expiration) - } - - if key.CreatedAt != nil { - protoKey.CreatedAt = timestamppb.New(*key.CreatedAt) - } - - return &protoKey -} - // Validate checks if a pre auth key can be used. func (pak *PreAuthKey) Validate() error { if pak == nil { diff --git a/hscontrol/types/users.go b/hscontrol/types/users.go index 546f5d062..cd55d7d7e 100644 --- a/hscontrol/types/users.go +++ b/hscontrol/types/users.go @@ -11,12 +11,10 @@ import ( "strconv" "strings" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" "github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/hscontrol/util/zlog/zf" "github.com/rs/zerolog" "github.com/rs/zerolog/log" - "google.golang.org/protobuf/types/known/timestamppb" "gorm.io/gorm" "tailscale.com/tailcfg" ) @@ -182,28 +180,6 @@ func (u UserView) TailscaleUserProfile() tailcfg.UserProfile { return u.ж.TailscaleUserProfile() } -func (u *User) Proto() *v1.User { - // Use Name if set, otherwise fall back to Username() which provides - // a display-friendly identifier (Email > ProviderIdentifier > ID). - // This ensures OIDC users (who typically have empty Name) display - // their email, while CLI users retain their original Name. - name := u.Name - if name == "" { - name = u.Username() - } - - return &v1.User{ - Id: uint64(u.ID), - Name: name, - CreatedAt: timestamppb.New(u.CreatedAt), - DisplayName: u.DisplayName, - Email: u.Email, - ProviderId: u.ProviderIdentifier.String, - Provider: u.Provider, - ProfilePicUrl: u.ProfilePicURL, - } -} - // MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging. func (u *User) MarshalZerologObject(e *zerolog.Event) { if u == nil { diff --git a/proto/buf.lock b/proto/buf.lock deleted file mode 100644 index 31cd0644b..000000000 --- a/proto/buf.lock +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by buf. DO NOT EDIT. -version: v1 -deps: - - remote: buf.build - owner: googleapis - repository: googleapis - commit: 61b203b9a9164be9a834f58c37be6f62 - digest: shake256:e619113001d6e284ee8a92b1561e5d4ea89a47b28bf0410815cb2fa23914df8be9f1a6a98dcf069f5bc2d829a2cfb1ac614863be45cd4f8a5ad8606c5f200224 - - remote: buf.build - owner: grpc-ecosystem - repository: grpc-gateway - commit: 4c5ba75caaf84e928b7137ae5c18c26a - digest: shake256:e174ad9408f3e608f6157907153ffec8d310783ee354f821f57178ffbeeb8faa6bb70b41b61099c1783c82fe16210ebd1279bc9c9ee6da5cffba9f0e675b8b99 - - remote: buf.build - owner: ufoundit-dev - repository: protoc-gen-gorm - commit: e2ecbaa0d37843298104bd29fd866df8 - digest: shake256:088347669906bc49513b40d58fd7ae877769668928fca038e070732ce0f9855c03f21885b0099e0d27acf9475feca0a34dbcedac22bb374bf2cd7c1e352de56c diff --git a/proto/buf.yaml b/proto/buf.yaml deleted file mode 100644 index 7e524ba03..000000000 --- a/proto/buf.yaml +++ /dev/null @@ -1,12 +0,0 @@ -version: v1 -lint: - use: - - DEFAULT -breaking: - use: - - FILE - -deps: - - buf.build/googleapis/googleapis - - buf.build/grpc-ecosystem/grpc-gateway - - buf.build/ufoundit-dev/protoc-gen-gorm diff --git a/proto/headscale/v1/apikey.proto b/proto/headscale/v1/apikey.proto deleted file mode 100644 index 6ea0d6699..000000000 --- a/proto/headscale/v1/apikey.proto +++ /dev/null @@ -1,35 +0,0 @@ -syntax = "proto3"; -package headscale.v1; -option go_package = "github.com/juanfont/headscale/gen/go/v1"; - -import "google/protobuf/timestamp.proto"; - -message ApiKey { - uint64 id = 1; - string prefix = 2; - google.protobuf.Timestamp expiration = 3; - google.protobuf.Timestamp created_at = 4; - google.protobuf.Timestamp last_seen = 5; -} - -message CreateApiKeyRequest { google.protobuf.Timestamp expiration = 1; } - -message CreateApiKeyResponse { string api_key = 1; } - -message ExpireApiKeyRequest { - string prefix = 1; - uint64 id = 2; -} - -message ExpireApiKeyResponse {} - -message ListApiKeysRequest {} - -message ListApiKeysResponse { repeated ApiKey api_keys = 1; } - -message DeleteApiKeyRequest { - string prefix = 1; - uint64 id = 2; -} - -message DeleteApiKeyResponse {} diff --git a/proto/headscale/v1/auth.proto b/proto/headscale/v1/auth.proto deleted file mode 100644 index cc0ec95a4..000000000 --- a/proto/headscale/v1/auth.proto +++ /dev/null @@ -1,26 +0,0 @@ -syntax = "proto3"; -package headscale.v1; -option go_package = "github.com/juanfont/headscale/gen/go/v1"; - -import "headscale/v1/node.proto"; - -message AuthRegisterRequest { - string user = 1; - string auth_id = 2; -} - -message AuthRegisterResponse { - Node node = 1; -} - -message AuthApproveRequest { - string auth_id = 1; -} - -message AuthApproveResponse {} - -message AuthRejectRequest { - string auth_id = 1; -} - -message AuthRejectResponse {} diff --git a/proto/headscale/v1/device.proto b/proto/headscale/v1/device.proto deleted file mode 100644 index 6c75df887..000000000 --- a/proto/headscale/v1/device.proto +++ /dev/null @@ -1,76 +0,0 @@ -syntax = "proto3"; -package headscale.v1; -option go_package = "github.com/juanfont/headscale/gen/go/v1"; - -import "google/protobuf/timestamp.proto"; - -// This is a potential reimplementation of Tailscale's API -// https://github.com/tailscale/tailscale/blob/main/api.md - -message Latency { - float latency_ms = 1; - bool preferred = 2; -} - -message ClientSupports { - bool hair_pinning = 1; - bool ipv6 = 2; - bool pcp = 3; - bool pmp = 4; - bool udp = 5; - bool upnp = 6; -} - -message ClientConnectivity { - repeated string endpoints = 1; - string derp = 2; - bool mapping_varies_by_dest_ip = 3; - map latency = 4; - ClientSupports client_supports = 5; -} - -message GetDeviceRequest { string id = 1; } - -message GetDeviceResponse { - repeated string addresses = 1; - string id = 2; - string user = 3; - string name = 4; - string hostname = 5; - string client_version = 6; - bool update_available = 7; - string os = 8; - google.protobuf.Timestamp created = 9; - google.protobuf.Timestamp last_seen = 10; - bool key_expiry_disabled = 11; - google.protobuf.Timestamp expires = 12; - bool authorized = 13; - bool is_external = 14; - string machine_key = 15; - string node_key = 16; - bool blocks_incoming_connections = 17; - repeated string enabled_routes = 18; - repeated string advertised_routes = 19; - ClientConnectivity client_connectivity = 20; -} - -message DeleteDeviceRequest { string id = 1; } - -message DeleteDeviceResponse {} - -message GetDeviceRoutesRequest { string id = 1; } - -message GetDeviceRoutesResponse { - repeated string enabled_routes = 1; - repeated string advertised_routes = 2; -} - -message EnableDeviceRoutesRequest { - string id = 1; - repeated string routes = 2; -} - -message EnableDeviceRoutesResponse { - repeated string enabled_routes = 1; - repeated string advertised_routes = 2; -} diff --git a/proto/headscale/v1/headscale.proto b/proto/headscale/v1/headscale.proto deleted file mode 100644 index cc2958a28..000000000 --- a/proto/headscale/v1/headscale.proto +++ /dev/null @@ -1,256 +0,0 @@ -syntax = "proto3"; -package headscale.v1; -option go_package = "github.com/juanfont/headscale/gen/go/v1"; - -import "google/api/annotations.proto"; - -import "headscale/v1/user.proto"; -import "headscale/v1/preauthkey.proto"; -import "headscale/v1/node.proto"; -import "headscale/v1/apikey.proto"; -import "headscale/v1/auth.proto"; -import "headscale/v1/policy.proto"; - -service HeadscaleService { - // --- User start --- - rpc CreateUser(CreateUserRequest) returns (CreateUserResponse) { - option (google.api.http) = { - post : "/api/v1/user" - body : "*" - }; - } - - rpc RenameUser(RenameUserRequest) returns (RenameUserResponse) { - option (google.api.http) = { - post : "/api/v1/user/{old_id}/rename/{new_name}" - }; - } - - rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse) { - option (google.api.http) = { - delete : "/api/v1/user/{id}" - }; - } - - rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) { - option (google.api.http) = { - get : "/api/v1/user" - }; - } - // --- User end --- - - // --- PreAuthKeys start --- - rpc CreatePreAuthKey(CreatePreAuthKeyRequest) - returns (CreatePreAuthKeyResponse) { - option (google.api.http) = { - post : "/api/v1/preauthkey" - body : "*" - }; - } - - rpc ExpirePreAuthKey(ExpirePreAuthKeyRequest) - returns (ExpirePreAuthKeyResponse) { - option (google.api.http) = { - post : "/api/v1/preauthkey/expire" - body : "*" - }; - } - - rpc DeletePreAuthKey(DeletePreAuthKeyRequest) - returns (DeletePreAuthKeyResponse) { - option (google.api.http) = { - delete : "/api/v1/preauthkey" - }; - } - - rpc ListPreAuthKeys(ListPreAuthKeysRequest) - returns (ListPreAuthKeysResponse) { - option (google.api.http) = { - get : "/api/v1/preauthkey" - }; - } - // --- PreAuthKeys end --- - - // --- Node start --- - rpc DebugCreateNode(DebugCreateNodeRequest) - returns (DebugCreateNodeResponse) { - option (google.api.http) = { - post : "/api/v1/debug/node" - body : "*" - }; - } - - rpc GetNode(GetNodeRequest) returns (GetNodeResponse) { - option (google.api.http) = { - get : "/api/v1/node/{node_id}" - }; - } - - rpc SetTags(SetTagsRequest) returns (SetTagsResponse) { - option (google.api.http) = { - post : "/api/v1/node/{node_id}/tags" - body : "*" - }; - } - - rpc SetApprovedRoutes(SetApprovedRoutesRequest) - returns (SetApprovedRoutesResponse) { - option (google.api.http) = { - post : "/api/v1/node/{node_id}/approve_routes" - body : "*" - }; - } - - rpc RegisterNode(RegisterNodeRequest) returns (RegisterNodeResponse) { - option (google.api.http) = { - post : "/api/v1/node/register" - }; - } - - rpc DeleteNode(DeleteNodeRequest) returns (DeleteNodeResponse) { - option (google.api.http) = { - delete : "/api/v1/node/{node_id}" - }; - } - - rpc ExpireNode(ExpireNodeRequest) returns (ExpireNodeResponse) { - option (google.api.http) = { - post : "/api/v1/node/{node_id}/expire" - }; - } - - rpc RenameNode(RenameNodeRequest) returns (RenameNodeResponse) { - option (google.api.http) = { - post : "/api/v1/node/{node_id}/rename/{new_name}" - }; - } - - rpc ListNodes(ListNodesRequest) returns (ListNodesResponse) { - option (google.api.http) = { - get : "/api/v1/node" - }; - } - - rpc BackfillNodeIPs(BackfillNodeIPsRequest) - returns (BackfillNodeIPsResponse) { - option (google.api.http) = { - post : "/api/v1/node/backfillips" - }; - } - - // --- Node end --- - - // --- Auth start --- - rpc AuthRegister(AuthRegisterRequest) returns (AuthRegisterResponse) { - option (google.api.http) = { - post : "/api/v1/auth/register" - body : "*" - }; - } - - rpc AuthApprove(AuthApproveRequest) returns (AuthApproveResponse) { - option (google.api.http) = { - post : "/api/v1/auth/approve" - body : "*" - }; - } - - rpc AuthReject(AuthRejectRequest) returns (AuthRejectResponse) { - option (google.api.http) = { - post : "/api/v1/auth/reject" - body : "*" - }; - } - // --- Auth end --- - - // --- ApiKeys start --- - rpc CreateApiKey(CreateApiKeyRequest) returns (CreateApiKeyResponse) { - option (google.api.http) = { - post : "/api/v1/apikey" - body : "*" - }; - } - - rpc ExpireApiKey(ExpireApiKeyRequest) returns (ExpireApiKeyResponse) { - option (google.api.http) = { - post : "/api/v1/apikey/expire" - body : "*" - }; - } - - rpc ListApiKeys(ListApiKeysRequest) returns (ListApiKeysResponse) { - option (google.api.http) = { - get : "/api/v1/apikey" - }; - } - - rpc DeleteApiKey(DeleteApiKeyRequest) returns (DeleteApiKeyResponse) { - option (google.api.http) = { - delete : "/api/v1/apikey/{prefix}" - }; - } - // --- ApiKeys end --- - - // --- Policy start --- - rpc GetPolicy(GetPolicyRequest) returns (GetPolicyResponse) { - option (google.api.http) = { - get : "/api/v1/policy" - }; - } - - rpc SetPolicy(SetPolicyRequest) returns (SetPolicyResponse) { - option (google.api.http) = { - put : "/api/v1/policy" - body : "*" - }; - } - - rpc CheckPolicy(CheckPolicyRequest) returns (CheckPolicyResponse) { - option (google.api.http) = { - post : "/api/v1/policy/check" - body : "*" - }; - } - // --- Policy end --- - - // --- Health start --- - rpc Health(HealthRequest) returns (HealthResponse) { - option (google.api.http) = { - get : "/api/v1/health" - }; - } - // --- Health end --- - - // Implement Tailscale API - // rpc GetDevice(GetDeviceRequest) returns(GetDeviceResponse) { - // option(google.api.http) = { - // get : "/api/v1/device/{id}" - // }; - // } - - // rpc DeleteDevice(DeleteDeviceRequest) returns(DeleteDeviceResponse) { - // option(google.api.http) = { - // delete : "/api/v1/device/{id}" - // }; - // } - - // rpc GetDeviceRoutes(GetDeviceRoutesRequest) - // returns(GetDeviceRoutesResponse) { - // option(google.api.http) = { - // get : "/api/v1/device/{id}/routes" - // }; - // } - - // rpc EnableDeviceRoutes(EnableDeviceRoutesRequest) - // returns(EnableDeviceRoutesResponse) { - // option(google.api.http) = { - // post : "/api/v1/device/{id}/routes" - // }; - // } -} - -message HealthRequest {} - -message HealthResponse { - bool database_connectivity = 1; -} diff --git a/proto/headscale/v1/node.proto b/proto/headscale/v1/node.proto deleted file mode 100644 index 1a3dd0e66..000000000 --- a/proto/headscale/v1/node.proto +++ /dev/null @@ -1,144 +0,0 @@ -syntax = "proto3"; -package headscale.v1; - -import "google/protobuf/timestamp.proto"; -import "headscale/v1/preauthkey.proto"; -import "headscale/v1/user.proto"; - -option go_package = "github.com/juanfont/headscale/gen/go/v1"; - -enum RegisterMethod { - REGISTER_METHOD_UNSPECIFIED = 0; - REGISTER_METHOD_AUTH_KEY = 1; - REGISTER_METHOD_CLI = 2; - REGISTER_METHOD_OIDC = 3; -} - -message Node { - // 9: removal of last_successful_update - reserved 9; - - uint64 id = 1; - string machine_key = 2; - string node_key = 3; - string disco_key = 4; - repeated string ip_addresses = 5; - string name = 6; - User user = 7; - - google.protobuf.Timestamp last_seen = 8; - google.protobuf.Timestamp expiry = 10; - - PreAuthKey pre_auth_key = 11; - - google.protobuf.Timestamp created_at = 12; - - RegisterMethod register_method = 13; - - reserved 14 to 20; - // google.protobuf.Timestamp updated_at = 14; - // google.protobuf.Timestamp deleted_at = 15; - - // bytes host_info = 15; - // bytes endpoints = 16; - // bytes enabled_routes = 17; - - // Deprecated - // repeated string forced_tags = 18; - // repeated string invalid_tags = 19; - // repeated string valid_tags = 20; - string given_name = 21; - bool online = 22; - repeated string approved_routes = 23; - repeated string available_routes = 24; - repeated string subnet_routes = 25; - repeated string tags = 26; -} - -message RegisterNodeRequest { - string user = 1; - string key = 2; -} - -message RegisterNodeResponse { - Node node = 1; -} - -message GetNodeRequest { - uint64 node_id = 1; -} - -message GetNodeResponse { - Node node = 1; -} - -message SetTagsRequest { - uint64 node_id = 1; - repeated string tags = 2; -} - -message SetTagsResponse { - Node node = 1; -} - -message SetApprovedRoutesRequest { - uint64 node_id = 1; - repeated string routes = 2; -} - -message SetApprovedRoutesResponse { - Node node = 1; -} - -message DeleteNodeRequest { - uint64 node_id = 1; -} - -message DeleteNodeResponse {} - -message ExpireNodeRequest { - uint64 node_id = 1; - google.protobuf.Timestamp expiry = 2; - // When true, sets expiry to null (node will never expire). - bool disable_expiry = 3; -} - -message ExpireNodeResponse { - Node node = 1; -} - -message RenameNodeRequest { - uint64 node_id = 1; - string new_name = 2; -} - -message RenameNodeResponse { - Node node = 1; -} - -message ListNodesRequest { - string user = 1; -} - -message ListNodesResponse { - repeated Node nodes = 1; -} - -message DebugCreateNodeRequest { - string user = 1; - string key = 2; - string name = 3; - repeated string routes = 4; -} - -message DebugCreateNodeResponse { - Node node = 1; -} - -message BackfillNodeIPsRequest { - bool confirmed = 1; -} - -message BackfillNodeIPsResponse { - repeated string changes = 1; -} diff --git a/proto/headscale/v1/policy.proto b/proto/headscale/v1/policy.proto deleted file mode 100644 index 4a578dbe4..000000000 --- a/proto/headscale/v1/policy.proto +++ /dev/null @@ -1,23 +0,0 @@ -syntax = "proto3"; -package headscale.v1; -option go_package = "github.com/juanfont/headscale/gen/go/v1"; - -import "google/protobuf/timestamp.proto"; - -message SetPolicyRequest { string policy = 1; } - -message SetPolicyResponse { - string policy = 1; - google.protobuf.Timestamp updated_at = 2; -} - -message GetPolicyRequest {} - -message GetPolicyResponse { - string policy = 1; - google.protobuf.Timestamp updated_at = 2; -} - -message CheckPolicyRequest { string policy = 1; } - -message CheckPolicyResponse {} diff --git a/proto/headscale/v1/preauthkey.proto b/proto/headscale/v1/preauthkey.proto deleted file mode 100644 index 04e88821a..000000000 --- a/proto/headscale/v1/preauthkey.proto +++ /dev/null @@ -1,49 +0,0 @@ -syntax = "proto3"; -package headscale.v1; - -import "google/protobuf/timestamp.proto"; -import "headscale/v1/user.proto"; - -option go_package = "github.com/juanfont/headscale/gen/go/v1"; - -message PreAuthKey { - User user = 1; - uint64 id = 2; - string key = 3; - bool reusable = 4; - bool ephemeral = 5; - bool used = 6; - google.protobuf.Timestamp expiration = 7; - google.protobuf.Timestamp created_at = 8; - repeated string acl_tags = 9; -} - -message CreatePreAuthKeyRequest { - uint64 user = 1; - bool reusable = 2; - bool ephemeral = 3; - google.protobuf.Timestamp expiration = 4; - repeated string acl_tags = 5; -} - -message CreatePreAuthKeyResponse { - PreAuthKey pre_auth_key = 1; -} - -message ExpirePreAuthKeyRequest { - uint64 id = 1; -} - -message ExpirePreAuthKeyResponse {} - -message DeletePreAuthKeyRequest { - uint64 id = 1; -} - -message DeletePreAuthKeyResponse {} - -message ListPreAuthKeysRequest {} - -message ListPreAuthKeysResponse { - repeated PreAuthKey pre_auth_keys = 1; -} diff --git a/proto/headscale/v1/user.proto b/proto/headscale/v1/user.proto deleted file mode 100644 index bd71bcb1e..000000000 --- a/proto/headscale/v1/user.proto +++ /dev/null @@ -1,44 +0,0 @@ -syntax = "proto3"; -package headscale.v1; -option go_package = "github.com/juanfont/headscale/gen/go/v1"; - -import "google/protobuf/timestamp.proto"; - -message User { - uint64 id = 1; - string name = 2; - google.protobuf.Timestamp created_at = 3; - string display_name = 4; - string email = 5; - string provider_id = 6; - string provider = 7; - string profile_pic_url = 8; -} - -message CreateUserRequest { - string name = 1; - string display_name = 2; - string email = 3; - string picture_url = 4; -} - -message CreateUserResponse { User user = 1; } - -message RenameUserRequest { - uint64 old_id = 1; - string new_name = 2; -} - -message RenameUserResponse { User user = 1; } - -message DeleteUserRequest { uint64 id = 1; } - -message DeleteUserResponse {} - -message ListUsersRequest { - uint64 id = 1; - string name = 2; - string email = 3; -} - -message ListUsersResponse { repeated User users = 1; } From 72adc5ff2a9d83a4792a94b861f707c5f03e2bb6 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 19 Jun 2026 06:14:55 +0000 Subject: [PATCH 062/127] docs: add changelog and refresh the API reference for the v1 API --- CHANGELOG.md | 28 ++++++++++++++++++++++++++ cmd/dev/README.md | 3 +-- docs/about/faq.md | 6 +++--- docs/ref/api.md | 29 ++++++++++++--------------- docs/ref/integration/reverse-proxy.md | 5 +---- docs/setup/requirements.md | 3 --- 6 files changed, 46 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6630e6c3a..82f8251c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ **Minimum supported Tailscale client version: v1.xx.0** +### v1 REST API replaced; gRPC and Protobuf removed + +The v1 REST API now provides an OpenAPI 3.1 specification at +`/api/v1/openapi.yaml`, with interactive documentation at `/api/v1/docs`. This +replaces the Swagger 2.0 document and the `/swagger` UI. The Protobuf, gRPC and +grpc-gateway stack behind it is gone, and the `headscale` CLI now talks to the +HTTP API directly. + +[#3324](https://github.com/juanfont/headscale/pull/3324) + +### BREAKING + +#### API + +- The gRPC API is removed; all programmatic access now goes through the HTTP API at `/api/v1` [#3324](https://github.com/juanfont/headscale/pull/3324) +- API errors are now RFC 7807 `application/problem+json`, including authentication failures, instead of the previous gRPC-status JSON shape [#3324](https://github.com/juanfont/headscale/pull/3324) +- Errors that previously returned HTTP 500 — unknown users or nodes, malformed input, duplicate names — now return the correct 404, 400 or 409 [#3324](https://github.com/juanfont/headscale/pull/3324) +- The OpenAPI document is OpenAPI 3.1 at `/api/v1/openapi.yaml` (docs at `/api/v1/docs`), replacing Swagger 2.0 at `/swagger` [#3324](https://github.com/juanfont/headscale/pull/3324) + +#### CLI + +- `--output json` / `--output yaml` now emit the API's shape — camelCase fields, string-encoded IDs, RFC3339 timestamps — instead of the old Protobuf encoding [#3324](https://github.com/juanfont/headscale/pull/3324) +- `headscale policy` renames the database-bypass flag from `--bypass-grpc-and-access-database-directly` to `--bypass-server-and-access-database-directly` [#3324](https://github.com/juanfont/headscale/pull/3324) + +### Changes + +- Expiring or deleting a non-existent pre-auth key now returns an error instead of silently succeeding [#3324](https://github.com/juanfont/headscale/pull/3324) + ## 0.29.1 (2026-06-18) **Minimum supported Tailscale client version: v1.80.0** diff --git a/cmd/dev/README.md b/cmd/dev/README.md index 46da415db..f703f4421 100644 --- a/cmd/dev/README.md +++ b/cmd/dev/README.md @@ -42,8 +42,7 @@ go tool mts node1 status | `--port` | 8080 | Headscale listen port | | `--keep` | false | Keep state directory on exit | -The metrics/debug port is `port + 1010` (default 9090) and the gRPC -port is `port + 42363` (default 50443). +The metrics/debug port is `port + 1010` (default 9090). ## What it does diff --git a/docs/about/faq.md b/docs/about/faq.md index 09792653d..25cdb1123 100644 --- a/docs/about/faq.md +++ b/docs/about/faq.md @@ -152,15 +152,15 @@ See also . Headscale checks if the policy is valid during startup and refuses to start if it detects an error. The error message indicates which part of the policy is invalid. Follow these steps to fix your policy: -- Dump the policy to a file: `headscale policy get --bypass-grpc-and-access-database-directly > policy.json` +- Dump the policy to a file: `headscale policy get --bypass-server-and-access-database-directly > policy.json` - Edit and fixup `policy.json`. Use the command `headscale policy check --file policy.json` to validate the policy. -- Load the modified policy: `headscale policy set --bypass-grpc-and-access-database-directly --file policy.json` +- Load the modified policy: `headscale policy set --bypass-server-and-access-database-directly --file policy.json` - Start Headscale as usual. !!! warning "Full server configuration required" The above commands to get/set the policy require a complete server configuration file including database settings. A - minimal config to [control Headscale via remote CLI](../ref/api.md#grpc) is not sufficient. You may use + minimal config to [control Headscale via remote CLI](../ref/api.md#remote-control) is not sufficient. You may use `headscale -c /path/to/config.yaml` to specify the path to an alternative configuration file. ## How can I migrate back to the recommended IP prefixes? diff --git a/docs/ref/api.md b/docs/ref/api.md index 4337da137..5e3677f83 100644 --- a/docs/ref/api.md +++ b/docs/ref/api.md @@ -1,10 +1,10 @@ # API -Headscale provides a [HTTP REST API](#rest-api) and a [gRPC interface](#grpc) which may be used to integrate a [web -interface](integration/web-ui.md), [remote control Headscale](#setup-remote-control) or provide a base for custom +Headscale provides a [HTTP REST API](#rest-api) which may be used to integrate a [web +interface](integration/web-ui.md), [remote control Headscale](#remote-control) or provide a base for custom integration and tooling. -Both interfaces require a valid API key before use. To create an API key, log into your Headscale server and generate +The API requires a valid API key before use. To create an API key, log into your Headscale server and generate one with the default expiration of 90 days: ```shell @@ -58,15 +58,14 @@ Headscale server at `/swagger` for details. https://headscale.example.com/api/v1/auth/register ``` -## gRPC +## Remote control -The gRPC interface can be used to control a Headscale instance from a remote machine with the `headscale` binary. +The `headscale` binary can control a Headscale instance from a remote machine over the HTTP API. ### Prerequisite - A workstation to run `headscale` (any supported platform, e.g. Linux). -- A Headscale server with gRPC enabled. -- Connections to the gRPC port (default: `50443`) are allowed. +- The Headscale server reachable over HTTP(S). - Remote access requires an encrypted connection via TLS. - An [API key](#api) to authenticate with the Headscale server. @@ -88,19 +87,20 @@ The gRPC interface can be used to control a Headscale instance from a remote mac ```yaml title="config.yaml" cli: - address: : + address: api_key: ``` === "Environment variables" ```shell - export HEADSCALE_CLI_ADDRESS=":" + export HEADSCALE_CLI_ADDRESS="" export HEADSCALE_CLI_API_KEY="" ``` - This instructs the `headscale` binary to connect to a remote instance at `:`, instead of - connecting to the local instance. + This instructs the `headscale` binary to connect to a remote instance at `` (e.g. + `https://headscale.example.com`), instead of connecting to the local instance. A bare host without a scheme is + assumed to be `https`. 1. Test the connection by listing all nodes: @@ -113,15 +113,12 @@ The gRPC interface can be used to control a Headscale instance from a remote mac ### Behind a proxy -It's possible to run the gRPC remote endpoint behind a reverse proxy, like Nginx, and have it run on the _same_ port as Headscale. - -While this is _not a supported_ feature, an example on how this can be set up on -[NixOS is shown here](https://github.com/kradalby/dotfiles/blob/4489cdbb19cddfbfae82cd70448a38fde5a76711/machines/headscale.oracldn/headscale.nix#L61-L91). +The remote CLI uses the same HTTP API as everything else, so it works through the reverse proxy already in front of +Headscale with no extra setup. ### Troubleshooting - Make sure you have the _same_ Headscale version on your server and workstation. -- Ensure that connections to the gRPC port are allowed. - Verify that your TLS certificate is valid and trusted. - If you don't have access to a trusted certificate (e.g. from Let's Encrypt), either: - Add your self-signed certificate to the trust store of your OS _or_ diff --git a/docs/ref/integration/reverse-proxy.md b/docs/ref/integration/reverse-proxy.md index 723ef6342..2729786a5 100644 --- a/docs/ref/integration/reverse-proxy.md +++ b/docs/ref/integration/reverse-proxy.md @@ -69,7 +69,6 @@ on inbound requests with sanitized values. Headscale picks the first valid IP ad - A reverse proxy adds another layer of complexity that needs to be able to handle the [Tailscale Control Protocol](#websocket) properly. Be sure to test your setup without a reverse proxy before raising an issue. - STUN (used along with the [embedded DERP server](../derp.md)) requires udp/3478 to be served publicly. -- [gRPC](../api.md#grpc) (used to remote control Headscale) may not be proxied. ## Reverse proxy specific configuration @@ -84,14 +83,12 @@ is [assumed](../../setup/requirements.md): - Service for Tailscale clients is served via HTTPS on port 443. - The reverse proxy redirects HTTP to HTTPS and is terminating TLS. - Both Headscale and the reverse proxy are running on the same host. -- [Metrics](../debug.md#metrics-and-debug-endpoint) and [gRPC](../api.md#grpc) are not proxied, those are available via - localhost. +- [Metrics](../debug.md#metrics-and-debug-endpoint) are not proxied, those are available via localhost. ```yaml title="config.yaml" hl_lines="1" server_url: https:// listen_addr: 127.0.0.1:8080 metrics_listen_addr: 127.0.0.1:9090 -grpc_listen_addr: 127.0.0.1:50443 trusted_proxies: - 127.0.0.1/32 - ::1/128 diff --git a/docs/setup/requirements.md b/docs/setup/requirements.md index 6c11386c3..da84ae856 100644 --- a/docs/setup/requirements.md +++ b/docs/setup/requirements.md @@ -26,9 +26,6 @@ The ports in use vary with the intended scenario and enabled features. Some of t - udp/3478 - Expose publicly: yes - STUN, required if the [embedded DERP server](../ref/derp.md) is enabled -- tcp/50443 - - Expose publicly: yes - - Only required if the gRPC interface is used to [remote-control Headscale](../ref/api.md#grpc). - tcp/9090 - Expose publicly: no - [Metrics and debug endpoint](../ref/debug.md#metrics-and-debug-endpoint) From 15781ed6d84022f095b48b3a37b44c7fbd3031ca Mon Sep 17 00:00:00 2001 From: Florian Preinstorfer Date: Fri, 19 Jun 2026 17:10:34 +0200 Subject: [PATCH 063/127] Fixup redirect for remote-control --- mkdocs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index 1d08b8330..c360bd407 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -87,8 +87,8 @@ plugins: iOS-client.md: usage/connect/apple.md#ios oidc.md: ref/oidc.md ref/exit-node.md: ref/routes.md - ref/remote-cli.md: ref/api.md#grpc - remote-cli.md: ref/api.md#grpc + ref/remote-cli.md: ref/api.md#remote-control + remote-cli.md: ref/api.md#remote-control reverse-proxy.md: ref/integration/reverse-proxy.md tls.md: ref/tls.md web-ui.md: ref/integration/web-ui.md From 88234d40460059cfce4f3146fc54d923831f7287 Mon Sep 17 00:00:00 2001 From: Florian Preinstorfer Date: Fri, 19 Jun 2026 17:22:02 +0200 Subject: [PATCH 064/127] Replace /swagger with /api/v1/docs --- docs/ref/api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ref/api.md b/docs/ref/api.md index 5e3677f83..f483b0656 100644 --- a/docs/ref/api.md +++ b/docs/ref/api.md @@ -29,12 +29,12 @@ headscale apikeys expire --prefix ## REST API - API endpoint: `/api/v1`, e.g. `https://headscale.example.com/api/v1` -- Documentation: `/swagger`, e.g. `https://headscale.example.com/swagger` +- Documentation: `/api/v1/docs`, e.g. `https://headscale.example.com/api/v1/docs` - Headscale Version: `/version`, e.g. `https://headscale.example.com/version` - Authenticate using HTTP Bearer authentication by sending the [API key](#api) with the HTTP `Authorization: Bearer ` header. Start by [creating an API key](#api) and test it with the examples below. Read the API documentation provided by your -Headscale server at `/swagger` for details. +Headscale server at `/api/v1/docs` for details. === "Get details for all users" From d7150755d4527be5c9326b20ac247a40ada81efa Mon Sep 17 00:00:00 2001 From: Florian Preinstorfer Date: Fri, 19 Jun 2026 17:27:42 +0200 Subject: [PATCH 065/127] TLS is not strictly required --- docs/ref/api.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/ref/api.md b/docs/ref/api.md index f483b0656..cd5421d24 100644 --- a/docs/ref/api.md +++ b/docs/ref/api.md @@ -66,7 +66,6 @@ The `headscale` binary can control a Headscale instance from a remote machine ov - A workstation to run `headscale` (any supported platform, e.g. Linux). - The Headscale server reachable over HTTP(S). -- Remote access requires an encrypted connection via TLS. - An [API key](#api) to authenticate with the Headscale server. ### Setup remote control From 49d6949639645a2b98d6dc1db7b111251658340e Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 19 Jun 2026 16:52:19 +0000 Subject: [PATCH 066/127] cli: stop doubling the scheme on a remote CLI address newRemoteClient prepended "https://" unconditionally, so HEADSCALE_CLI_ADDRESS=https://host became https://https://host and dialed the host "https". Default a bare host to https, keep an explicit scheme. --- cmd/headscale/cli/utils.go | 14 ++++++++++++- cmd/headscale/cli/utils_test.go | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 cmd/headscale/cli/utils_test.go diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go index 8b8c67f7a..9a5c311c2 100644 --- a/cmd/headscale/cli/utils.go +++ b/cmd/headscale/cli/utils.go @@ -241,6 +241,18 @@ func dialHeadscaleSocket(ctx context.Context, socketPath string) (net.Conn, erro }, backoff.WithBackOff(b)) } +// clientBaseURL turns a configured CLI address into a client base URL. A bare +// host[:port] (the historical form) defaults to https; an address that already +// carries a scheme is used as-is, so an explicit http:// or https:// is honoured +// rather than doubled into https://https://... +func clientBaseURL(address string) string { + if strings.Contains(address, "://") { + return address + } + + return "https://" + address +} + // newRemoteClient builds an API client for a remote Headscale over HTTPS, // honouring insecure (skip TLS verification) and injecting the API key as a // bearer token on every request. @@ -257,7 +269,7 @@ func newRemoteClient(address, apiKey string, insecure bool) (*clientv1.ClientWit httpClient := &http.Client{Transport: transport} return clientv1.NewClientWithResponses( - "https://"+address, + clientBaseURL(address), clientv1.WithHTTPClient(httpClient), clientv1.WithRequestEditorFn(func(_ context.Context, req *http.Request) error { req.Header.Set("Authorization", "Bearer "+apiKey) diff --git a/cmd/headscale/cli/utils_test.go b/cmd/headscale/cli/utils_test.go new file mode 100644 index 000000000..845d676c5 --- /dev/null +++ b/cmd/headscale/cli/utils_test.go @@ -0,0 +1,35 @@ +package cli + +import "testing" + +func TestClientBaseURL(t *testing.T) { + tests := []struct { + name string + address string + want string + }{ + { + name: "bare host defaults to https", + address: "headscale.example.com:50443", + want: "https://headscale.example.com:50443", + }, + { + name: "explicit https scheme is kept", + address: "https://headscale.example.com", + want: "https://headscale.example.com", + }, + { + name: "explicit http scheme is kept", + address: "http://localhost:8080", + want: "http://localhost:8080", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := clientBaseURL(tt.address); got != tt.want { + t.Errorf("clientBaseURL(%q) = %q, want %q", tt.address, got, tt.want) + } + }) + } +} From 7af39a67984f591de7c152aada864d832e3f232b Mon Sep 17 00:00:00 2001 From: Florian Preinstorfer Date: Fri, 19 Jun 2026 19:23:06 +0200 Subject: [PATCH 067/127] Add headscale-panel to webui docs --- docs/ref/integration/web-ui.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/ref/integration/web-ui.md b/docs/ref/integration/web-ui.md index 8f00695a8..12aa6d373 100644 --- a/docs/ref/integration/web-ui.md +++ b/docs/ref/integration/web-ui.md @@ -23,5 +23,7 @@ Headscale doesn't provide a built-in web interface but users may pick one from t - [HeadControl](https://github.com/ahmadzip/HeadControl) - Minimal Headscale admin dashboard, built with Go and HTMX - [Headscale Manager](https://github.com/hkdone/headscalemanager) - Headscale UI for Android - [Headscale UI](https://github.com/MunMunMiao/headscale-ui) - Headscale UI online and Self-hosting +- [Headscale Panel](https://github.com/headscale-panel/panel) - A modern Headscale management panel with a clean, + network-operations-focused UI You can ask for support on our [Discord server](https://discord.gg/c84AZQhmpx) in the "web-interfaces" channel. From 3c504af2be8fe1ce10f0ff1c9503fd41215c35cc Mon Sep 17 00:00:00 2001 From: Florian Preinstorfer Date: Fri, 19 Jun 2026 20:49:40 +0200 Subject: [PATCH 068/127] Fix placeholder in curl example --- docs/ref/api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ref/api.md b/docs/ref/api.md index cd5421d24..8f492dfd3 100644 --- a/docs/ref/api.md +++ b/docs/ref/api.md @@ -54,7 +54,7 @@ Headscale server at `/api/v1/docs` for details. ```console curl -H "Authorization: Bearer " \ - --json '{"user": "", "authId": "AUTH_ID>"}' \ + --json '{"user": "", "authId": ""}' \ https://headscale.example.com/api/v1/auth/register ``` From 07b3a8ebd3be75731c6680e06b719944d74999d5 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 08:06:08 +0000 Subject: [PATCH 069/127] ci: migrate Nix CI to official installer and hestia cache Swap nix-quick-install-action -> nix-installer-action and cache-nix-action -> Mic92/hestia/action across every Nix workflow, drop the now-unneeded cache keys, and run devShell tooling through a defaults.run.shell. Add gc.yml to trim the hestia cache daily. --- .github/workflows/build.yml | 24 +++++++-------- .github/workflows/check-generated.yml | 13 +++++---- .github/workflows/check-tests.yaml | 14 ++++----- .github/workflows/container-main.yml | 26 +++++++---------- .github/workflows/gc.yml | 39 +++++++++++++++++++++++++ .github/workflows/lint.yml | 24 +++++++-------- .github/workflows/nix-module-test.yml | 8 ++--- .github/workflows/release.yml | 14 ++++----- .github/workflows/test-integration.yaml | 31 +++++++++----------- .github/workflows/test.yml | 14 ++++----- 10 files changed, 114 insertions(+), 93 deletions(-) create mode 100644 .github/workflows/gc.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4d33b8b1c..81246706c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,6 +10,10 @@ concurrency: group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} cancel-in-progress: true +defaults: + run: + shell: nix develop --fallback --command bash -e {0} + jobs: build-nix: runs-on: ubuntu-latest @@ -29,20 +33,16 @@ jobs: - '**/*.go' - 'integration_test/' - 'config-example.yaml' - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main if: steps.changed-files.outputs.files == 'true' - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main if: steps.changed-files.outputs.files == 'true' - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', - '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} - name: Check vendor hash id: vendorhash if: steps.changed-files.outputs.files == 'true' run: | - nix develop --fallback --command -- go run ./cmd/vendorhash check | tee check-result + go run ./cmd/vendorhash check | tee check-result { grep '^expected_sri=' check-result || true grep '^actual_sri=' check-result || true @@ -81,17 +81,13 @@ jobs: - "GOARCH=amd64 GOOS=darwin" steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', - '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main - name: Run go cross compile env: CGO_ENABLED: 0 - run: env ${{ matrix.env }} nix develop --fallback --command -- go build -o "headscale" + run: env ${{ matrix.env }} go build -o "headscale" ./cmd/headscale - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: diff --git a/.github/workflows/check-generated.yml b/.github/workflows/check-generated.yml index 4b463fccd..fffc3da04 100644 --- a/.github/workflows/check-generated.yml +++ b/.github/workflows/check-generated.yml @@ -12,6 +12,10 @@ concurrency: group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} cancel-in-progress: true +defaults: + run: + shell: nix develop --fallback --command bash -e {0} + jobs: check-generated: runs-on: ubuntu-latest @@ -29,17 +33,14 @@ jobs: - 'go.*' - '**/*.go' - 'tools/**' - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main if: steps.changed-files.outputs.files == 'true' - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main if: steps.changed-files.outputs.files == 'true' - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} - name: Run make generate if: steps.changed-files.outputs.files == 'true' - run: nix develop --fallback --command -- make generate + run: make generate - name: Check for uncommitted changes if: steps.changed-files.outputs.files == 'true' diff --git a/.github/workflows/check-tests.yaml b/.github/workflows/check-tests.yaml index df5b857c3..b73011732 100644 --- a/.github/workflows/check-tests.yaml +++ b/.github/workflows/check-tests.yaml @@ -6,6 +6,10 @@ concurrency: group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} cancel-in-progress: true +defaults: + run: + shell: nix develop --fallback --command bash -e {0} + jobs: check-tests: runs-on: ubuntu-latest @@ -24,19 +28,15 @@ jobs: - '**/*.go' - 'integration_test/' - 'config-example.yaml' - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main if: steps.changed-files.outputs.files == 'true' - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main if: steps.changed-files.outputs.files == 'true' - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', - '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} - name: Generate and check integration tests if: steps.changed-files.outputs.files == 'true' run: | - nix develop --fallback --command bash -c "cd .github/workflows && go generate" + (cd .github/workflows && go generate) git diff --exit-code .github/workflows/test-integration.yaml - name: Show missing tests diff --git a/.github/workflows/container-main.yml b/.github/workflows/container-main.yml index 71710e9f1..e2ebe6fc0 100644 --- a/.github/workflows/container-main.yml +++ b/.github/workflows/container-main.yml @@ -16,6 +16,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.sha }} cancel-in-progress: true +defaults: + run: + shell: nix develop --fallback --command bash -e {0} + jobs: container: if: github.repository == 'juanfont/headscale' @@ -40,12 +44,8 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', - '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main - name: Set commit timestamp run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> $GITHUB_ENV @@ -56,7 +56,7 @@ jobs: KO_DEFAULTBASEIMAGE: gcr.io/distroless/base-debian13 CGO_ENABLED: "0" run: | - nix develop --fallback --command -- ko build \ + ko build \ --bare \ --platform=linux/amd64,linux/arm64 \ --tags=main-${GITHUB_SHA::7},development \ @@ -68,7 +68,7 @@ jobs: KO_DEFAULTBASEIMAGE: gcr.io/distroless/base-debian13 CGO_ENABLED: "0" run: | - nix develop --fallback --command -- ko build \ + ko build \ --bare \ --platform=linux/amd64,linux/arm64 \ --tags=main-${GITHUB_SHA::7},development \ @@ -92,19 +92,15 @@ jobs: - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', - '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main - name: Build binary env: CGO_ENABLED: "0" GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} - run: nix develop --fallback --command -- go build -o headscale ./cmd/headscale + run: go build -o headscale ./cmd/headscale - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: diff --git a/.github/workflows/gc.yml b/.github/workflows/gc.yml new file mode 100644 index 000000000..9df7a0370 --- /dev/null +++ b/.github/workflows/gc.yml @@ -0,0 +1,39 @@ +name: Cache GC + +# Garbage collection for the hestia binary cache. Must run on the default +# branch: a PR job's cache scope is read-only towards the default branch and +# dies with the PR, but the default-branch scope grows forever without GC. + +concurrency: + group: hestia-gc + cancel-in-progress: false + +on: + schedule: + # Daily, off-peak (UTC). + - cron: "23 3 * * *" + workflow_dispatch: + inputs: + dry-run: + description: Plan only; do not repack, touch, or delete anything. + type: boolean + default: false + +permissions: + contents: read + +jobs: + gc: + runs-on: ubuntu-latest + permissions: + # REST cache deletes need actions:write. + actions: write + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main + - name: Run garbage collection + run: '"${HESTIA_BIN}" gc ${{ inputs.dry-run && ''--dry-run'' || '''' }}' + env: + GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ed809ac59..617355d6d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,6 +6,10 @@ concurrency: group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} cancel-in-progress: true +defaults: + run: + shell: nix develop --fallback --command bash -e {0} + jobs: golangci-lint: runs-on: ubuntu-latest @@ -24,18 +28,14 @@ jobs: - '**/*.go' - 'integration_test/' - 'config-example.yaml' - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main if: steps.changed-files.outputs.files == 'true' - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main if: steps.changed-files.outputs.files == 'true' - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', - '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} - name: golangci-lint if: steps.changed-files.outputs.files == 'true' - run: nix develop --fallback --command -- golangci-lint run + run: golangci-lint run --new-from-rev=${{github.event.pull_request.base.sha}} --output.text.path=stdout --output.text.print-linter-name @@ -64,16 +64,12 @@ jobs: - '**/*.css' - '**/*.scss' - '**/*.html' - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main if: steps.changed-files.outputs.files == 'true' - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main if: steps.changed-files.outputs.files == 'true' - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', - '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} - name: Prettify code if: steps.changed-files.outputs.files == 'true' - run: nix develop --fallback --command -- prettier --no-error-on-unmatched-pattern + run: prettier --no-error-on-unmatched-pattern --ignore-unknown --check **/*.{ts,js,md,yaml,yml,sass,css,scss,html} diff --git a/.github/workflows/nix-module-test.yml b/.github/workflows/nix-module-test.yml index 03fe11911..1a7fbcc85 100644 --- a/.github/workflows/nix-module-test.yml +++ b/.github/workflows/nix-module-test.yml @@ -38,15 +38,11 @@ jobs: - 'cmd/**' - 'hscontrol/**' - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true' - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true' - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', - '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} - name: Run NixOS module tests if: steps.changed-files.outputs.nix == 'true' || steps.changed-files.outputs.go == 'true' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d8c172b3..349ad53d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,10 @@ on: - "*" # triggers only if push new tag version workflow_dispatch: +defaults: + run: + shell: nix develop --fallback --command bash -e {0} + jobs: goreleaser: if: github.repository == 'juanfont/headscale' @@ -30,14 +34,10 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', - '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main - name: Run goreleaser - run: nix develop --fallback --command -- goreleaser release --clean + run: goreleaser release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index bfa13e340..bfdb3f574 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -6,6 +6,9 @@ on: [pull_request] concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true +defaults: + run: + shell: nix develop --fallback --command bash -e {0} jobs: # build: Builds binaries and Docker images once, uploads as artifacts for reuse. # build-postgres: Pulls postgres image separately to avoid Docker Hub rate limits. @@ -36,23 +39,18 @@ jobs: - '.github/workflows/test-integration.yaml' - '.github/workflows/integration-test-template.yml' - 'Dockerfile.*' - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main if: steps.changed-files.outputs.files == 'true' - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main if: steps.changed-files.outputs.files == 'true' - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} - name: Build binaries and warm Go cache if: steps.changed-files.outputs.files == 'true' run: | # Build all Go binaries in one nix shell to maximize cache reuse - nix develop --fallback --command -- bash -c ' - go build -o hi ./cmd/hi - CGO_ENABLED=0 GOOS=linux go build -o headscale ./cmd/headscale - # Build integration test binary to warm the cache with all dependencies - go test -c ./integration -o /dev/null 2>/dev/null || true - ' + go build -o hi ./cmd/hi + CGO_ENABLED=0 GOOS=linux go build -o headscale ./cmd/headscale + # Build integration test binary to warm the cache with all dependencies + go test -c ./integration -o /dev/null 2>/dev/null || true - name: Upload hi binary if: steps.changed-files.outputs.files == 'true' uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 @@ -128,6 +126,7 @@ jobs: if: needs.build.outputs.files-changed == 'true' steps: - name: Force overlay2 storage driver + shell: bash run: | sudo mkdir -p /etc/docker echo '{"storage-driver":"overlay2"}' | sudo tee /etc/docker/daemon.json @@ -143,6 +142,7 @@ jobs: username: ${{ env.DOCKERHUB_USERNAME }} password: ${{ env.DOCKERHUB_TOKEN }} - name: Pull and save postgres image + shell: bash run: | docker pull postgres:latest docker tag postgres:latest postgres:${{ github.sha }} @@ -159,11 +159,8 @@ jobs: if: needs.build.outputs.files-changed == 'true' steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main - name: Force overlay2 storage driver run: | sudo mkdir -p /etc/docker @@ -182,7 +179,7 @@ jobs: - name: List Tailscale versions to pre-pull id: versions run: | - versions=$(nix develop --fallback --command go run ./cmd/hi list-versions --set=must --exclude=head) + versions=$(go run ./cmd/hi list-versions --set=must --exclude=head) echo "versions=${versions}" >> "$GITHUB_OUTPUT" echo "Pre-pulling: ${versions}" - name: Pull Tailscale images diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 911f00571..dadacddce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,10 @@ concurrency: group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} cancel-in-progress: true +defaults: + run: + shell: nix develop --fallback --command bash -e {0} + jobs: test: runs-on: ubuntu-latest @@ -27,14 +31,10 @@ jobs: - 'integration_test/' - 'config-example.yaml' - - uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main if: steps.changed-files.outputs.files == 'true' - - uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3 + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main if: steps.changed-files.outputs.files == 'true' - with: - primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', - '**/flake.lock') }} - restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} - name: Run tests if: steps.changed-files.outputs.files == 'true' @@ -44,4 +44,4 @@ jobs: # some of the database migration tests. LC_ALL: "en_US.UTF-8" LC_CTYPE: "en_US.UTF-8" - run: nix develop --fallback --command -- gotestsum + run: gotestsum From 2632006f5fdb33afaadd58268f4f5282e4797e28 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 08:06:08 +0000 Subject: [PATCH 070/127] ci: drop the DeterminateSystems flake.lock update cron Remove update-flake.yml; flake.lock is no longer auto-bumped by CI. --- .github/workflows/update-flake.yml | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 .github/workflows/update-flake.yml diff --git a/.github/workflows/update-flake.yml b/.github/workflows/update-flake.yml deleted file mode 100644 index 1c8b262ed..000000000 --- a/.github/workflows/update-flake.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: update-flake-lock -on: - workflow_dispatch: # allows manual triggering - schedule: - - cron: "0 0 * * 0" # runs weekly on Sunday at 00:00 - -jobs: - lockfile: - if: github.repository == 'juanfont/headscale' - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Install Nix - uses: DeterminateSystems/nix-installer-action@21a544727d0c62386e78b4befe52d19ad12692e3 # v17 - - name: Update flake.lock - uses: DeterminateSystems/update-flake-lock@428c2b58a4b7414dabd372acb6a03dba1084d3ab # v25 - with: - pr-title: "Update flake.lock" From 8f314797ce8d558e22ab89d061838f40360a2df7 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 10:28:03 +0000 Subject: [PATCH 071/127] all: fix full-tree golangci-lint findings The new full-tree golangci-lint check reports issues the --new-from-rev diff lint hid: nine wsl_v5 whitespace gaps, a prealloc, and an unparam (setCSRFCookie never errored, so drop the return and update callers). gocyclo on the central UpdateNodeFromMapRequest and an SA1019 NetMap deprecation in an integration helper are suppressed with reasons. --- cmd/headscale/cli/nodes.go | 1 + hscontrol/derp/derp.go | 1 + hscontrol/derp/server/derp_server.go | 1 + hscontrol/mapper/node_conn.go | 1 + hscontrol/oidc.go | 16 ++++------------ hscontrol/oidc_test.go | 3 +-- hscontrol/servertest/client.go | 1 + hscontrol/state/state.go | 3 ++- hscontrol/types/common.go | 1 + hscontrol/util/dns.go | 2 +- integration/dockertestutil/network.go | 1 + integration/hsic/hsic.go | 1 + integration/tsic/tsic.go | 2 +- 13 files changed, 17 insertions(+), 17 deletions(-) diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go index 671a68ccd..9dc798a3a 100644 --- a/cmd/headscale/cli/nodes.go +++ b/cmd/headscale/cli/nodes.go @@ -146,6 +146,7 @@ var listNodeRoutesCmd = &cobra.Command{ } nodes := resp.JSON200.Nodes + if identifier != 0 { idStr := strconv.FormatUint(identifier, util.Base10) for _, node := range nodes { diff --git a/hscontrol/derp/derp.go b/hscontrol/derp/derp.go index 909c4cb55..beeb66ada 100644 --- a/hscontrol/derp/derp.go +++ b/hscontrol/derp/derp.go @@ -28,6 +28,7 @@ func loadDERPMapFromPath(path string) (*tailcfg.DERPMap, error) { } var derpMap tailcfg.DERPMap + err = yaml.Unmarshal(b, &derpMap) return &derpMap, err diff --git a/hscontrol/derp/server/derp_server.go b/hscontrol/derp/server/derp_server.go index 0cb8339b6..e164c608e 100644 --- a/hscontrol/derp/server/derp_server.go +++ b/hscontrol/derp/server/derp_server.go @@ -80,6 +80,7 @@ func (d *DERPServer) GenerateRegion() (tailcfg.DERPRegion, error) { host, portStr, err := net.SplitHostPort(serverURL.Host) var port int + if err != nil { host = serverURL.Host if serverURL.Scheme == "https" { diff --git a/hscontrol/mapper/node_conn.go b/hscontrol/mapper/node_conn.go index 27a43a13b..97d024a85 100644 --- a/hscontrol/mapper/node_conn.go +++ b/hscontrol/mapper/node_conn.go @@ -309,6 +309,7 @@ func (mc *multiChannelNodeConn) send(data *tailcfg.MapResponse) error { snapshot = append(snapshot, conn) } } + mc.mutex.RUnlock() if len(snapshot) == 0 { diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index a4fb06f10..fa2c38097 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -156,18 +156,10 @@ func (a *AuthProviderOIDC) authHandler( } // Set the state and nonce cookies to protect against CSRF attacks - state, err := setCSRFCookie(writer, req, "state") - if err != nil { - httpUserError(writer, err) - return - } + state := setCSRFCookie(writer, req, "state") // Set the state and nonce cookies to protect against CSRF attacks - nonce, err := setCSRFCookie(writer, req, "nonce") - if err != nil { - httpUserError(writer, err) - return - } + nonce := setCSRFCookie(writer, req, "nonce") registrationInfo := AuthInfo{ AuthID: authID, @@ -928,7 +920,7 @@ func getCookieName(baseName, value string) string { return fmt.Sprintf("%s_%s", baseName, value[:n]) } -func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) (string, error) { +func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) string { val := rands.HexString(64) //nolint:gosec // G124: Secure set conditionally via r.TLS; HttpOnly + SameSite set below @@ -948,5 +940,5 @@ func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) (string, } http.SetCookie(w, c) - return val, nil + return val } diff --git a/hscontrol/oidc_test.go b/hscontrol/oidc_test.go index 0472f01cf..2c5814078 100644 --- a/hscontrol/oidc_test.go +++ b/hscontrol/oidc_test.go @@ -186,8 +186,7 @@ func TestSetCSRFCookieSameSite(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/abcdef0123456789", nil) - _, err := setCSRFCookie(w, r, "state") - require.NoError(t, err) + setCSRFCookie(w, r, "state") cookies := w.Result().Cookies() require.Len(t, cookies, 1) diff --git a/hscontrol/servertest/client.go b/hscontrol/servertest/client.go index 54d870040..67232dd33 100644 --- a/hscontrol/servertest/client.go +++ b/hscontrol/servertest/client.go @@ -204,6 +204,7 @@ func (c *TestClient) startPollLoop() { go func() { defer close(c.pollDone) + _ = c.direct.PollNetMap(c.pollCtx, c) }() } diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index 1069d8de8..ab4fe9978 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -2432,6 +2432,7 @@ func (s *State) HandleNodeFromPreAuthKey( node.User = nil node.Expiry = nil } + node.AuthKey = pak node.AuthKeyID = &pak.ID // Do NOT reset IsOnline here. Online status is managed exclusively by @@ -2793,7 +2794,7 @@ func isAutoDerivedGivenName(given, hostname string) bool { // - node.PeerChangeFromMapRequest // - node.ApplyPeerChange // - logTracePeerChange in poll.go. -func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest) (change.Change, error) { +func (s *State) UpdateNodeFromMapRequest(id types.NodeID, req tailcfg.MapRequest) (change.Change, error) { //nolint:gocyclo // central map-request reconciliation; the sequential branch flow reads clearer as one function than split across helpers log.Trace(). Caller(). Uint64(zf.NodeID, id.Uint64()). diff --git a/hscontrol/types/common.go b/hscontrol/types/common.go index 86e42f282..e96aef9be 100644 --- a/hscontrol/types/common.go +++ b/hscontrol/types/common.go @@ -237,6 +237,7 @@ func (rn *AuthRequest) FinishAuth(verdict AuthVerdict) { } rn.finished <- verdict + close(rn.finished) } diff --git a/hscontrol/util/dns.go b/hscontrol/util/dns.go index c705ee884..025cb50d6 100644 --- a/hscontrol/util/dns.go +++ b/hscontrol/util/dns.go @@ -164,7 +164,7 @@ func GenerateIPv6DNSRootDomain(ipPrefix netip.Prefix) []dnsname.FQDN { // but the inputs are not so long as to cause problems, // and from what I can see, the generateMagicDNSRootDomains // function is called only once over the lifetime of a server process. - prefixConstantParts := []string{} + prefixConstantParts := make([]string, 0, maskBits/nibbleLen) for i := range maskBits / nibbleLen { prefixConstantParts = append(prefixConstantParts, string(nibbleStr[i])) } diff --git a/integration/dockertestutil/network.go b/integration/dockertestutil/network.go index 83fa1619f..df7f55c1a 100644 --- a/integration/dockertestutil/network.go +++ b/integration/dockertestutil/network.go @@ -231,6 +231,7 @@ func waitNetworkContainer( } found := false + for _, c := range net.Containers { if (c.Name == testContainer || c.Name == "/"+testContainer) && match(c) { found = true diff --git a/integration/hsic/hsic.go b/integration/hsic/hsic.go index add0bc481..ed5809921 100644 --- a/integration/hsic/hsic.go +++ b/integration/hsic/hsic.go @@ -1334,6 +1334,7 @@ func (t *HeadscaleInContainer) NodesByUser() (map[string][]*clientv1.Node, error } userMap := make(map[string][]*clientv1.Node) + for _, node := range nodes { name := node.User.Name userMap[name] = append(userMap[name], node) diff --git a/integration/tsic/tsic.go b/integration/tsic/tsic.go index 553cc0a8a..901a263e9 100644 --- a/integration/tsic/tsic.go +++ b/integration/tsic/tsic.go @@ -1111,7 +1111,7 @@ func (t *TailscaleInContainer) watchIPN(ctx context.Context) (*ipn.Notify, error resultChan <- result{nil, fmt.Errorf("parse notify: %w", err)} } - if notify.NetMap != nil { + if notify.NetMap != nil { //nolint:staticcheck // SA1019: NetMap is still populated on the platforms our integration containers run; migrating to InitialStatus/PeerChanges is a separate change resultChan <- result{¬ify, nil} } } From 24dfcf178f7c92dbeace26303a184de3efd521f1 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 10:28:24 +0000 Subject: [PATCH 072/127] ci: run build, test, lint and format as nix flake checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt kradalby/flake-checks: flake.nix exposes build, gotest, golangci-lint and formatting checks over one shared, fileset-filtered source set, and nix-checks.yml gates each with nix build .#checks... Drop test.yml and lint.yml — gotestsum, golangci-lint and prettier are now those checks. golangci-lint runs full-tree; Go formatting stays in it while formatting adds prettier. hscontrol/servertest is too slow and timing-sensitive for the sandboxed gotest check, so servertest.yml runs it in the devShell instead. --- .github/workflows/lint.yml | 75 ------------------------------ .github/workflows/nix-checks.yml | 56 ++++++++++++++++++++++ .github/workflows/servertest.yml | 36 +++++++++++++++ .github/workflows/test.yml | 47 ------------------- flake.lock | 79 +++++++++++++++++++++++++++++++- flake.nix | 63 ++++++++++++++++++++++++- 6 files changed, 232 insertions(+), 124 deletions(-) delete mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/nix-checks.yml create mode 100644 .github/workflows/servertest.yml delete mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 617355d6d..000000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Lint - -on: [pull_request] - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -defaults: - run: - shell: nix develop --fallback --command bash -e {0} - -jobs: - golangci-lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - with: - fetch-depth: 0 - - name: Get changed files - id: changed-files - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 - with: - filters: | - files: - - '*.nix' - - 'go.*' - - '**/*.go' - - 'integration_test/' - - 'config-example.yaml' - - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main - if: steps.changed-files.outputs.files == 'true' - - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main - if: steps.changed-files.outputs.files == 'true' - - - name: golangci-lint - if: steps.changed-files.outputs.files == 'true' - run: golangci-lint run - --new-from-rev=${{github.event.pull_request.base.sha}} - --output.text.path=stdout - --output.text.print-linter-name - --output.text.print-issued-lines - --output.text.colors - - prettier-lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - with: - fetch-depth: 2 - - name: Get changed files - id: changed-files - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 - with: - filters: | - files: - - '*.nix' - - '**/*.md' - - '**/*.yml' - - '**/*.yaml' - - '**/*.ts' - - '**/*.js' - - '**/*.sass' - - '**/*.css' - - '**/*.scss' - - '**/*.html' - - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main - if: steps.changed-files.outputs.files == 'true' - - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main - if: steps.changed-files.outputs.files == 'true' - - - name: Prettify code - if: steps.changed-files.outputs.files == 'true' - run: prettier --no-error-on-unmatched-pattern - --ignore-unknown --check **/*.{ts,js,md,yaml,yml,sass,css,scss,html} diff --git a/.github/workflows/nix-checks.yml b/.github/workflows/nix-checks.yml new file mode 100644 index 000000000..03c4b8e8b --- /dev/null +++ b/.github/workflows/nix-checks.yml @@ -0,0 +1,56 @@ +name: Nix Flake Checks + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +# Each job only runs `nix build .#checks..`; the check logic lives +# in flake.nix via the flake-checks library. The fileset-filtered checks hit the +# hestia cache when their inputs are unchanged, so no changed-files gating. +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main + - name: build + run: nix build -L .#checks.x86_64-linux.build + + gotest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main + - name: gotest + run: nix build -L .#checks.x86_64-linux.gotest + + golangci-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main + - name: golangci-lint + run: nix build -L .#checks.x86_64-linux.golangci-lint + + formatting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main + - name: formatting + run: nix build -L .#checks.x86_64-linux.formatting diff --git a/.github/workflows/servertest.yml b/.github/workflows/servertest.yml new file mode 100644 index 000000000..795b29590 --- /dev/null +++ b/.github/workflows/servertest.yml @@ -0,0 +1,36 @@ +name: Server Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +# hscontrol/servertest is excluded from the sandboxed gotest flake check: it is +# slow (10s+ convergence cases) and timing-sensitive (race/stress/HA property +# tests), so it runs here in the devShell with a generous timeout instead. +defaults: + run: + shell: nix develop --fallback --command bash -e {0} + +permissions: + contents: read + +jobs: + servertest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main + - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main + + - name: go test ./hscontrol/servertest + env: + CGO_ENABLED: "0" + run: go test -timeout=20m ./hscontrol/servertest/... diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index dadacddce..000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Tests - -on: [push, pull_request] - -concurrency: - group: ${{ github.workflow }}-$${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -defaults: - run: - shell: nix develop --fallback --command bash -e {0} - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - with: - fetch-depth: 2 - - - name: Get changed files - id: changed-files - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 - with: - filters: | - files: - - '*.nix' - - 'go.*' - - '**/*.go' - - 'integration_test/' - - 'config-example.yaml' - - - uses: NixOS/nix-installer-action@6b8548fe06acfb0155a50ab5d561accb215764cc # main - if: steps.changed-files.outputs.files == 'true' - - uses: Mic92/hestia/action@ff07bb902a9968ac0c3d0e51d90a606662a375d8 # main - if: steps.changed-files.outputs.files == 'true' - - - name: Run tests - if: steps.changed-files.outputs.files == 'true' - env: - # As of 2025-01-06, these env vars was not automatically - # set anymore which breaks the initdb for postgres on - # some of the database migration tests. - LC_ALL: "en_US.UTF-8" - LC_CTYPE: "en_US.UTF-8" - run: gotestsum diff --git a/flake.lock b/flake.lock index 81b438068..c723d287a 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,27 @@ { "nodes": { + "flake-checks": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": [ + "nixpkgs" + ], + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1781951072, + "narHash": "sha256-hA9u6hB4QzpReP8hudxqiaa4vLhoRvtXi6YmUgJRGEs=", + "owner": "kradalby", + "repo": "flake-checks", + "rev": "3d2882efec5cf10f8b5a8a035d93ba6ba9f21717", + "type": "github" + }, + "original": { + "owner": "kradalby", + "repo": "flake-checks", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems" @@ -18,6 +40,24 @@ "type": "github" } }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1781153106, @@ -36,7 +76,8 @@ }, "root": { "inputs": { - "flake-utils": "flake-utils", + "flake-checks": "flake-checks", + "flake-utils": "flake-utils_2", "nixpkgs": "nixpkgs" } }, @@ -54,6 +95,42 @@ "repo": "default", "type": "github" } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "flake-checks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1780220602, + "narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "db947814a175b7ca6ded66e21383d938df01c227", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 78daf7427..0513aed7b 100644 --- a/flake.nix +++ b/flake.nix @@ -9,12 +9,17 @@ # once it ships go_1_26 >= 1.26.4. nixpkgs.url = "github:NixOS/nixpkgs/staging-next-26.05"; flake-utils.url = "github:numtide/flake-utils"; + # Reusable Go flake checks (build/test/lint/format); CI runs them via + # `nix build .#checks..` instead of bespoke per-tool steps. + flake-checks.url = "github:kradalby/flake-checks"; + flake-checks.inputs.nixpkgs.follows = "nixpkgs"; }; outputs = { self , nixpkgs , flake-utils + , flake-checks , ... }: let @@ -176,6 +181,59 @@ contents = [ pkgs.headscale ]; config.Entrypoint = [ (pkgs.headscale + "/bin/headscale") ]; }; + + # Go flake checks from the flake-checks library. CI gates on + # `nix build .#checks..`; the logic lives here, not in + # bespoke workflow steps. Linux-only: parts of the tree are + # Linux-specific and the pure unit subset is validated by CI. + fc = flake-checks.lib; + common = { + inherit pkgs; + root = ./.; + pname = "headscale"; + version = headscaleVersion; + vendorHash = (builtins.fromJSON (builtins.readFile ./flakehashes.json)).vendor.sri; + goPkg = pkgs.go_1_26; + # //go:embed targets and test-read files outside the default whitelist. + embedDirs = [ ./hscontrol/assets ./hscontrol/db/schema.sql ./config-example.yaml ]; + extraSrc = [ + ./hscontrol/testdata + ./hscontrol/types/testdata + ./hscontrol/db/testdata + ./hscontrol/policy/v2/testdata + ]; + }; + goChecks = { + build = fc.goBuild (common // { subPackages = [ "cmd/headscale" ]; }); + + # The pure unit subset. ./integration (Docker) and + # ./hscontrol/servertest (slow: 10s+ convergence plus race/stress/HA + # property tests — run by the servertest workflow instead) are dropped + # from the test set but kept in source so cmd/hi and friends still + # compile; TestPostgres* needs a server (the SQLite equivalents still + # run). CGO off matches the build. + gotest = fc.goTest (common // { + testExclude = [ "/integration" "/hscontrol/servertest" ]; + goSkip = [ "TestPostgres" ]; + testEnv = "export CGO_ENABLED=0"; + }); + + # Full-tree golangci-lint (golines, gofumpt, etc.); uses the overlay's + # golangci-lint built against the pinned Go. + golangci-lint = fc.goLint common; + + # nixpkgs-fmt + prettier, excluding generated output. goFmt = "off": + # Go formatting (golines, gofumpt) is enforced by the golangci-lint + # check, not treefmt. prettierExts matches the old prettier-lint glob + # (no json: testdata fixtures are hand-formatted). + formatting = fc.goFormat (common // { + goFmt = "off"; + prettier = true; + prettierExts = [ "ts" "js" "md" "yaml" "yml" "sass" "css" "scss" "html" ]; + # Mirror .prettierignore (docs/ are mkdocs-flavoured; gen/ generated). + fmtExclude = [ ./gen ./docs ]; + }); + }; in { # `nix develop` @@ -221,6 +279,9 @@ checks = { headscale = pkgs.testers.nixosTest (import ./nix/tests/headscale.nix); - }; + } + # The Go build/test checks are gated to Linux: parts of the tree are + # Linux-specific and the pure unit subset is validated by CI. + // pkgs.lib.optionalAttrs pkgs.stdenv.isLinux goChecks; }); } From 339cb392a9db846f2f42cdaed968e55a025148ec Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH 073/127] types: add Node StringID and UserView accessors Add StringID() on Node/NodeView and Username/Display/CreatedAt on UserView, so the HTTP read paths render ids and read users through view accessors instead of cloning with AsStruct(). --- hscontrol/types/node.go | 20 ++++++++++++++++++++ hscontrol/types/users.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index af29bd4bf..dd3dc92d1 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -92,6 +92,16 @@ func (id NodeID) String() string { return strconv.FormatUint(id.Uint64(), util.Base10) } +// StringID returns the node's id as a decimal string, the form the HTTP APIs +// render it as. +func (node *Node) StringID() string { + if node == nil { + return "" + } + + return node.ID.String() +} + func ParseNodeID(s string) (NodeID, error) { id, err := strconv.ParseUint(s, util.Base10, 64) return NodeID(id), err @@ -852,6 +862,16 @@ func (nv NodeView) RequestTagsSlice() views.Slice[string] { return nv.Hostinfo().RequestTags() } +// StringID returns the node's id as a decimal string, the form the HTTP APIs +// render it as. +func (nv NodeView) StringID() string { + if !nv.Valid() { + return "" + } + + return nv.ID().String() +} + // IsTagged reports if a device is tagged // and therefore should not be treated as a // user owned device. diff --git a/hscontrol/types/users.go b/hscontrol/types/users.go index cd55d7d7e..cc194aa49 100644 --- a/hscontrol/types/users.go +++ b/hscontrol/types/users.go @@ -10,6 +10,7 @@ import ( "net/url" "strconv" "strings" + "time" "github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/hscontrol/util/zlog/zf" @@ -133,6 +134,33 @@ func (u *User) Display() string { return cmp.Or(u.DisplayName, u.Username()) } +// Username returns the user's login name via the view; see [User.Username]. +func (v UserView) Username() string { + if !v.Valid() { + return "" + } + + return v.ж.Username() +} + +// Display returns the user's display name via the view; see [User.Display]. +func (v UserView) Display() string { + if !v.Valid() { + return "" + } + + return v.ж.Display() +} + +// CreatedAt returns when the user was created. +func (v UserView) CreatedAt() time.Time { + if !v.Valid() { + return time.Time{} + } + + return v.ж.CreatedAt +} + func (u *User) TailscaleUser() tailcfg.User { return tailcfg.User{ ID: tailcfg.UserID(u.ID), //nolint:gosec // UserID is bounded From 96d736ec2322d358eebe383d56d1d20c17c78627 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH 074/127] api/v1: build responses from view accessors, not AsStruct Serialize node, user and pre-auth-key responses through the NodeView, UserView and PreAuthKeyView accessors instead of AsStruct(), which clones the whole record on every read. Document the convention in AGENTS.md. --- AGENTS.md | 5 +++ hscontrol/api/v1/nodes.go | 77 ++++++++++++++++----------------- hscontrol/api/v1/preauthkeys.go | 14 +++--- hscontrol/api/v1/types.go | 25 ++++++----- hscontrol/api/v1/users.go | 6 +-- 5 files changed, 66 insertions(+), 61 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7fc325c6a..8f3428acb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -275,6 +275,11 @@ Key reminders: `e = e.Str("k", v)`. Forgetting to reassign silently drops the field. - **Tests**: prefer `hscontrol/servertest/` for server-level tests that don't need Docker — faster than full integration tests. +- **View types in read paths**: response serializers must read through + `NodeView`/`UserView`/`PreAuthKeyView` accessors. `AsStruct()` clones the + whole record on every read — it is only for DB-write/merge clones and mutable + working copies, never to build an API response. `grep AsStruct hscontrol/api` + must come back empty. ## Gotchas diff --git a/hscontrol/api/v1/nodes.go b/hscontrol/api/v1/nodes.go index c105388f0..dbc1adfc5 100644 --- a/hscontrol/api/v1/nodes.go +++ b/hscontrol/api/v1/nodes.go @@ -223,7 +223,7 @@ func registerNodeReadOps(api huma.API, b Backend) { // Tags-as-identity: tagged nodes are presented as the special // TaggedDevices user. if node.IsTagged() { - user := userFromState(&types.TaggedDevices) + user := userFromView(types.TaggedDevices.View()) n.User = &user } @@ -583,74 +583,73 @@ func registerNodeAdminOps(api huma.API, b Backend) { }) } -// nodeFromView builds the Node response from a NodeView. SubnetRoutes is left -// empty; callers that serve routes set it explicitly. +// nodeFromView builds the Node response from a NodeView, reading through the +// view accessors. SubnetRoutes is left empty; callers that serve routes set it +// explicitly. func nodeFromView(view types.NodeView) Node { - node := view.AsStruct() - n := Node{ - ID: formatID(uint64(node.ID)), - MachineKey: node.MachineKey.String(), - NodeKey: node.NodeKey.String(), - DiscoKey: node.DiscoKey.String(), - IPAddresses: nonNilStrings(node.IPsAsString()), - Name: node.Hostname, - CreatedAt: node.CreatedAt, - RegisterMethod: registerMethodEnum(node.RegisterMethod), - GivenName: node.GivenName, - Online: node.IsOnline != nil && *node.IsOnline, - ApprovedRoutes: nonNilStrings(util.PrefixesToString(node.ApprovedRoutes)), - AvailableRoutes: nonNilStrings(util.PrefixesToString(node.AnnouncedRoutes())), + ID: view.StringID(), + MachineKey: view.MachineKey().String(), + NodeKey: view.NodeKey().String(), + DiscoKey: view.DiscoKey().String(), + IPAddresses: nonNilStrings(view.IPsAsString()), + Name: view.Hostname(), + CreatedAt: view.CreatedAt(), + RegisterMethod: registerMethodEnum(view.RegisterMethod()), + GivenName: view.GivenName(), + Online: view.IsOnline().Valid() && view.IsOnline().Get(), + ApprovedRoutes: nonNilStrings(util.PrefixesToString(view.ApprovedRoutes().AsSlice())), + AvailableRoutes: nonNilStrings(util.PrefixesToString(view.AnnouncedRoutes())), SubnetRoutes: []string{}, - Tags: nonNilStrings(node.Tags), + Tags: nonNilStrings(view.Tags().AsSlice()), } - if node.User != nil { - user := userFromState(node.User) + if view.User().Valid() { + user := userFromView(view.User()) n.User = &user } - if node.AuthKey != nil { - n.PreAuthKey = nodePreAuthKeyFromState(node.AuthKey) + if view.AuthKey().Valid() { + n.PreAuthKey = nodePreAuthKeyFromView(view.AuthKey()) } - if node.LastSeen != nil { - ls := *node.LastSeen + if view.LastSeen().Valid() { + ls := view.LastSeen().Get() n.LastSeen = &ls } - if node.Expiry != nil { - exp := *node.Expiry + if view.Expiry().Valid() { + exp := view.Expiry().Get() n.Expiry = &exp } return n } -// nodePreAuthKeyFromState builds the embedded NodePreAuthKey, masking the key to +// nodePreAuthKeyFromView builds the embedded NodePreAuthKey, masking the key to // its prefix (legacy plaintext keys are shown in full). -func nodePreAuthKeyFromState(key *types.PreAuthKey) *NodePreAuthKey { +func nodePreAuthKeyFromView(key types.PreAuthKeyView) *NodePreAuthKey { pak := &NodePreAuthKey{ - ID: formatID(key.ID), + ID: formatID(key.ID()), Key: maskedPreAuthKey(key), - Reusable: key.Reusable, - Ephemeral: key.Ephemeral, - Used: key.Used, - AclTags: nonNilStrings(key.Tags), + Reusable: key.Reusable(), + Ephemeral: key.Ephemeral(), + Used: key.Used(), + AclTags: nonNilStrings(key.Tags().AsSlice()), } - if key.User != nil { - user := userFromState(key.User) + if key.User().Valid() { + user := userFromView(key.User()) pak.User = &user } - if key.Expiration != nil { - exp := *key.Expiration + if key.Expiration().Valid() { + exp := key.Expiration().Get() pak.Expiration = &exp } - if key.CreatedAt != nil { - created := *key.CreatedAt + if key.CreatedAt().Valid() { + created := key.CreatedAt().Get() pak.CreatedAt = &created } diff --git a/hscontrol/api/v1/preauthkeys.go b/hscontrol/api/v1/preauthkeys.go index 6658262e6..9afb0aa22 100644 --- a/hscontrol/api/v1/preauthkeys.go +++ b/hscontrol/api/v1/preauthkeys.go @@ -223,7 +223,7 @@ func preAuthKeyNewToResponse(key *types.PreAuthKeyNew) PreAuthKey { } if key.User != nil { - u := userFromState(key.User) + u := userFromView(key.User.View()) out.User = &u } @@ -243,7 +243,7 @@ func preAuthKeyNewToResponse(key *types.PreAuthKeyNew) PreAuthKey { func preAuthKeyToResponse(key *types.PreAuthKey) PreAuthKey { out := PreAuthKey{ ID: formatID(key.ID), - Key: maskedPreAuthKey(key), + Key: maskedPreAuthKey(key.View()), Reusable: key.Reusable, Ephemeral: key.Ephemeral, Used: key.Used, @@ -251,7 +251,7 @@ func preAuthKeyToResponse(key *types.PreAuthKey) PreAuthKey { } if key.User != nil { - u := userFromState(key.User) + u := userFromView(key.User.View()) out.User = &u } @@ -269,12 +269,12 @@ func preAuthKeyToResponse(key *types.PreAuthKey) PreAuthKey { // maskedPreAuthKey masks new keys (those with a stored prefix) so the secret is // never returned; legacy plaintext keys are returned in full for backwards // compatibility. -func maskedPreAuthKey(key *types.PreAuthKey) string { - if key.Prefix != "" { - return "hskey-auth-" + key.Prefix + "-***" +func maskedPreAuthKey(key types.PreAuthKeyView) string { + if key.Prefix() != "" { + return "hskey-auth-" + key.Prefix() + "-***" } - return key.Key + return key.Key() } // nonNilTags ensures aclTags serializes as [] rather than null, matching diff --git a/hscontrol/api/v1/types.go b/hscontrol/api/v1/types.go index 91943b774..e8ffbd879 100644 --- a/hscontrol/api/v1/types.go +++ b/hscontrol/api/v1/types.go @@ -29,23 +29,24 @@ type User struct { ProfilePicURL string `json:"profilePicUrl"` } -// userFromState converts a domain user into the v1 response shape: Name falls -// back to Username() (email/provider/id) when the stored Name is empty, so OIDC -// users display their email. -func userFromState(u *types.User) User { - name := u.Name +// userFromView converts a domain user into the v1 response shape, reading +// through the [types.UserView] accessors: Name falls back to Username() +// (email/provider/id) when the stored Name is empty, so OIDC users display +// their email. +func userFromView(u types.UserView) User { + name := u.Name() if name == "" { name = u.Username() } return User{ - ID: formatID(u.ID), + ID: formatID(u.ID()), Name: name, - CreatedAt: u.CreatedAt, - DisplayName: u.DisplayName, - Email: u.Email, - ProviderID: u.ProviderIdentifier.String, - Provider: u.Provider, - ProfilePicURL: u.ProfilePicURL, + CreatedAt: u.CreatedAt(), + DisplayName: u.DisplayName(), + Email: u.Email(), + ProviderID: u.ProviderIdentifier().String, + Provider: u.Provider(), + ProfilePicURL: u.ProfilePicURL(), } } diff --git a/hscontrol/api/v1/users.go b/hscontrol/api/v1/users.go index db8a0c5a8..9468d67a1 100644 --- a/hscontrol/api/v1/users.go +++ b/hscontrol/api/v1/users.go @@ -93,7 +93,7 @@ func registerUsers(api huma.API, b Backend) { b.Change(policyChanged) out := &userOutput{} - out.Body.User = userFromState(user) + out.Body.User = userFromView(user.View()) return out, nil }) @@ -129,7 +129,7 @@ func registerUsers(api huma.API, b Backend) { } out := &userOutput{} - out.Body.User = userFromState(newUser) + out.Body.User = userFromView(newUser.View()) return out, nil }) @@ -192,7 +192,7 @@ func registerUsers(api huma.API, b Backend) { out.Body.Users = make([]User, len(users)) for i := range users { - out.Body.Users[i] = userFromState(&users[i]) + out.Body.Users[i] = userFromView(users[i].View()) } return out, nil From 4fa6af0102d27fd2106575ea7c75484be0cbb239 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH 075/127] policy/v2: write the SSH check period as time arithmetic Express the maximum SSH check period as 7 * 24 * time.Hour instead of a bare nanosecond constant, matching how the rest of the code spells out magic durations. --- hscontrol/policy/v2/types.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index a8e1bd850..4e2483235 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -63,7 +63,7 @@ var ( // SaaS imposes no minimum (0s is accepted) so headscale matches. const ( SSHCheckPeriodDefault = 12 * time.Hour - SSHCheckPeriodMax = 168 * time.Hour + SSHCheckPeriodMax = 7 * 24 * time.Hour ) // ACL validation errors. From 3d811f29a6659589f4dc55dd329bcab5eade6c20 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH 076/127] db: add API-key owner and pre-auth-key description for v2 Give API keys an optional owning user and pre-auth keys a free-text description, plus the state accessors the v2 API needs to act as a key's owner and to persist descriptions. --- hscontrol/db/api_key.go | 26 +++++++++++++++++++++ hscontrol/db/db.go | 41 ++++++++++++++++++++++++++++++---- hscontrol/db/preauth_keys.go | 23 +++++++++++++++++++ hscontrol/db/schema.sql | 2 ++ hscontrol/state/state.go | 28 ++++++++++++++++++++--- hscontrol/types/api_key.go | 7 ++++++ hscontrol/types/preauth_key.go | 26 +++++++++++++++++++++ hscontrol/types/types_clone.go | 25 +++++++++++---------- hscontrol/types/types_view.go | 37 +++++++++++++++++------------- 9 files changed, 180 insertions(+), 35 deletions(-) diff --git a/hscontrol/db/api_key.go b/hscontrol/db/api_key.go index 41f169152..b56f1c188 100644 --- a/hscontrol/db/api_key.go +++ b/hscontrol/db/api_key.go @@ -25,6 +25,7 @@ const ( var ( ErrAPIKeyFailedToParse = errors.New("failed to parse ApiKey") ErrAPIKeyGenerationFailed = errors.New("failed to generate API key") + ErrAPIKeyExpired = errors.New("API key expired") ) // CreateAPIKey creates a new [types.APIKey] in a user, and returns it. @@ -127,6 +128,31 @@ func (hsdb *HSDatabase) ValidateAPIKey(keyStr string) (bool, error) { return true, nil } +// AuthenticateAPIKey validates keyStr and returns the matching, unexpired +// [types.APIKey] (with its owning UserID populated). Unlike ValidateAPIKey it +// returns the key itself, so the v2 API can act as the key's owning user. A +// non-nil error means the key is missing, malformed, or expired. +func (hsdb *HSDatabase) AuthenticateAPIKey(keyStr string) (*types.APIKey, error) { + key, err := validateAPIKey(hsdb.DB, keyStr) + if err != nil { + return nil, err + } + + if key.Expiration != nil && key.Expiration.Before(time.Now()) { + return nil, ErrAPIKeyExpired + } + + return key, nil +} + +// SetAPIKeyUser sets the owning user of an API key. Used when an admin mints a +// key on behalf of a user (headscale apikeys create --user). +func (hsdb *HSDatabase) SetAPIKeyUser(keyID uint64, userID types.UserID) error { + return hsdb.DB.Model(&types.APIKey{}). + Where("id = ?", keyID). + Update("user_id", uint(userID)).Error +} + // ParseAPIKeyPrefix extracts the database prefix from a display prefix. // Handles formats: "hskey-api-{12chars}-***", "hskey-api-{12chars}", or just "{12chars}". // Returns the 12-character prefix suitable for database lookup. diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index 431ae5103..ea9b80859 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -38,9 +38,9 @@ var errDatabaseNotSupported = errors.New("database type not supported") var errForeignKeyConstraintsViolated = errors.New("foreign key constraints violated") const ( - maxIdleConns = 100 - maxOpenConns = 100 - contextTimeoutSecs = 10 + maxIdleConns = 100 + maxOpenConns = 100 + contextTimeout = 10 * time.Second ) type HSDatabase struct { @@ -781,6 +781,39 @@ WHERE user_id IS NULL }, Rollback: func(db *gorm.DB) error { return nil }, }, + { + // Add an optional owning user to API keys so the v2 API can + // create user-owned (untagged) auth keys, mirroring Tailscale's + // "key owned by the creating identity". + ID: "202606191500-api-key-user-id", + Migrate: func(tx *gorm.DB) error { + if !tx.Migrator().HasColumn(&types.APIKey{}, "user_id") { + err := tx.Migrator().AddColumn(&types.APIKey{}, "user_id") + if err != nil { + return fmt.Errorf("adding user_id to api_keys: %w", err) + } + } + + return nil + }, + Rollback: func(db *gorm.DB) error { return nil }, + }, + { + // Add a free-text description to pre-auth keys, set via the + // v2 keys API. + ID: "202606191501-pre-auth-key-description", + Migrate: func(tx *gorm.DB) error { + if !tx.Migrator().HasColumn(&types.PreAuthKey{}, "description") { + err := tx.Migrator().AddColumn(&types.PreAuthKey{}, "description") + if err != nil { + return fmt.Errorf("adding description to pre_auth_keys: %w", err) + } + } + + return nil + }, + Rollback: func(db *gorm.DB) error { return nil }, + }, }, ) @@ -873,7 +906,7 @@ WHERE user_id IS NULL defer sqlConn.SetMaxIdleConns(1) defer sqlConn.SetMaxOpenConns(1) - ctx, cancel := context.WithTimeout(context.Background(), contextTimeoutSecs*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), contextTimeout) defer cancel() opts := squibble.DigestOptions{ diff --git a/hscontrol/db/preauth_keys.go b/hscontrol/db/preauth_keys.go index 2f4f5de07..ff2c7a301 100644 --- a/hscontrol/db/preauth_keys.go +++ b/hscontrol/db/preauth_keys.go @@ -133,6 +133,15 @@ func CreatePreAuthKey( }, nil } +// SetPreAuthKeyDescription sets the free-text description on a pre-auth key. +// The v2 keys API sets it after creation rather than threading it through the +// many-armed CreatePreAuthKey signature shared by every other caller. +func (hsdb *HSDatabase) SetPreAuthKeyDescription(id uint64, description string) error { + return hsdb.DB.Model(&types.PreAuthKey{}). + Where("id = ?", id). + Update("description", description).Error +} + func (hsdb *HSDatabase) ListPreAuthKeys() ([]types.PreAuthKey, error) { return Read(hsdb.DB, ListPreAuthKeys) } @@ -295,6 +304,20 @@ func GetPreAuthKey(tx *gorm.DB, key string) (*types.PreAuthKey, error) { return findAuthKey(tx, key) } +// GetPreAuthKeyByID returns a [types.PreAuthKey] by its primary key, with the +// owning user preloaded. +func (hsdb *HSDatabase) GetPreAuthKeyByID(id uint64) (*types.PreAuthKey, error) { + pak := types.PreAuthKey{} + // Explicit primary-key clause: a struct condition would drop a zero-valued + // ID, making the lookup unconditional and returning the first row instead + // of not-found. + if result := hsdb.DB.Preload("User").First(&pak, "id = ?", id); result.Error != nil { + return nil, result.Error + } + + return &pak, nil +} + // DestroyPreAuthKey destroys a preauthkey. Returns error if the [types.PreAuthKey] // does not exist. This also clears the auth_key_id on any nodes that reference // this key. diff --git a/hscontrol/db/schema.sql b/hscontrol/db/schema.sql index 781446c00..d7172c56e 100644 --- a/hscontrol/db/schema.sql +++ b/hscontrol/db/schema.sql @@ -44,6 +44,7 @@ CREATE TABLE pre_auth_keys( prefix text, hash blob, user_id integer, + description text, reusable numeric, ephemeral numeric DEFAULT false, used numeric DEFAULT false, @@ -60,6 +61,7 @@ CREATE TABLE api_keys( id integer PRIMARY KEY AUTOINCREMENT, prefix text, hash blob, + user_id integer, expiration datetime, last_seen datetime, diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index ab4fe9978..9c0033782 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -1373,6 +1373,17 @@ func (s *State) ValidateAPIKey(keyStr string) (bool, error) { return s.db.ValidateAPIKey(keyStr) } +// AuthenticateAPIKey validates an API key and returns it (with its owning +// user), so callers like the v2 API can act as the key's owner. +func (s *State) AuthenticateAPIKey(keyStr string) (*types.APIKey, error) { + return s.db.AuthenticateAPIKey(keyStr) +} + +// SetAPIKeyUser sets the owning user of an API key by its database ID. +func (s *State) SetAPIKeyUser(keyID uint64, userID types.UserID) error { + return s.db.SetAPIKeyUser(keyID, userID) +} + // CreateAPIKey generates a new API key with optional expiration. func (s *State) CreateAPIKey(expiration *time.Time) (string, *types.APIKey, error) { return s.db.CreateAPIKey(expiration) @@ -1457,9 +1468,15 @@ func (s *State) DB() *hsdb.HSDatabase { return s.db } -// GetPreAuthKey retrieves a pre-authentication key by ID. -func (s *State) GetPreAuthKey(id string) (*types.PreAuthKey, error) { - return s.db.GetPreAuthKey(id) +// GetPreAuthKey retrieves a pre-authentication key by its secret. The caller is +// responsible for checking whether the key is usable (expired or used). +func (s *State) GetPreAuthKey(keyStr string) (*types.PreAuthKey, error) { + return s.db.GetPreAuthKey(keyStr) +} + +// GetPreAuthKeyByID retrieves a pre-authentication key by its database id. +func (s *State) GetPreAuthKeyByID(id uint64) (*types.PreAuthKey, error) { + return s.db.GetPreAuthKeyByID(id) } // ListPreAuthKeys returns all pre-authentication keys for a user. @@ -1467,6 +1484,11 @@ func (s *State) ListPreAuthKeys() ([]types.PreAuthKey, error) { return s.db.ListPreAuthKeys() } +// SetPreAuthKeyDescription sets the free-text description on a pre-auth key. +func (s *State) SetPreAuthKeyDescription(id uint64, description string) error { + return s.db.SetPreAuthKeyDescription(id, description) +} + // ExpirePreAuthKey marks a pre-authentication key as expired. func (s *State) ExpirePreAuthKey(id uint64) error { return s.db.ExpirePreAuthKey(id) diff --git a/hscontrol/types/api_key.go b/hscontrol/types/api_key.go index 17854b44d..845f88caf 100644 --- a/hscontrol/types/api_key.go +++ b/hscontrol/types/api_key.go @@ -17,6 +17,13 @@ type APIKey struct { Prefix string `gorm:"uniqueIndex"` Hash []byte + // Optional owning user id. When set, an auth key created through the v2 API + // with no tags is owned by this user — mirroring Tailscale, where a key is + // owned by the identity that created it. Nil for legacy/admin keys, which + // can only create tagged keys. Kept as a plain column (no foreign key) so an + // upgraded database matches a freshly-migrated one. + UserID *uint + CreatedAt *time.Time Expiration *time.Time LastSeen *time.Time diff --git a/hscontrol/types/preauth_key.go b/hscontrol/types/preauth_key.go index cf8d178a5..30e4a627f 100644 --- a/hscontrol/types/preauth_key.go +++ b/hscontrol/types/preauth_key.go @@ -1,8 +1,10 @@ package types import ( + "strconv" "time" + "github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/hscontrol/util/zlog/zf" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -12,6 +14,26 @@ type PAKError string func (e PAKError) Error() string { return string(e) } +// StringID returns the key's id as a decimal string, the form the HTTP APIs +// render it as. +func (pak *PreAuthKey) StringID() string { + if pak == nil { + return "" + } + + return strconv.FormatUint(pak.ID, util.Base10) +} + +// StringID returns the key's id as a decimal string, the form the HTTP APIs +// render it as. +func (pak *PreAuthKeyNew) StringID() string { + if pak == nil { + return "" + } + + return strconv.FormatUint(pak.ID, util.Base10) +} + // PreAuthKey describes a pre-authorization key usable in a particular user. type PreAuthKey struct { ID uint64 `gorm:"primary_key"` @@ -29,6 +51,10 @@ type PreAuthKey struct { UserID *uint User *User `gorm:"constraint:OnDelete:SET NULL;"` + // Free-text description, set via the v2 API. Empty for keys created through + // the v1 API or CLI. + Description string + Reusable bool Ephemeral bool `gorm:"default:false"` Used bool `gorm:"default:false"` diff --git a/hscontrol/types/types_clone.go b/hscontrol/types/types_clone.go index 315cabf81..f3587ba04 100644 --- a/hscontrol/types/types_clone.go +++ b/hscontrol/types/types_clone.go @@ -137,16 +137,17 @@ func (src *PreAuthKey) Clone() *PreAuthKey { // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _PreAuthKeyCloneNeedsRegeneration = PreAuthKey(struct { - ID uint64 - Key string - Prefix string - Hash []byte - UserID *uint - User *User - Reusable bool - Ephemeral bool - Used bool - Tags []string - CreatedAt *time.Time - Expiration *time.Time + ID uint64 + Key string + Prefix string + Hash []byte + UserID *uint + User *User + Description string + Reusable bool + Ephemeral bool + Used bool + Tags []string + CreatedAt *time.Time + Expiration *time.Time }{}) diff --git a/hscontrol/types/types_view.go b/hscontrol/types/types_view.go index 4bdaa7785..312059c3a 100644 --- a/hscontrol/types/types_view.go +++ b/hscontrol/types/types_view.go @@ -395,10 +395,14 @@ func (v PreAuthKeyView) Hash() views.ByteSlice[[]byte] { return views.ByteSliceO // Can be nil for system-created tagged keys func (v PreAuthKeyView) UserID() views.ValuePointer[uint] { return views.ValuePointerOf(v.ж.UserID) } -func (v PreAuthKeyView) User() UserView { return v.ж.User.View() } -func (v PreAuthKeyView) Reusable() bool { return v.ж.Reusable } -func (v PreAuthKeyView) Ephemeral() bool { return v.ж.Ephemeral } -func (v PreAuthKeyView) Used() bool { return v.ж.Used } +func (v PreAuthKeyView) User() UserView { return v.ж.User.View() } + +// Free-text description, set via the v2 API. Empty for keys created through +// the v1 API or CLI. +func (v PreAuthKeyView) Description() string { return v.ж.Description } +func (v PreAuthKeyView) Reusable() bool { return v.ж.Reusable } +func (v PreAuthKeyView) Ephemeral() bool { return v.ж.Ephemeral } +func (v PreAuthKeyView) Used() bool { return v.ж.Used } // Tags to assign to nodes registered with this key. // Tags are copied to the node during registration. @@ -414,16 +418,17 @@ func (v PreAuthKeyView) Expiration() views.ValuePointer[time.Time] { // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _PreAuthKeyViewNeedsRegeneration = PreAuthKey(struct { - ID uint64 - Key string - Prefix string - Hash []byte - UserID *uint - User *User - Reusable bool - Ephemeral bool - Used bool - Tags []string - CreatedAt *time.Time - Expiration *time.Time + ID uint64 + Key string + Prefix string + Hash []byte + UserID *uint + User *User + Description string + Reusable bool + Ephemeral bool + Used bool + Tags []string + CreatedAt *time.Time + Expiration *time.Time }{}) From 2ff342764f7382c1796f929c3dcc3faa90f43ebd Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH 077/127] state: return ErrGivenNameInvalid for invalid node rename RenameNode wrapped the raw dnsname error, so the v2 API mapped it to a 500. Emit the sentinel so an invalid name surfaces as a 400. --- hscontrol/state/state.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index 9c0033782..6068dbbea 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -1034,7 +1034,7 @@ func (s *State) SetApprovedRoutes(nodeID types.NodeID, routes []netip.Prefix) (t func (s *State) RenameNode(nodeID types.NodeID, newName string) (types.NodeView, change.Change, error) { err := dnsname.ValidLabel(newName) if err != nil { - return types.NodeView{}, change.Change{}, fmt.Errorf("renaming node: %w", err) + return types.NodeView{}, change.Change{}, fmt.Errorf("%w: %w", ErrGivenNameInvalid, err) } view, err := s.nodeStore.SetGivenName(nodeID, newName) From fe08d439783bbf5eb49ba359bade4cef4807f298 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH 078/127] api/v2: add Tailscale-compatible keys API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the v2 foundation — Basic/Bearer auth, scope scaffolding, the tailnet check, and the Tailscale error shape — plus the auth-key endpoints mapped onto Headscale pre-auth keys. --- hscontrol/api/v2/README.md | 87 +++++++++ hscontrol/api/v2/api.go | 263 ++++++++++++++++++++++++++ hscontrol/api/v2/errors.go | 83 +++++++++ hscontrol/api/v2/helpers.go | 69 +++++++ hscontrol/api/v2/keys.go | 360 ++++++++++++++++++++++++++++++++++++ 5 files changed, 862 insertions(+) create mode 100644 hscontrol/api/v2/README.md create mode 100644 hscontrol/api/v2/api.go create mode 100644 hscontrol/api/v2/errors.go create mode 100644 hscontrol/api/v2/helpers.go create mode 100644 hscontrol/api/v2/keys.go diff --git a/hscontrol/api/v2/README.md b/hscontrol/api/v2/README.md new file mode 100644 index 000000000..0a6437105 --- /dev/null +++ b/hscontrol/api/v2/README.md @@ -0,0 +1,87 @@ +# API v2 — Headscale's v2 API + +This is Headscale's v2 HTTP API, served at `/api/v2`. Some of its endpoints are +**ported from Tailscale's API** — reusing Tailscale's wire shapes — so the +Tailscale ecosystem that cannot talk to Headscale today works: the +[Terraform/OpenTofu provider], [tscli], and the official [Go client] +(`tailscale.com/client/tailscale/v2`). + +It is **not** a port of the whole Tailscale API. Ported endpoints are added one +at a time, only as we need them; a headscale-native v2 endpoint may use +headscale's own conventions. The headscale-native admin API stays at `/api/v1` +(`hscontrol/api/v1`). This guide is for the endpoints **ported from Tailscale**. + +[Terraform/OpenTofu provider]: https://registry.terraform.io/providers/tailscale/tailscale/latest +[tscli]: https://github.com/jaxxstorm/tscli +[Go client]: https://pkg.go.dev/tailscale.com/client/tailscale/v2 + +## Conventions + +- Operations derived from Tailscale carry the `Tailscale compat` tag. +- The `{tailnet}` path segment must be `-` (the single Headscale tailnet); + anything else is `404`. See `requireDefaultTailnet`. +- Errors use **Tailscale's** body (`{"message","data","status"}`), installed as + a per-API transform (`tailscaleErrorTransformer` in `errors.go`). A future + headscale-native v2 operation would keep Huma's RFC 9457 problem+json. +- Auth accepts the API key as **HTTP Basic** (key as username — what the SDK + sends) or **Bearer**. See `authMiddleware`. +- Each operation declares the Tailscale scope it would require (`auth_keys`, + `devices:core`, `devices:routes`, `policy_file`, `feature_settings`, each with + a `:read` subset). Nothing is enforced yet — every key is all-access — pending + OAuth tokens; see the `TODO(scopes)` in `api.go`. +- Resolve one entity by id with a typed getter (`GetNodeByID`, `GetUserByID`, + `GetAPIKeyByID`, `GetPreAuthKeyByID`); add one to state/db if it is missing + rather than scanning a `List`. Build responses from the view accessors + (`NodeView`/`UserView`/`PreAuthKeyView`), never `AsStruct()`. +- Reuse upstream wire shapes, but declare the request/response structs here: + Huma reflects these to build the OpenAPI schema, and the upstream `Key`'s + `ExpirySeconds *time.Duration` marshals as nanoseconds, which the spec and + every client read as seconds. + +## Adding an endpoint + +Worked example: the keys resource (`keys.go`) = Tailscale auth keys = Headscale +pre-auth keys. + +1. **Read the Tailscale spec.** Find the operation in the [Tailscale API + reference](https://tailscale.com/api) (OpenAPI 3.1). Note method, path, + request/response schema, and which variant(s) Headscale supports (auth keys + only, for keys). + +2. **Capture golden samples.** Pull the request + response JSON examples from the + spec, prune to the variant, and use them as the assertion in the contract + test. _Acceptance: the captured request and response are recorded in the + test._ + +3. **Map to Headscale.** Write the field ↔ field ↔ `state` call mapping. Record + gaps and the decision for each (e.g. Tailscale `preauthorized` has no + Headscale equivalent — accepted, ignored, echoed back). _Acceptance: every + request field is consumed or deliberately ignored; every response field has a + source._ + +4. **Implement the Huma operation.** Declare named request/response structs with + validation/`default`/`example`/`doc` tags; tag the operation `Tailscale +compat`; declare its `Errors`; enforce the tailnet and scope. Map state + errors with `mapError`. _Acceptance: `go build ./hscontrol/api/v2/` and the + operation appears in `Spec()`._ + +5. **Contract test (in-process, `humatest`).** Assert the server accepts the + golden request and returns the golden response shape, with secrets and + timestamps neutralised. Pin the wire facts (e.g. `expirySeconds` in seconds, + the list `{"keys":[...]}` envelope, the error `message`). See + `hscontrol/apiv2_keys_test.go`. _Acceptance: the test is green._ + +6. **Roundtrip the real clients.** Add a `t.Run` subtest to `TestAPIv2` + (`hscontrol/servertest/apiv2_test.go`) for each of the Go client, tscli, and + OpenTofu — full create→read→list→delete against one shared server on a real + loopback port (`servertest.WithRealListener`). tscli and tofu come from the + nix dev shell; a missing binary fails the test. _Acceptance: `nix develop -c +go test ./hscontrol/servertest/ -run TestAPIv2` is green._ + +7. **Update the CLI** only if the v2 operation fully replaces a v1 one. Tailscale + has no separate key-expire verb — its `DELETE` _is_ the revoke — so v2 maps + `DELETE` to a soft revoke: the key stays retrievable with `invalid: true` + until the collector reaps it (`preauth_keys.revoked_retention`), the + equivalent of v1 `preauthkeys expire`. `headscale preauthkeys` still stays on + v1 for now (it is the cross-user admin surface), but the verb gap that + previously blocked migration is closed. diff --git a/hscontrol/api/v2/api.go b/hscontrol/api/v2/api.go new file mode 100644 index 000000000..02d9a5c6c --- /dev/null +++ b/hscontrol/api/v2/api.go @@ -0,0 +1,263 @@ +// Package apiv2 is Headscale's v2 HTTP API, served at /api/v2. +// +// Where the v1 API (hscontrol/api/v1) is the headscale-native admin surface, v2 +// additionally ports selected endpoints from Tailscale's API — reusing +// Tailscale's wire shapes (paths, request/response JSON, error body) — so the +// existing Tailscale ecosystem (the Terraform/OpenTofu provider, tscli, and +// tailscale.com/client/tailscale/v2) can drive Headscale unchanged. Ported +// operations carry the "Tailscale compat" tag; a headscale-native v2 operation +// may use headscale's own conventions instead. See README.md for the porting +// guide. +// +// It depends only on the domain layer (hscontrol/state, hscontrol/types, and +// the db error sentinels), never on the hscontrol server package, so it sits +// beside the v1 API without either importing the other. +package apiv2 + +import ( + "context" + "encoding/base64" + "maps" + "net/http" + "strings" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humachi" + "github.com/go-chi/chi/v5" + "github.com/juanfont/headscale/hscontrol/state" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/types/change" +) + +// Backend is the dependency surface the v2 API needs from the control plane: +// the state layer, the change-notification sink that distributes node/policy +// updates to connected clients, and the config (Policy.Mode/Path, Node.Expiry, +// and TLS are read by the ACL and settings handlers). +type Backend struct { + State *state.State + Change func(...change.Change) + Cfg *types.Config +} + +// security is the requirement applied to authenticated operations: an API key +// presented as HTTP Basic (the key as username — what the Tailscale SDK sends) +// or as a Bearer token. +var security = []map[string][]string{{"basicAuth": {}}, {"bearerAuth": {}}} + +// registrations is populated by each resource file's init(), so adding a +// resource means adding a file rather than editing a shared list. +var registrations []func(huma.API, Backend) + +// Register wires up every operation contributed by the resource files. Exported +// so tests can register the v2 operations onto a humatest API. +func Register(api huma.API, b Backend) { + for _, fn := range registrations { + fn(api, b) + } +} + +// Config returns the Huma configuration shared by the production API and tests: +// the v2 OpenAPI/docs paths, the basic+bearer security schemes, and the +// Tailscale error transform. Suppressing SchemasPath/CreateHooks keeps "$schema" +// out of the emitted bodies, matching the Tailscale wire contract. +func Config() huma.Config { + config := huma.DefaultConfig("Headscale API", "v2") + config.Info.Description = "Headscale v2 API. Some endpoints are ported from / compatible with the Tailscale API (tagged \"Tailscale compat\")." + + config.OpenAPIPath = "/api/v2/openapi" + config.DocsPath = "/api/v2/docs" + config.SchemasPath = "" + config.CreateHooks = nil + + config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{ + "basicAuth": {Type: "http", Scheme: "basic"}, + "bearerAuth": {Type: "http", Scheme: "bearer"}, + } + + config.Transformers = append(config.Transformers, tailscaleErrorTransformer) + + // Accept application/hujson request bodies (the Tailscale SDK sends the + // policy file that way). The bytes are captured raw by the ACL handler, so + // reusing the JSON format is only to satisfy huma's content-type check. + // Clone first — config.Formats aliases huma's shared DefaultFormats map. + formats := maps.Clone(config.Formats) + formats["application/hujson"] = formats["application/json"] + config.Formats = formats + + return config +} + +// NewAPI builds the v2 Huma API on router, installs the auth+scope middleware, +// and registers every operation. +func NewAPI(router chi.Router, backend Backend) huma.API { + api := humachi.New(router, Config()) + + // Must run before Register: Huma snapshots the middleware chain at operation + // registration, so a middleware added afterwards would silently never run. + api.UseMiddleware(authMiddleware(api, backend)) + + Register(api, backend) + + return api +} + +// Handler builds the v2 API on a fresh mux and returns both, for mounting. +func Handler(backend Backend) (*chi.Mux, huma.API) { + mux := chi.NewMux() + api := NewAPI(mux, backend) + + return mux, api +} + +// Spec emits the OpenAPI 3.1 document. The zero Backend is safe because +// handlers are registered but never invoked during emission. +func Spec() ([]byte, error) { + api := NewAPI(chi.NewMux(), Backend{}) + + return api.OpenAPI().YAML() +} + +type contextKey int + +const ( + localTrustKey contextKey = iota + ownerUserKey +) + +// WithLocalTrust marks a request as arriving over a locally-trusted transport +// (the unix socket), bypassing API-key authentication. Reserved for a future +// v2 socket mount; the network listener always authenticates. +func WithLocalTrust(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + next.ServeHTTP(w, req.WithContext( + context.WithValue(req.Context(), localTrustKey, struct{}{}), + )) + }) +} + +// authMiddleware authenticates the API key (HTTP Basic with the key as the +// username — what the Tailscale SDK sends — or Bearer), records the key's +// owning user for handlers, and enforces the operation's required scope. +func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Context)) { + return func(ctx huma.Context, next func(huma.Context)) { + if ctx.Context().Value(localTrustKey) != nil { + next(ctx) + + return + } + + if len(ctx.Operation().Security) == 0 { + next(ctx) + + return + } + + token, ok := bearerOrBasicToken(ctx.Header("Authorization")) + if !ok { + _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized") + + return + } + + key, err := b.State.AuthenticateAPIKey(token) + if err != nil { + _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized") + + return + } + + // TODO(scopes): every valid key is all-access today. Operations still + // declare their required scope (requireScope) so that once OAuth tokens + // arrive, the granted set can be derived from the token and checked + // against the operation's scope here. Until then, accept everything. + + // Record the key's owning user (may be unset) so handlers can create + // user-owned keys on its behalf. + if key.UserID != nil { + ctx = huma.WithValue(ctx, ownerUserKey, types.UserID(*key.UserID)) + } + + next(ctx) + } +} + +// bearerOrBasicToken extracts the API key from an Authorization header. The +// Tailscale SDK sends the key as the Basic-auth username with an empty +// password; curl and humans may use Bearer. +func bearerOrBasicToken(header string) (string, bool) { + if token, ok := strings.CutPrefix(header, "Bearer "); ok { + return token, token != "" + } + + if encoded, ok := strings.CutPrefix(header, "Basic "); ok { + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", false + } + + username, _, _ := strings.Cut(string(raw), ":") + + return username, username != "" + } + + return "", false +} + +// ownerUser returns the user the request's API key belongs to, if any. +func ownerUser(ctx context.Context) (types.UserID, bool) { + uid, ok := ctx.Value(ownerUserKey).(types.UserID) + + return uid, ok +} + +// requireDefaultTailnet rejects any tailnet other than "-". Headscale is +// single-tailnet; the Tailscale SDK sends "-" (its default tailnet). A non-"-" +// value is "no such tailnet", a 404, which lets the SDK's IsNotFound behave. +func requireDefaultTailnet(tailnet string) error { + if tailnet != "-" { + return huma.Error404NotFound("tailnet not found") + } + + return nil +} + +// Scope is an OAuth capability an operation requires and a token grants. The +// names mirror Tailscale's API scopes (see the OAuth scope descriptions in the +// Tailscale OpenAPI spec); a ...Read scope is the read-only subset of its +// write scope. Nothing is enforced yet — every key is all-access — but every +// operation declares the scope it would require, so OAuth tokens can later be +// checked against it without reworking the operations. +type Scope string + +const ( + ScopeAuthKeys Scope = "auth_keys" + ScopeAuthKeysRead Scope = "auth_keys:read" + + ScopeDevicesCore Scope = "devices:core" + ScopeDevicesCoreRead Scope = "devices:core:read" + + ScopeDevicesRoutes Scope = "devices:routes" + ScopeDevicesRoutesRead Scope = "devices:routes:read" + + ScopePolicyFile Scope = "policy_file" + ScopePolicyFileRead Scope = "policy_file:read" + + ScopeFeatureSettings Scope = "feature_settings" + ScopeFeatureSettingsRead Scope = "feature_settings:read" +) + +// scopeMetaKey keys the per-operation required Scope in huma.Operation.Metadata. +const scopeMetaKey = "headscale.scope" + +// requireScope records op's required scope in its Metadata, where the auth +// middleware can read it back. It keeps the requirement next to the operation +// definition. +func requireScope(op huma.Operation, s Scope) huma.Operation { + if op.Metadata == nil { + op.Metadata = map[string]any{} + } + + op.Metadata[scopeMetaKey] = s + + return op +} diff --git a/hscontrol/api/v2/errors.go b/hscontrol/api/v2/errors.go new file mode 100644 index 000000000..78241b3d4 --- /dev/null +++ b/hscontrol/api/v2/errors.go @@ -0,0 +1,83 @@ +package apiv2 + +import ( + "errors" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/db" + "github.com/juanfont/headscale/hscontrol/state" + "gorm.io/gorm" +) + +// apiError is the Tailscale API error body. The official Tailscale Go client — +// and therefore the Terraform provider and tscli built on it — decode 4xx/5xx +// responses into this shape. Huma's default RFC 9457 problem+json would reach +// them with an empty message, so every v2 error is rewritten into this shape by +// tailscaleErrorTransformer. The HTTP status is read from the response code, +// not from the body's status field. +type apiError struct { + Message string `json:"message"` + Data []apiErrorData `json:"data,omitempty"` + Status int `json:"status"` +} + +type apiErrorData struct { + User string `json:"user,omitempty"` + Errors []string `json:"errors,omitempty"` +} + +// tailscaleErrorTransformer rewrites Huma's RFC 9457 error model into the +// Tailscale error shape. It is registered on the v2 API config only, so the +// headscale-native v1 API keeps emitting problem+json. Non-error bodies pass +// through untouched. +func tailscaleErrorTransformer(_ huma.Context, _ string, v any) (any, error) { + em, ok := v.(*huma.ErrorModel) + if !ok { + return v, nil + } + + message := em.Detail + if message == "" { + message = em.Title + } + + out := apiError{Message: message, Status: em.Status} + + if len(em.Errors) > 0 { + details := make([]string, 0, len(em.Errors)) + for _, d := range em.Errors { + details = append(details, d.Error()) + } + + out.Data = []apiErrorData{{Errors: details}} + } + + return out, nil +} + +// mapError translates a state/db-layer error into a Huma HTTP error +// (not-found→404, invalid input→400, everything else→500). The transformer +// then reshapes it into the Tailscale body. +func mapError(msg string, err error) error { + if err == nil { + return nil + } + + switch { + case errors.Is(err, gorm.ErrRecordNotFound), + errors.Is(err, db.ErrPreAuthKeyNotFound), + errors.Is(err, state.ErrNodeNotFound): + return huma.Error404NotFound(msg, err) + + case errors.Is(err, db.ErrPreAuthKeyNotTaggedOrOwned), + errors.Is(err, db.ErrPreAuthKeyACLTagInvalid), + errors.Is(err, state.ErrGivenNameInvalid), + errors.Is(err, state.ErrGivenNameTaken), + errors.Is(err, state.ErrNodeNameNotUnique), + errors.Is(err, state.ErrRequestedTagsInvalidOrNotPermitted): + return huma.Error400BadRequest(msg, err) + + default: + return huma.Error500InternalServerError(msg, err) + } +} diff --git a/hscontrol/api/v2/helpers.go b/hscontrol/api/v2/helpers.go new file mode 100644 index 000000000..aad0392d0 --- /dev/null +++ b/hscontrol/api/v2/helpers.go @@ -0,0 +1,69 @@ +package apiv2 + +import ( + "math" + "strconv" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" +) + +// emptyIfNil ensures a slice serializes as [] rather than null, which the +// Tailscale clients expect for empty list fields. +func emptyIfNil(s []string) []string { + if s == nil { + return []string{} + } + + return s +} + +// parseID parses a decimal entity id from a path segment. A non-numeric id is +// simply an unknown entity, surfaced as a 404 naming subject (e.g. "auth key"), +// so the Tailscale SDK's IsNotFound behaves. +func parseID(rawID, subject string) (uint64, error) { + id, err := strconv.ParseUint(rawID, util.Base10, util.BitSize64) + if err != nil { + return 0, huma.Error404NotFound(subject + " not found") + } + + return id, nil +} + +// parseNodeID parses a device id path segment into a [types.NodeID]. +func parseNodeID(rawID string) (types.NodeID, error) { + id, err := parseID(rawID, "device") + if err != nil { + return 0, err + } + + return types.NodeID(id), nil +} + +// timeOrZero dereferences a time pointer, returning the zero time for nil. +func timeOrZero(t *time.Time) time.Time { + if t == nil { + return time.Time{} + } + + return *t +} + +// expirySeconds reports the lifetime between created and expires in whole +// seconds, the unit the Tailscale spec documents for the response field. The +// lifetime is rounded because the stored expiration is stamped a hair before +// CreatedAt, so an 86400s request would otherwise read back as 86399. +func expirySeconds(created, expires *time.Time) int64 { + if created == nil || expires == nil { + return 0 + } + + secs := int64(math.Round(expires.Sub(*created).Seconds())) + if secs < 0 { + return 0 + } + + return secs +} diff --git a/hscontrol/api/v2/keys.go b/hscontrol/api/v2/keys.go new file mode 100644 index 000000000..9ffb0a99c --- /dev/null +++ b/hscontrol/api/v2/keys.go @@ -0,0 +1,360 @@ +package apiv2 + +import ( + "context" + "net/http" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/types" +) + +func init() { + registrations = append(registrations, registerKeys) +} + +// defaultExpiry is the auth-key lifetime when the request omits expirySeconds: +// 90 days, matching Tailscale and the Terraform provider default. +const defaultExpiry = 90 * 24 * time.Hour + +// keyTypeAuth is the only key kind Headscale issues; Tailscale's OAuth-client +// and federated-identity kinds are out of scope. +const keyTypeAuth = "auth" + +// KeyCapabilities maps a resource to the actions a key permits. Headscale +// populates only devices.create (auth keys); the named types (vs Tailscale's +// anonymous nesting) give Huma stable schema names. +type KeyCapabilities struct { + Devices KeyDeviceCapabilities `json:"devices"` +} + +type KeyDeviceCapabilities struct { + Create KeyDeviceCreateCapabilities `json:"create"` +} + +type KeyDeviceCreateCapabilities struct { + Reusable bool `json:"reusable"` + Ephemeral bool `json:"ephemeral"` + Preauthorized bool `json:"preauthorized"` + // Tags is not nullable:"false": the Tailscale clients send "tags":null for + // an untagged key, which must be accepted. The response always emits [] via + // emptyIfNil. + Tags []string `json:"tags"` +} + +// CreateKeyRequest is the POST body. expirySeconds is plain seconds (unlike the +// response, see Key). +type CreateKeyRequest struct { + Capabilities KeyCapabilities `json:"capabilities"` + ExpirySeconds int64 `doc:"Lifetime in seconds; defaults to 90 days." json:"expirySeconds,omitempty"` + Description string `json:"description,omitempty" maxLength:"50"` +} + +// Key is the Tailscale auth-key response. expirySeconds is emitted in seconds +// to match the Tailscale spec; the secret key is present only at creation. +type Key struct { + ID string `json:"id"` + KeyType string `json:"keyType"` + Key string `json:"key,omitempty"` + Description string `json:"description,omitempty"` + ExpirySeconds int64 `json:"expirySeconds,omitempty"` + Created time.Time `json:"created"` + Expires *time.Time `json:"expires,omitempty"` + Revoked *time.Time `json:"revoked,omitempty"` + Invalid bool `json:"invalid"` + Capabilities KeyCapabilities `json:"capabilities"` + Tags []string `json:"tags,omitempty"` + UserID string `json:"userId,omitempty"` +} + +type ( + createKeyInput struct { + Tailnet string `doc:"Tailnet; must be \"-\" (the single Headscale tailnet)." path:"tailnet"` + Body CreateKeyRequest + } + + listKeysInput struct { + Tailnet string `path:"tailnet"` + All bool `doc:"Accepted for compatibility; Headscale returns all keys." query:"all"` + } + + keyByIDInput struct { + Tailnet string `path:"tailnet"` + KeyID string `path:"keyId"` + } + + keyOutput struct { + Body Key + } + + listKeysOutput struct { + Body struct { + Keys []Key `json:"keys" nullable:"false"` + } + } + + deleteKeyOutput struct { + Body struct{} + } +) + +func registerKeys(api huma.API, b Backend) { + keysTags := []string{"Keys", "Tailscale compat"} + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "createKey", + Method: http.MethodPost, + Path: "/api/v2/tailnet/{tailnet}/keys", + Summary: "Create an auth key", + Tags: keysTags, + Security: security, + Errors: []int{ + http.StatusBadRequest, + http.StatusUnauthorized, + http.StatusForbidden, + http.StatusNotFound, + }, + }, ScopeAuthKeys), func(ctx context.Context, in *createKeyInput) (*keyOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + create := in.Body.Capabilities.Devices.Create + + // Ownership: tags -> tagged key; no tags -> owned by the API key's user; + // no tags and an ownerless (legacy/admin) key -> 400. + var userID *types.UserID + + if len(create.Tags) == 0 { + uid, ok := ownerUser(ctx) + if !ok { + return nil, huma.Error400BadRequest( + "an auth key without tags must be created with a user-owned API key", + ) + } + + userID = &uid + } + + expiration := time.Now().Add(expiryDuration(in.Body.ExpirySeconds)) + + pak, err := b.State.CreatePreAuthKey( + userID, + create.Reusable, + create.Ephemeral, + &expiration, + create.Tags, + ) + if err != nil { + return nil, mapError("creating auth key", err) + } + + if in.Body.Description != "" { + err := b.State.SetPreAuthKeyDescription( + pak.ID, + in.Body.Description, + ) + if err != nil { + return nil, huma.Error500InternalServerError( + "setting auth key description", + err, + ) + } + } + + return &keyOutput{Body: keyFromNew(pak, in.Body)}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "listKeys", + Method: http.MethodGet, + Path: "/api/v2/tailnet/{tailnet}/keys", + Summary: "List auth keys", + Tags: keysTags, + Security: security, + Errors: []int{ + http.StatusUnauthorized, + http.StatusForbidden, + http.StatusNotFound, + }, + }, ScopeAuthKeysRead), func(ctx context.Context, in *listKeysInput) (*listKeysOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + keys, err := b.State.ListPreAuthKeys() + if err != nil { + return nil, huma.Error500InternalServerError("listing auth keys", err) + } + + out := &listKeysOutput{} + out.Body.Keys = make([]Key, 0, len(keys)) + + for i := range keys { + out.Body.Keys = append(out.Body.Keys, keyFromStored(&keys[i])) + } + + return out, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "getKey", + Method: http.MethodGet, + Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}", + Summary: "Get an auth key", + Tags: keysTags, + Security: security, + Errors: []int{ + http.StatusUnauthorized, + http.StatusForbidden, + http.StatusNotFound, + }, + }, ScopeAuthKeysRead), func(ctx context.Context, in *keyByIDInput) (*keyOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + key, err := findKeyByID(b, in.KeyID) + if err != nil { + return nil, err + } + + return &keyOutput{Body: keyFromStored(key)}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "deleteKey", + Method: http.MethodDelete, + Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}", + Summary: "Delete an auth key", + Tags: keysTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{ + http.StatusUnauthorized, + http.StatusForbidden, + http.StatusNotFound, + }, + }, ScopeAuthKeys), func(ctx context.Context, in *keyByIDInput) (*deleteKeyOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + id, err := parseID(in.KeyID, "auth key") + if err != nil { + return nil, err + } + + err = b.State.DeletePreAuthKey(id) + if err != nil { + return nil, mapError("deleting auth key", err) + } + + return &deleteKeyOutput{}, nil + }) +} + +// findKeyByID looks up a stored pre-auth key by its (stringified) id with a +// direct by-id query; an unknown id surfaces as gorm.ErrRecordNotFound, which +// mapError turns into a 404. +func findKeyByID(b Backend, rawID string) (*types.PreAuthKey, error) { + id, err := parseID(rawID, "auth key") + if err != nil { + return nil, err + } + + pak, err := b.State.GetPreAuthKeyByID(id) + if err != nil { + return nil, mapError("looking up auth key", err) + } + + return pak, nil +} + +// keyFromNew builds the create response from the freshly created key. The +// plaintext secret is returned only here. preauthorized is reported true to +// match keyFromStored: Headscale always authorizes pre-auth-key nodes, so the +// create and read paths must agree or the Terraform provider sees a diff. +func keyFromNew(pak *types.PreAuthKeyNew, req CreateKeyRequest) Key { + create := req.Capabilities.Devices.Create + + key := Key{ + ID: pak.StringID(), + KeyType: keyTypeAuth, + Key: pak.Key, + Description: req.Description, + Created: timeOrZero(pak.CreatedAt), + Capabilities: capabilities( + create.Reusable, + create.Ephemeral, + true, + pak.Tags, + ), + Tags: pak.Tags, + } + + if pak.Expiration != nil { + key.Expires = pak.Expiration + key.ExpirySeconds = expirySeconds(pak.CreatedAt, pak.Expiration) + } + + // A tagged key presents no owner; a user-owned key reports its user id. + if len(pak.Tags) == 0 && pak.User != nil { + key.UserID = pak.User.StringID() + } + + return key +} + +// keyFromStored builds the get/list response from a stored key. The secret is +// never returned here. preauthorized is reported true: Headscale has no separate +// device-approval step, so every pre-auth key authorizes its nodes. Reporting it +// stably keeps the Terraform provider from seeing a forced-replacement diff. +func keyFromStored(pak *types.PreAuthKey) Key { + key := Key{ + ID: pak.StringID(), + KeyType: keyTypeAuth, + Description: pak.Description, + Created: timeOrZero(pak.CreatedAt), + Invalid: pak.Validate() != nil, + Capabilities: capabilities(pak.Reusable, pak.Ephemeral, true, pak.Tags), + Tags: pak.Tags, + } + + if pak.Expiration != nil { + key.Expires = pak.Expiration + key.ExpirySeconds = expirySeconds(pak.CreatedAt, pak.Expiration) + } + + if len(pak.Tags) == 0 && pak.User != nil { + key.UserID = pak.User.StringID() + } + + return key +} + +func capabilities( + reusable, ephemeral, preauthorized bool, + tags []string, +) KeyCapabilities { + return KeyCapabilities{ + Devices: KeyDeviceCapabilities{Create: KeyDeviceCreateCapabilities{ + Reusable: reusable, + Ephemeral: ephemeral, + Preauthorized: preauthorized, + Tags: emptyIfNil(tags), + }}, + } +} + +func expiryDuration(seconds int64) time.Duration { + if seconds <= 0 { + return defaultExpiry + } + + return time.Duration(seconds) * time.Second +} From ff7dd807fee641e27bf5cffa97fadb7268399259 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH 079/127] hscontrol: mount the v2 API at /api/v2 Wire the v2 mux into the router beside v1, threading the state, change sink, and config through the Backend. --- hscontrol/app.go | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/hscontrol/app.go b/hscontrol/app.go index 46a26d66d..af19af9b9 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -25,6 +25,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/metrics" apiv1 "github.com/juanfont/headscale/hscontrol/api/v1" + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" "github.com/juanfont/headscale/hscontrol/capver" "github.com/juanfont/headscale/hscontrol/db" "github.com/juanfont/headscale/hscontrol/derp" @@ -408,7 +409,7 @@ func serveHumaMux(mux http.Handler) http.HandlerFunc { } } -func (h *Headscale) createRouter(apiMux http.Handler) *chi.Mux { +func (h *Headscale) createRouter(apiV1Mux, apiV2Mux http.Handler) *chi.Mux { r := chi.NewRouter() r.Use(metrics.Collector(metrics.CollectorOpts{ Host: false, @@ -454,11 +455,13 @@ func (h *Headscale) createRouter(apiMux http.Handler) *chi.Mux { r.HandleFunc("/bootstrap-dns", derpServer.DERPBootstrapDNSHandler(h.state.DERPMap())) } - // Auth is enforced inside the Huma mux per-operation (bearer security), so - // the whole API mounts as one handler: operations need an API key while the - // OpenAPI document and docs UI stay public. A v2 mux is one more r.Handle line. + // Auth is enforced inside each Huma mux per-operation, so the whole API + // mounts as one handler per version: operations need an API key while the + // OpenAPI document and docs UI stay public. v1 is the headscale-native admin + // API; v2 is Headscale's v2 API, which ports some endpoints from Tailscale. r.Route("/api", func(r chi.Router) { - r.Handle("/v1/*", serveHumaMux(apiMux)) + r.Handle("/v1/*", serveHumaMux(apiV1Mux)) + r.Handle("/v2/*", serveHumaMux(apiV2Mux)) }) // Ping response endpoint: receives HEAD from clients responding // to a [tailcfg.PingRequest]. The unguessable ping ID serves as authentication. @@ -605,6 +608,15 @@ func (h *Headscale) Serve() error { Cfg: h.cfg, }) + // The Headscale v2 API. It is served only over the remote + // listener (Basic/Bearer auth); no unix-socket mount yet, as nothing local + // calls it — the CLI still speaks v1. + humaV2Mux, _ := apiv2.Handler(apiv2.Backend{ + State: h.state, + Change: h.Change, + Cfg: h.cfg, + }) + // Serve the Huma API over the unix socket without TLS or auth: socket access // implies trust. WithLocalTrust marks these requests so the security // middleware skips the API-key check. @@ -631,7 +643,7 @@ func (h *Headscale) Serve() error { // // This is the regular router that we expose // over our main Addr - router := h.createRouter(humaMux) + router := h.createRouter(humaMux, humaV2Mux) httpServer := &http.Server{ Addr: h.cfg.Addr, @@ -960,7 +972,13 @@ func (h *Headscale) HTTPHandler() http.Handler { Cfg: h.cfg, }) - return h.createRouter(humaMux) + humaV2Mux, _ := apiv2.Handler(apiv2.Backend{ + State: h.state, + Change: h.Change, + Cfg: h.cfg, + }) + + return h.createRouter(humaMux, humaV2Mux) } // NoisePublicKey returns the server's Noise protocol public key. From 6b413fe23729458df76c517aca1ec23a8931fe3b Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH 080/127] api/v2: soft-revoke auth keys with a configurable collector Tailscale's keys API has no separate expire verb: DELETE is the revoke. Map it to a soft revoke so the key stays retrievable as invalid afterwards instead of vanishing, matching the SDK and Terraform's expectations. Add a revoked timestamp to pre-auth keys (migration plus schema), mark a key invalid once revoked, and have the keys DELETE handler stamp it rather than destroy the row. A background collector reaps revoked keys after a configurable retention window (preauth_keys.revoked_retention, default 168h), so the table does not grow without bound. --- hscontrol/api/v2/keys.go | 11 +++++-- hscontrol/app.go | 19 ++++++++++++ hscontrol/db/db.go | 17 ++++++++++ hscontrol/db/preauth_keys.go | 57 ++++++++++++++++++++++++++++++++++ hscontrol/db/schema.sql | 1 + hscontrol/state/state.go | 12 +++++++ hscontrol/types/config.go | 14 +++++++++ hscontrol/types/preauth_key.go | 9 ++++++ hscontrol/types/types_clone.go | 4 +++ hscontrol/types/types_view.go | 8 +++++ 10 files changed, 150 insertions(+), 2 deletions(-) diff --git a/hscontrol/api/v2/keys.go b/hscontrol/api/v2/keys.go index 9ffb0a99c..9ce5f26fb 100644 --- a/hscontrol/api/v2/keys.go +++ b/hscontrol/api/v2/keys.go @@ -249,9 +249,12 @@ func registerKeys(api huma.API, b Backend) { return nil, err } - err = b.State.DeletePreAuthKey(id) + // Tailscale's DELETE revokes the key but keeps it retrievable (invalid) + // rather than destroying it; the collector reaps it after the retention + // window. + err = b.State.RevokePreAuthKey(id) if err != nil { - return nil, mapError("deleting auth key", err) + return nil, mapError("revoking auth key", err) } return &deleteKeyOutput{}, nil @@ -330,6 +333,10 @@ func keyFromStored(pak *types.PreAuthKey) Key { key.ExpirySeconds = expirySeconds(pak.CreatedAt, pak.Expiration) } + if pak.Revoked != nil { + key.Revoked = pak.Revoked + } + if len(pak.Tags) == 0 && pak.User != nil { key.UserID = pak.User.StringID() } diff --git a/hscontrol/app.go b/hscontrol/app.go index af19af9b9..7564c9094 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -310,12 +310,31 @@ func (h *Headscale) scheduledTasks(ctx context.Context) { Msg("HA subnet router health probing enabled") } + var revokedKeyGCChan <-chan time.Time + + if h.cfg.PreAuthKeys.RevokedRetention > 0 { + revokedKeyTicker := time.NewTicker(time.Hour) + defer revokedKeyTicker.Stop() + + revokedKeyGCChan = revokedKeyTicker.C + } + for { select { case <-ctx.Done(): log.Info().Caller().Msg("scheduled task worker is shutting down.") return + case <-revokedKeyGCChan: + cutoff := time.Now().Add(-h.cfg.PreAuthKeys.RevokedRetention) + + reaped, err := h.state.DestroyRevokedPreAuthKeysBefore(cutoff) + if err != nil { + log.Error().Err(err).Msg("reaping revoked pre-auth keys") + } else if reaped > 0 { + log.Info().Int("count", reaped).Msg("reaped revoked pre-auth keys") + } + case <-expireTicker.C: var ( expiredNodeChanges []change.Change diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index ea9b80859..af2a6d349 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -814,6 +814,23 @@ WHERE user_id IS NULL }, Rollback: func(db *gorm.DB) error { return nil }, }, + { + // Add a revoked timestamp to pre-auth keys. The v2 API's DELETE + // soft-revokes a key (set revoked = now) rather than destroying + // it; the row is reaped later by the background collector. + ID: "202606201200-pre-auth-key-revoked", + Migrate: func(tx *gorm.DB) error { + if !tx.Migrator().HasColumn(&types.PreAuthKey{}, "revoked") { + err := tx.Migrator().AddColumn(&types.PreAuthKey{}, "revoked") + if err != nil { + return fmt.Errorf("adding revoked to pre_auth_keys: %w", err) + } + } + + return nil + }, + Rollback: func(db *gorm.DB) error { return nil }, + }, }, ) diff --git a/hscontrol/db/preauth_keys.go b/hscontrol/db/preauth_keys.go index ff2c7a301..1da35ac36 100644 --- a/hscontrol/db/preauth_keys.go +++ b/hscontrol/db/preauth_keys.go @@ -357,6 +357,63 @@ func (hsdb *HSDatabase) DeletePreAuthKey(id uint64) error { }) } +func (hsdb *HSDatabase) RevokePreAuthKey(id uint64) error { + return hsdb.Write(func(tx *gorm.DB) error { + return RevokePreAuthKey(tx, id) + }) +} + +// RevokePreAuthKey soft-revokes a key (the v2 API's DELETE): the row is kept and +// stays retrievable with its invalid flag set, but the key can no longer +// authorize nodes. The background collector hard-deletes it after the retention +// window. An already-revoked or unknown id returns [ErrPreAuthKeyNotFound], so a +// repeated DELETE is a clean 404. +func RevokePreAuthKey(tx *gorm.DB, id uint64) error { + res := tx.Model(&types.PreAuthKey{}). + Where("id = ? AND revoked IS NULL", id). + Update("revoked", time.Now()) + if res.Error != nil { + return res.Error + } + + if res.RowsAffected == 0 { + return ErrPreAuthKeyNotFound + } + + return nil +} + +// DestroyRevokedPreAuthKeysBefore hard-deletes every key revoked before cutoff, +// returning how many were removed. The background collector calls this to reap +// soft-revoked keys after the retention window. +func (hsdb *HSDatabase) DestroyRevokedPreAuthKeysBefore(cutoff time.Time) (int, error) { + var count int + + err := hsdb.Write(func(tx *gorm.DB) error { + var ids []uint64 + + err := tx.Model(&types.PreAuthKey{}). + Where("revoked IS NOT NULL AND revoked < ?", cutoff). + Pluck("id", &ids).Error + if err != nil { + return err + } + + for _, id := range ids { + err := DestroyPreAuthKey(tx, id) + if err != nil { + return err + } + } + + count = len(ids) + + return nil + }) + + return count, err +} + // UsePreAuthKey atomically marks a [types.PreAuthKey] as used. The UPDATE is // guarded by `used = false` so two concurrent registrations racing for // the same single-use key cannot both succeed: the first commits and diff --git a/hscontrol/db/schema.sql b/hscontrol/db/schema.sql index d7172c56e..6343ad81c 100644 --- a/hscontrol/db/schema.sql +++ b/hscontrol/db/schema.sql @@ -50,6 +50,7 @@ CREATE TABLE pre_auth_keys( used numeric DEFAULT false, tags text, expiration datetime, + revoked datetime, created_at datetime, diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index 6068dbbea..c4d70efd9 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -1479,6 +1479,18 @@ func (s *State) GetPreAuthKeyByID(id uint64) (*types.PreAuthKey, error) { return s.db.GetPreAuthKeyByID(id) } +// RevokePreAuthKey soft-revokes a pre-authentication key: it is kept and stays +// retrievable (invalid) until the collector reaps it after the retention window. +func (s *State) RevokePreAuthKey(id uint64) error { + return s.db.RevokePreAuthKey(id) +} + +// DestroyRevokedPreAuthKeysBefore hard-deletes pre-auth keys revoked before +// cutoff, returning how many were removed. +func (s *State) DestroyRevokedPreAuthKeysBefore(cutoff time.Time) (int, error) { + return s.db.DestroyRevokedPreAuthKeysBefore(cutoff) +} + // ListPreAuthKeys returns all pre-authentication keys for a user. func (s *State) ListPreAuthKeys() ([]types.PreAuthKey, error) { return s.db.ListPreAuthKeys() diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 852c2b625..4ef0ba343 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -78,6 +78,14 @@ type RouteConfig struct { HA HARouteConfig } +// PreAuthKeysConfig contains configuration for pre-auth key lifecycle. +type PreAuthKeysConfig struct { + // RevokedRetention is how long a soft-revoked pre-auth key (revoked via the + // v2 API's DELETE) is kept retrievable before the background collector + // hard-deletes it. A zero or negative duration disables the collector. + RevokedRetention time.Duration +} + // NodeConfig contains configuration for node lifecycle and expiry. type NodeConfig struct { // Expiry is the default key expiry duration for non-tagged nodes. @@ -100,6 +108,7 @@ type Config struct { MetricsAddr string TrustedProxies []netip.Prefix Node NodeConfig + PreAuthKeys PreAuthKeysConfig PrefixV4 *netip.Prefix PrefixV6 *netip.Prefix IPAllocation IPAllocationStrategy @@ -440,6 +449,7 @@ func LoadConfig(path string, isFile bool) error { viper.SetDefault("node.expiry", "0") viper.SetDefault("node.ephemeral.inactivity_timeout", "120s") + viper.SetDefault("preauth_keys.revoked_retention", "168h") viper.SetDefault("node.routes.ha.probe_interval", "10s") viper.SetDefault("node.routes.ha.probe_timeout", "5s") @@ -1204,6 +1214,10 @@ func LoadServerConfig() (*Config, error) { }, }, + PreAuthKeys: PreAuthKeysConfig{ + RevokedRetention: viper.GetDuration("preauth_keys.revoked_retention"), + }, + Database: databaseConfig(), TLS: tlsConfig(), diff --git a/hscontrol/types/preauth_key.go b/hscontrol/types/preauth_key.go index 30e4a627f..27502f3d4 100644 --- a/hscontrol/types/preauth_key.go +++ b/hscontrol/types/preauth_key.go @@ -66,6 +66,11 @@ type PreAuthKey struct { CreatedAt *time.Time Expiration *time.Time + + // Revoked is set when the key is revoked through the v2 API (Tailscale's + // DELETE). A revoked key is invalid but kept retrievable until the + // background collector reaps it after the configured retention window. + Revoked *time.Time } // PreAuthKeyNew is returned once when the key is created. @@ -92,6 +97,10 @@ func (pak *PreAuthKey) Validate() error { EmbedObject(pak). Msg("PreAuthKey.Validate: checking key") + if pak.Revoked != nil { + return PAKError("authkey revoked") + } + if pak.Expiration != nil && pak.Expiration.Before(time.Now()) { return PAKError("authkey expired") } diff --git a/hscontrol/types/types_clone.go b/hscontrol/types/types_clone.go index f3587ba04..1040e2142 100644 --- a/hscontrol/types/types_clone.go +++ b/hscontrol/types/types_clone.go @@ -132,6 +132,9 @@ func (src *PreAuthKey) Clone() *PreAuthKey { if dst.Expiration != nil { dst.Expiration = new(*src.Expiration) } + if dst.Revoked != nil { + dst.Revoked = new(*src.Revoked) + } return dst } @@ -150,4 +153,5 @@ var _PreAuthKeyCloneNeedsRegeneration = PreAuthKey(struct { Tags []string CreatedAt *time.Time Expiration *time.Time + Revoked *time.Time }{}) diff --git a/hscontrol/types/types_view.go b/hscontrol/types/types_view.go index 312059c3a..6adcdda12 100644 --- a/hscontrol/types/types_view.go +++ b/hscontrol/types/types_view.go @@ -416,6 +416,13 @@ func (v PreAuthKeyView) Expiration() views.ValuePointer[time.Time] { return views.ValuePointerOf(v.ж.Expiration) } +// Revoked is set when the key is revoked through the v2 API (Tailscale's +// DELETE). A revoked key is invalid but kept retrievable until the +// background collector reaps it after the configured retention window. +func (v PreAuthKeyView) Revoked() views.ValuePointer[time.Time] { + return views.ValuePointerOf(v.ж.Revoked) +} + // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _PreAuthKeyViewNeedsRegeneration = PreAuthKey(struct { ID uint64 @@ -431,4 +438,5 @@ var _PreAuthKeyViewNeedsRegeneration = PreAuthKey(struct { Tags []string CreatedAt *time.Time Expiration *time.Time + Revoked *time.Time }{}) From b3472bb8dd554474d9473d011b442c73b37b9208 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH 081/127] api/v2: add Tailscale-compatible devices API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map the Tailscale device surface — get, list, delete, set name, tags, key expiry, routes, and authorization — onto Headscale nodes. --- hscontrol/api/v2/devices.go | 472 ++++++++++++++++++++++++++++++++++++ 1 file changed, 472 insertions(+) create mode 100644 hscontrol/api/v2/devices.go diff --git a/hscontrol/api/v2/devices.go b/hscontrol/api/v2/devices.go new file mode 100644 index 000000000..23eb7cacd --- /dev/null +++ b/hscontrol/api/v2/devices.go @@ -0,0 +1,472 @@ +package apiv2 + +import ( + "context" + "net/http" + "net/netip" + "slices" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" + "tailscale.com/net/tsaddr" +) + +func init() { + registrations = append(registrations, registerDevices) +} + +// Device is the Tailscale device response. Headscale nodes map onto it; fields +// Headscale does not track are emitted as their zero value. The route slices +// are only populated for the fields=all variant, matching Tailscale. +type Device struct { + Addresses []string `json:"addresses" nullable:"false"` + Name string `json:"name"` + ID string `json:"id"` + NodeID string `json:"nodeId"` + Authorized bool `json:"authorized"` + User string `json:"user"` + Tags []string `json:"tags" nullable:"false"` + KeyExpiryDisabled bool `json:"keyExpiryDisabled"` + Created time.Time `json:"created"` + Expires *time.Time `json:"expires,omitempty"` + Hostname string `json:"hostname"` + IsEphemeral bool `json:"isEphemeral"` + LastSeen *time.Time `json:"lastSeen,omitempty"` + MachineKey string `json:"machineKey"` + NodeKey string `json:"nodeKey"` + OS string `json:"os"` + ClientVersion string `json:"clientVersion"` + UpdateAvailable bool `json:"updateAvailable"` + + // Populated only when fields=all is requested. + AdvertisedRoutes []string `json:"advertisedRoutes,omitempty"` + EnabledRoutes []string `json:"enabledRoutes,omitempty"` +} + +// DeviceRoutes is the GET /device/{id}/routes response: what the node announces +// vs which of those are approved (Tailscale calls approved routes "enabled"). +type DeviceRoutes struct { + Advertised []string `json:"advertisedRoutes" nullable:"false"` + Enabled []string `json:"enabledRoutes" nullable:"false"` +} + +// Request bodies match the Tailscale SDK wire shapes. +type ( + setAuthorizedRequest struct { + Authorized bool `json:"authorized"` + } + setNameRequest struct { + Name string `json:"name"` + } + // setTagsRequest.Tags is intentionally not nullable:"false": the SDK sends + // "tags":null for "make untagged", which the handler accepts as a no-op + // rather than failing to decode (Headscale cannot untag a node). + setTagsRequest struct { + Tags []string `json:"tags"` + } + setKeyRequest struct { + KeyExpiryDisabled bool `json:"keyExpiryDisabled"` + } + setSubnetRoutesRequest struct { + Routes []string `json:"routes" nullable:"false"` + } +) + +type ( + deviceByIDInput struct { + DeviceID string `doc:"Device id (the decimal node id)." path:"id"` + Fields string `doc:"Set to \"all\" for route fields." query:"fields"` + } + listDevicesInput struct { + Tailnet string `doc:"Tailnet; must be \"-\"." path:"tailnet"` + Fields string `query:"fields"` + } + setAuthorizedInput struct { + DeviceID string `path:"id"` + Body setAuthorizedRequest + } + setNameInput struct { + DeviceID string `path:"id"` + Body setNameRequest + } + setTagsInput struct { + DeviceID string `path:"id"` + Body setTagsRequest + } + setKeyInput struct { + DeviceID string `path:"id"` + Body setKeyRequest + } + setSubnetRoutesInput struct { + DeviceID string `path:"id"` + Body setSubnetRoutesRequest + } + + deviceOutput struct{ Body Device } + deviceRoutesOutput struct{ Body DeviceRoutes } + listDevicesOutput struct { + Body struct { + Devices []Device `json:"devices" nullable:"false"` + } + } + emptyOutput struct{ Body struct{} } +) + +func registerDevices(api huma.API, b Backend) { + deviceTags := []string{"Devices", "Tailscale compat"} + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "getDevice", + Method: http.MethodGet, + Path: "/api/v2/device/{id}", + Summary: "Get a device", + Tags: deviceTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeDevicesCoreRead), func(ctx context.Context, in *deviceByIDInput) (*deviceOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + return &deviceOutput{Body: deviceFromView(node, in.Fields == "all")}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "listDevices", + Method: http.MethodGet, + Path: "/api/v2/tailnet/{tailnet}/devices", + Summary: "List devices", + Tags: deviceTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeDevicesCoreRead), func(ctx context.Context, in *listDevicesInput) (*listDevicesOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + nodes := b.State.ListNodes() + allFields := in.Fields == "all" + + out := &listDevicesOutput{} + out.Body.Devices = make([]Device, 0, nodes.Len()) + + for _, node := range nodes.All() { + out.Body.Devices = append(out.Body.Devices, deviceFromView(node, allFields)) + } + + return out, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "deleteDevice", + Method: http.MethodDelete, + Path: "/api/v2/device/{id}", + Summary: "Delete a device", + Tags: deviceTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeDevicesCore), func(ctx context.Context, in *deviceByIDInput) (*emptyOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + nodeChange, err := b.State.DeleteNode(node) + if err != nil { + return nil, mapError("deleting device", err) + } + + b.Change(nodeChange) + + return &emptyOutput{}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "authorizeDevice", + Method: http.MethodPost, + Path: "/api/v2/device/{id}/authorized", + Summary: "Authorize a device", + Tags: deviceTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeDevicesCore), func(ctx context.Context, in *setAuthorizedInput) (*emptyOutput, error) { + _, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + // Headscale nodes are authorized the moment they register; there is no + // de-authorize state. Accept authorized=true as a no-op; reject false so + // callers are not misled into thinking the device is fenced off. + if !in.Body.Authorized { + return nil, huma.Error400BadRequest( + "Headscale does not support de-authorizing a device; delete or expire it instead", + ) + } + + return &emptyOutput{}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "setDeviceName", + Method: http.MethodPost, + Path: "/api/v2/device/{id}/name", + Summary: "Set a device's name", + Tags: deviceTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeDevicesCore), func(ctx context.Context, in *setNameInput) (*emptyOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + _, nodeChange, err := b.State.RenameNode(node.ID(), in.Body.Name) + if err != nil { + return nil, mapError("renaming device", err) + } + + b.Change(nodeChange) + + return &emptyOutput{}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "setDeviceTags", + Method: http.MethodPost, + Path: "/api/v2/device/{id}/tags", + Summary: "Set a device's tags", + Tags: deviceTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeDevicesCore), func(ctx context.Context, in *setTagsInput) (*emptyOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + // Headscale cannot make a node untagged (tags-as-identity is one-way), so + // an empty/null tag set is accepted as a no-op rather than rejected. This + // keeps tooling lifecycles such as Terraform destroy working; the SDK + // sends "tags":null for "make untagged". + if len(in.Body.Tags) == 0 { + return &emptyOutput{}, nil + } + + _, nodeChange, err := b.State.SetNodeTags(node.ID(), in.Body.Tags) + if err != nil { + return nil, mapError("setting device tags", err) + } + + b.Change(nodeChange) + + return &emptyOutput{}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "setDeviceKey", + Method: http.MethodPost, + Path: "/api/v2/device/{id}/key", + Summary: "Set a device's key settings", + Tags: deviceTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeDevicesCore), func(ctx context.Context, in *setKeyInput) (*emptyOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + // Only disabling expiry maps cleanly (expiry=nil never expires). + // Re-enabling has no target expiry in the Tailscale request and Headscale + // stores no original, so it is accepted as a no-op (keeps Terraform + // destroy working) rather than guessing a lifetime. + if !in.Body.KeyExpiryDisabled { + return &emptyOutput{}, nil + } + + _, nodeChange, err := b.State.SetNodeExpiry(node.ID(), nil) + if err != nil { + return nil, mapError("setting device key expiry", err) + } + + b.Change(nodeChange) + + return &emptyOutput{}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "setDeviceRoutes", + Method: http.MethodPost, + Path: "/api/v2/device/{id}/routes", + Summary: "Set a device's enabled subnet routes", + Tags: deviceTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeDevicesRoutes), func(ctx context.Context, in *setSubnetRoutesInput) (*deviceRoutesOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + approved, err := parseRoutes(in.Body.Routes) + if err != nil { + return nil, err + } + + updated, nodeChange, err := b.State.SetApprovedRoutes(node.ID(), approved) + if err != nil { + return nil, mapError("setting device routes", err) + } + + b.Change(nodeChange) + + return &deviceRoutesOutput{Body: routesFromView(updated)}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "getDeviceRoutes", + Method: http.MethodGet, + Path: "/api/v2/device/{id}/routes", + Summary: "Get a device's subnet routes", + Tags: deviceTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeDevicesRoutesRead), func(ctx context.Context, in *deviceByIDInput) (*deviceRoutesOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + return &deviceRoutesOutput{Body: routesFromView(node)}, nil + }) +} + +// lookupNode resolves a device id to its NodeView, mapping a malformed or +// unknown id to 404 (the Tailscale SDK keys IsNotFound off the status code). +func lookupNode(b Backend, rawID string) (types.NodeView, error) { + nodeID, err := parseNodeID(rawID) + if err != nil { + return types.NodeView{}, err + } + + node, ok := b.State.GetNodeByID(nodeID) + if !ok { + return types.NodeView{}, huma.Error404NotFound("device not found") + } + + return node, nil +} + +// parseRoutes parses the enabled-route strings, expanding an exit route to both +// families (else the node is not annotated as an exit node), then sorts/dedups. +func parseRoutes(routes []string) ([]netip.Prefix, error) { + var approved []netip.Prefix + + for _, route := range routes { + prefix, err := netip.ParsePrefix(route) + if err != nil { + return nil, huma.Error400BadRequest("parsing route", err) + } + + if prefix == tsaddr.AllIPv4() || prefix == tsaddr.AllIPv6() { + approved = append(approved, tsaddr.AllIPv4(), tsaddr.AllIPv6()) + } else { + approved = append(approved, prefix) + } + } + + slices.SortFunc(approved, netip.Prefix.Compare) + + return slices.Compact(approved), nil +} + +// deviceFromView maps a Headscale node onto the Tailscale Device, reading +// through the [types.NodeView] accessors. allFields gates the route slices, +// which Tailscale only returns for fields=all. +func deviceFromView(view types.NodeView, allFields bool) Device { + id := view.StringID() + + d := Device{ + Addresses: emptyIfNil(view.IPsAsString()), + Name: view.GivenName(), + ID: id, + NodeID: id, + Authorized: true, // Headscale has no post-registration de-auth state. + User: deviceUser(view), + Tags: emptyIfNil(view.Tags().AsSlice()), + KeyExpiryDisabled: !view.Expiry().Valid(), + Created: view.CreatedAt(), + Hostname: view.Hostname(), + IsEphemeral: view.IsEphemeral(), + MachineKey: view.MachineKey().String(), + NodeKey: view.NodeKey().String(), + OS: hostinfoOS(view), + ClientVersion: hostinfoVersion(view), + } + + if view.Expiry().Valid() { + exp := view.Expiry().Get() + d.Expires = &exp + } + + // LastSeen is reported only when the device is offline, matching tailcfg. + if view.LastSeen().Valid() && view.IsOnline().Valid() && !view.IsOnline().Get() { + ls := view.LastSeen().Get() + d.LastSeen = &ls + } + + if allFields { + d.AdvertisedRoutes = emptyIfNil(util.PrefixesToString(view.AnnouncedRoutes())) + d.EnabledRoutes = emptyIfNil(util.PrefixesToString(view.ApprovedRoutes().AsSlice())) + } + + return d +} + +func routesFromView(view types.NodeView) DeviceRoutes { + return DeviceRoutes{ + Advertised: emptyIfNil(util.PrefixesToString(view.AnnouncedRoutes())), + Enabled: emptyIfNil(util.PrefixesToString(view.ApprovedRoutes().AsSlice())), + } +} + +// deviceUser is the owning login. [types.NodeView.Owner] already resolves the +// tags-as-identity rule: tagged nodes present the special TaggedDevices user, +// user-owned nodes present their login, orphaned nodes present nothing. +func deviceUser(view types.NodeView) string { + owner := view.Owner() + if owner.Valid() { + return owner.Username() + } + + return "" +} + +// hostinfoOS / hostinfoVersion read Hostinfo fields, returning "" when the node +// has not reported Hostinfo yet. +func hostinfoOS(view types.NodeView) string { + if hi := view.Hostinfo(); hi.Valid() { + return hi.OS() + } + + return "" +} + +func hostinfoVersion(view types.NodeView) string { + if hi := view.Hostinfo(); hi.Valid() { + return hi.IPNVersion() + } + + return "" +} From 3cddc07026025f727fb780dcf4475343f89b5cef Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH 082/127] api/v2: add Tailscale-compatible ACL endpoint Serve and replace the policy as HuJSON with content-addressed ETags and If-Match preconditions; writes are rejected in file mode. --- hscontrol/api/v2/acl.go | 229 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 hscontrol/api/v2/acl.go diff --git a/hscontrol/api/v2/acl.go b/hscontrol/api/v2/acl.go new file mode 100644 index 000000000..8129e7670 --- /dev/null +++ b/hscontrol/api/v2/acl.go @@ -0,0 +1,229 @@ +package apiv2 + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "os" + "strings" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" +) + +func init() { + registrations = append(registrations, registerACL) +} + +const ( + contentTypeJSON = "application/json" + contentTypeHuJSON = "application/hujson" +) + +// defaultPolicy is served by GET when no policy is set, and is what an If-Match +// of "ts-default" matches against. Allow-all, matching the cluster's behaviour +// with no configured policy. +const defaultPolicy = `{ + // Headscale default policy. Allows all communication. + "acls": [ + {"action": "accept", "src": ["*"], "dst": ["*:*"]}, + ], +} +` + +type getACLInput struct { + Tailnet string `path:"tailnet"` + Accept string `header:"Accept"` + Details bool `doc:"Accepted for compatibility; ignored." query:"details"` +} + +type setACLInput struct { + Tailnet string `path:"tailnet"` + IfMatch string `header:"If-Match"` + Accept string `header:"Accept"` + // RawBody captures the HuJSON or JSON policy body verbatim; huma feeds the + // raw request bytes here regardless of Content-Type. The declared type only + // shapes the OpenAPI schema. + RawBody []byte `contentType:"application/json"` +} + +func registerACL(api huma.API, b Backend) { + aclTags := []string{"Policy", "Tailscale compat"} + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "getACL", + Method: http.MethodGet, + Path: "/api/v2/tailnet/{tailnet}/acl", + Summary: "Get the policy file", + Tags: aclTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusInternalServerError}, + }, ScopePolicyFileRead), func(ctx context.Context, in *getACLInput) (*huma.StreamResponse, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + data, err := currentPolicy(b) + if err != nil { + return nil, err + } + + return streamPolicy(data, aclContentType(in.Accept)), nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "setACL", + Method: http.MethodPost, + Path: "/api/v2/tailnet/{tailnet}/acl", + Summary: "Set the policy file", + Tags: aclTags, + Security: security, + DefaultStatus: http.StatusOK, + // The body is an opaque HuJSON document captured raw; skip huma's + // schema validation (which would expect a base64 string). + SkipValidateBody: true, + Errors: []int{ + http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, + http.StatusNotFound, http.StatusPreconditionFailed, http.StatusInternalServerError, + }, + }, ScopePolicyFile), func(ctx context.Context, in *setACLInput) (*huma.StreamResponse, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + if b.Cfg.Policy.Mode != types.PolicyModeDB { + return nil, huma.Error400BadRequest( + types.ErrPolicyUpdateIsDisabled.Error(), types.ErrPolicyUpdateIsDisabled, + ) + } + + if in.IfMatch != "" { + current, err := currentPolicy(b) + if err != nil { + return nil, err + } + + if !etagMatches(in.IfMatch, current) { + return nil, huma.Error412PreconditionFailed("precondition failed, invalid old hash") + } + } + + // Mirror the v1 setPolicy flow: validate, SSH-check, persist, reload. + nodes := b.State.ListNodes() + + _, err = b.State.SetPolicy(in.RawBody) + if err != nil { + return nil, huma.Error400BadRequest("setting policy", err) + } + + if nodes.Len() > 0 { + _, err = b.State.SSHPolicy(nodes.At(0)) + if err != nil { + return nil, huma.Error400BadRequest("verifying SSH rules", err) + } + } + + updated, err := b.State.SetPolicyInDB(string(in.RawBody)) + if err != nil { + return nil, huma.Error500InternalServerError("setting policy", err) + } + + cs, err := b.State.ReloadPolicy() + if err != nil { + return nil, huma.Error500InternalServerError("reloading policy", err) + } + + if len(cs) > 0 { + b.Change(cs...) + } + + return streamPolicy([]byte(updated.Data), aclContentType(in.Accept)), nil + }) +} + +// currentPolicy returns the stored policy bytes, or the allow-all default when +// none is set. File mode reads the configured path; DB mode reads the database. +func currentPolicy(b Backend) ([]byte, error) { + switch b.Cfg.Policy.Mode { + case types.PolicyModeDB: + p, err := b.State.GetPolicy() + if err != nil { + if errors.Is(err, types.ErrPolicyNotFound) { + return []byte(defaultPolicy), nil + } + + return nil, huma.Error500InternalServerError("loading policy", err) + } + + if p.Data == "" { + return []byte(defaultPolicy), nil + } + + return []byte(p.Data), nil + + case types.PolicyModeFile: + path := util.AbsolutePathFromConfigPath(b.Cfg.Policy.Path) + if path == "" { + return []byte(defaultPolicy), nil + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, huma.Error500InternalServerError("reading policy file", err) + } + + return data, nil + } + + return nil, huma.Error500InternalServerError("unsupported policy mode", nil) +} + +// streamPolicy writes the policy bytes verbatim with the chosen content type and +// a content-addressed ETag, bypassing huma's JSON marshaler so HuJSON survives. +func streamPolicy(data []byte, contentType string) *huma.StreamResponse { + return &huma.StreamResponse{Body: func(ctx huma.Context) { + ctx.SetHeader("Content-Type", contentType) + ctx.SetHeader("ETag", policyETag(data)) + ctx.SetStatus(http.StatusOK) + _, _ = ctx.BodyWriter().Write(data) + }} +} + +// aclContentType serves HuJSON only when explicitly asked; everything else +// (including an empty Accept) gets application/json. The bytes are identical. +func aclContentType(accept string) string { + if strings.Contains(accept, contentTypeHuJSON) { + return contentTypeHuJSON + } + + return contentTypeJSON +} + +// policyETag is the quoted hex SHA-256 of the policy bytes: stable across reads, +// changes iff the policy changes. +func policyETag(data []byte) string { + sum := sha256.Sum256(data) + + return `"` + hex.EncodeToString(sum[:]) + `"` +} + +// etagMatches reports whether an If-Match header satisfies the current policy. +// "*" always matches; "ts-default" matches only when no policy is set (an +// approximation of Tailscale's untouched-default semantics). +func etagMatches(ifMatch string, current []byte) bool { + ifMatch = strings.TrimSpace(ifMatch) + + switch ifMatch { + case "*": + return true + case `"ts-default"`, "ts-default": + return string(current) == defaultPolicy + default: + return ifMatch == policyETag(current) + } +} From e2e9fe3081b459efe02754ac7c9d16c75b65a02c Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH 083/127] api/v2: add read-only tailnet settings endpoint Report the settings the k8s operator reads, computed from config; writes return 501. --- hscontrol/api/v2/settings.go | 99 ++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 hscontrol/api/v2/settings.go diff --git a/hscontrol/api/v2/settings.go b/hscontrol/api/v2/settings.go new file mode 100644 index 000000000..cdd2bea03 --- /dev/null +++ b/hscontrol/api/v2/settings.go @@ -0,0 +1,99 @@ +package apiv2 + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/types" +) + +func init() { + registrations = append(registrations, registerSettings) +} + +// TailnetSettings is the Tailscale tailnet-settings response. Headscale's config +// is file-based and mostly not runtime-mutable, so only a few fields carry a +// real value; the rest report the honest "off"/default. +type TailnetSettings struct { + ACLsExternallyManagedOn bool `json:"aclsExternallyManagedOn"` + ACLsExternalLink string `json:"aclsExternalLink"` + + DevicesApprovalOn bool `json:"devicesApprovalOn"` + DevicesAutoUpdatesOn bool `json:"devicesAutoUpdatesOn"` + DevicesKeyDurationDays int `json:"devicesKeyDurationDays"` + + UsersApprovalOn bool `json:"usersApprovalOn"` + UsersRoleAllowedToJoinExternalTailnets string `json:"usersRoleAllowedToJoinExternalTailnets"` + + NetworkFlowLoggingOn bool `json:"networkFlowLoggingOn"` + RegionalRoutingOn bool `json:"regionalRoutingOn"` + PostureIdentityCollectionOn bool `json:"postureIdentityCollectionOn"` + HTTPSEnabled bool `json:"httpsEnabled"` +} + +type ( + getSettingsInput struct { + Tailnet string `path:"tailnet"` + } + settingsOutput struct { + Body TailnetSettings + } + patchSettingsInput struct { + Tailnet string `path:"tailnet"` + // Accepted and ignored; updating settings is not supported. + Body json.RawMessage + } +) + +func registerSettings(api huma.API, b Backend) { + settingsTags := []string{"TailnetSettings", "Tailscale compat"} + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "getTailnetSettings", + Method: http.MethodGet, + Path: "/api/v2/tailnet/{tailnet}/settings", + Summary: "Get tailnet settings", + Tags: settingsTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeFeatureSettingsRead), func(ctx context.Context, in *getSettingsInput) (*settingsOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + cfg := b.Cfg + + return &settingsOutput{Body: TailnetSettings{ + // File-mode policy is genuinely externally managed (read-only via API). + ACLsExternallyManagedOn: cfg.Policy.Mode == types.PolicyModeFile, + DevicesKeyDurationDays: int(cfg.Node.Expiry / (24 * time.Hour)), + HTTPSEnabled: cfg.TLS.CertPath != "" || cfg.TLS.LetsEncrypt.Hostname != "", + UsersRoleAllowedToJoinExternalTailnets: "none", + }}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "updateTailnetSettings", + Method: http.MethodPatch, + Path: "/api/v2/tailnet/{tailnet}/settings", + Summary: "Update tailnet settings", + Tags: settingsTags, + Security: security, + // The body is accepted but ignored; skip validation. + SkipValidateBody: true, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented}, + }, ScopeFeatureSettings), func(ctx context.Context, in *patchSettingsInput) (*settingsOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + return nil, huma.Error501NotImplemented( + "updating tailnet settings is not supported by Headscale", + ) + }) +} From 8898c69d0a784fb3a6f5c61f46fc0fc4a3082cf9 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH 084/127] flake: add opentofu and tscli for v2 API tests Provide tofu and tscli in the dev shell, and add the Tailscale Go client the roundtrip tests drive. --- flake.nix | 5 +++++ flakehashes.json | 4 ++-- go.mod | 1 + go.sum | 2 ++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 0513aed7b..96786def3 100644 --- a/flake.nix +++ b/flake.nix @@ -157,6 +157,11 @@ yq-go ripgrep postgresql + + # External clients exercised by the Tailscale-compatible v2 API + # roundtrip tests (TestAPIv2). Binaries: tofu, tscli. + opentofu + tscli python314Packages.mdformat python314Packages.mdformat-footnote python314Packages.mdformat-frontmatter diff --git a/flakehashes.json b/flakehashes.json index 65bbdacd3..ea971a38e 100644 --- a/flakehashes.json +++ b/flakehashes.json @@ -1,6 +1,6 @@ { "vendor": { - "goModSum": "sha256-tboE8Nc56tKKOki0JVvR64HcTZJE3ESgLnfdOYQq53Q=", - "sri": "sha256-82WhhyBCnwp0ysrI+n2vHf22cgD29Pjus3GtIOpJonU=" + "goModSum": "sha256-SJml8RXGmb2p0g1nOsHn86FA1hwgd5ZffLSkUj5zek8=", + "sri": "sha256-pjGNuVtgFFzWNq/2cK7a4iyF13AfcHz098nk92a9Ido=" } } diff --git a/go.mod b/go.mod index c6eaad910..41cf5b057 100644 --- a/go.mod +++ b/go.mod @@ -53,6 +53,7 @@ require ( gorm.io/gorm v1.31.1 pgregory.net/rapid v1.3.0 tailscale.com v1.101.0-pre + tailscale.com/client/tailscale/v2 v2.9.0 zombiezen.com/go/postgrestest v1.0.1 ) diff --git a/go.sum b/go.sum index da29c9a4c..048eba2ca 100644 --- a/go.sum +++ b/go.sum @@ -674,5 +674,7 @@ software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= tailscale.com v1.101.0-pre h1:q1eBWxryj7Lz5fMvi7npSbN/fJ3q6/crvbbfMkx89F8= tailscale.com v1.101.0-pre/go.mod h1:DQ9YBy85DpNlSyeU2XRIWzbAu3RsGp/frv+Khg57meE= +tailscale.com/client/tailscale/v2 v2.9.0 h1:zBZIIeIYXL42qvvile7d29O2DKSr3AfNc2gzd1JCf2o= +tailscale.com/client/tailscale/v2 v2.9.0/go.mod h1:FGjvGT3ThHelqo0gfdK3IN3k1dwNbRzYbQh2XO3C47U= zombiezen.com/go/postgrestest v1.0.1 h1:aXoADQAJmZDU3+xilYVut0pHhgc0sF8ZspPW9gFNwP4= zombiezen.com/go/postgrestest v1.0.1/go.mod h1:marlZezr+k2oSJrvXHnZUs1olHqpE9czlz8ZYkVxliQ= From d5555c6b8c2502a067431fae0f6628ec1ef14dd0 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH 085/127] servertest: add real-listener option and node/key helpers Add WithRealListener for tests needing a real loopback port, plus CreateAPIKey and CreateRegisteredNode for the v2 roundtrip. --- hscontrol/servertest/server.go | 67 +++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/hscontrol/servertest/server.go b/hscontrol/servertest/server.go index 71e3e6bb5..9601694cc 100644 --- a/hscontrol/servertest/server.go +++ b/hscontrol/servertest/server.go @@ -5,6 +5,7 @@ package servertest import ( + "context" "net" "net/http" "net/netip" @@ -43,6 +44,7 @@ type serverConfig struct { nodeExpiry time.Duration batcherWorkers int taildropEnabled bool + realListener bool } func defaultServerConfig() *serverConfig { @@ -82,6 +84,14 @@ func WithNodeExpiry(d time.Duration) ServerOption { return func(c *serverConfig) { c.nodeExpiry = d } } +// WithRealListener binds the HTTP API to a real loopback TCP port instead of +// the in-process memnet, so external processes — the Tailscale SDK over a real +// socket, tscli, OpenTofu — can reach the API. The Noise/[TestClient] control +// path still uses memnet, so this is for REST/API tests only. +func WithRealListener() ServerOption { + return func(c *serverConfig) { c.realListener = true } +} + // WithTaildropEnabled toggles the Taildrop file-sharing feature. // Defaults to true to match production. Pass false to verify // behaviour when an operator has switched the toggle off — e.g. @@ -164,13 +174,24 @@ func NewServer(tb testing.TB, opts ...ServerOption) *TestServer { app.StartBatcherForTest(tb) app.StartEphemeralGCForTest(tb) - // Start the HTTP server over an in-memory network so that all - // TCP connections stay in-process. - var memNetwork memnet.Network + // Start the HTTP server. By default it binds an in-memory network so all + // TCP connections stay in-process; WithRealListener swaps in a real loopback + // port so external binaries can connect. + var ( + memNetwork memnet.Network + ln net.Listener + ) + + if sc.realListener { + var lc net.ListenConfig + + ln, err = lc.Listen(context.Background(), "tcp", "127.0.0.1:0") + } else { + ln, err = memNetwork.Listen("tcp", "127.0.0.1:443") + } - ln, err := memNetwork.Listen("tcp", "127.0.0.1:443") if err != nil { - tb.Fatalf("servertest: memnet Listen: %v", err) + tb.Fatalf("servertest: Listen: %v", err) } httpServer := &http.Server{ @@ -233,6 +254,42 @@ func (s *TestServer) CreateUser(tb testing.TB, name string) *types.User { return u } +// CreateAPIKey mints an API key string (the Bearer/Basic credential). When +// owner is non-nil the key is owned by that user, so the v2 API mints +// user-owned (untagged) auth keys on its behalf. +func (s *TestServer) CreateAPIKey(tb testing.TB, owner *types.User) string { + tb.Helper() + + keyStr, key, err := s.st.CreateAPIKey(nil) + if err != nil { + tb.Fatalf("servertest: CreateAPIKey: %v", err) + } + + if owner != nil { + err := s.st.SetAPIKeyUser(key.ID, types.UserID(owner.ID)) + if err != nil { + tb.Fatalf("servertest: SetAPIKeyUser: %v", err) + } + } + + return keyStr +} + +// CreateRegisteredNode mints a registered node (allocated IPs, registered +// method) in BOTH the database and the in-memory NodeStore, then returns its +// view. The v2 device endpoints resolve nodes via State.GetNodeByID, which reads +// the NodeStore, so a DB-only node would be invisible to them. +func (s *TestServer) CreateRegisteredNode(tb testing.TB, owner *types.User, hostname ...string) types.NodeView { + tb.Helper() + + node := s.st.CreateRegisteredNodeForTest(owner, hostname...) + // CreateRegisteredNodeForTest sets UserID but leaves the User association + // unloaded; the device response renders the owner login, so attach it. + node.User = owner + + return s.st.PutNodeInStoreForTest(*node) +} + // CreatePreAuthKey creates a reusable pre-auth key for the given user. func (s *TestServer) CreatePreAuthKey(tb testing.TB, userID types.UserID) string { tb.Helper() From 13ffd958ed4fa836be88227d7b56af417eda991d Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH 086/127] hscontrol: add v2 API contract tests Drive each endpoint in-process with humatest and validate both the wire shapes and the server-side state: keys, devices, ACL, settings. --- hscontrol/apiv2_acl_test.go | 239 +++++++++++++++++++++ hscontrol/apiv2_devices_test.go | 343 +++++++++++++++++++++++++++++++ hscontrol/apiv2_keys_test.go | 312 ++++++++++++++++++++++++++++ hscontrol/apiv2_settings_test.go | 146 +++++++++++++ 4 files changed, 1040 insertions(+) create mode 100644 hscontrol/apiv2_acl_test.go create mode 100644 hscontrol/apiv2_devices_test.go create mode 100644 hscontrol/apiv2_keys_test.go create mode 100644 hscontrol/apiv2_settings_test.go diff --git a/hscontrol/apiv2_acl_test.go b/hscontrol/apiv2_acl_test.go new file mode 100644 index 000000000..def9af824 --- /dev/null +++ b/hscontrol/apiv2_acl_test.go @@ -0,0 +1,239 @@ +package hscontrol + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/danielgtaylor/huma/v2/humatest" + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const allowAllPolicy = `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + +// storedPolicy returns the HuJSON actually persisted in the DB (server-side +// ground truth), or "" when none is set. +func storedPolicy(t *testing.T, app *Headscale) string { + t.Helper() + + p, err := app.state.GetPolicy() + if errors.Is(err, types.ErrPolicyNotFound) { + return "" + } + + require.NoError(t, err) + + return p.Data +} + +// policyETagOf recomputes the expected ETag independently of the handler, so a +// handler that hashed the wrong buffer is caught. +func policyETagOf(data string) string { + sum := sha256.Sum256([]byte(data)) + + return `"` + hex.EncodeToString(sum[:]) + `"` +} + +// setACL POSTs a raw policy body with the given content type and optional +// headers (e.g. "If-Match: ..."). +func setACL(t *testing.T, api humatest.TestAPI, body, contentType string, headers ...string) *httptest.ResponseRecorder { + t.Helper() + + args := make([]any, 0, 2+len(headers)) + args = append(args, bytes.NewReader([]byte(body)), "Content-Type: "+contentType) + + for _, h := range headers { + args = append(args, h) + } + + return api.Post("/api/v2/tailnet/-/acl", args...) +} + +func TestAPIv2ACLDefaultWhenUnset(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + resp := api.Get("/api/v2/tailnet/-/acl") + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + assert.NotEmpty(t, resp.Header().Get("Etag")) + assert.Contains(t, resp.Body.String(), `"acls"`) + + // GET did not materialize a stored policy. + assert.Empty(t, storedPolicy(t, app)) + + // Content-addressed: a second GET returns the same ETag. + assert.Equal(t, resp.Header().Get("Etag"), api.Get("/api/v2/tailnet/-/acl").Header().Get("Etag")) +} + +func TestAPIv2ACLContentNegotiation(t *testing.T) { + api := registerAPIV2(t, createTestApp(t)) + + jsonResp := api.Get("/api/v2/tailnet/-/acl") + huResp := api.Get("/api/v2/tailnet/-/acl", "Accept: application/hujson") + + assert.Equal(t, "application/json", jsonResp.Header().Get("Content-Type")) + assert.Equal(t, "application/hujson", huResp.Header().Get("Content-Type")) + // Same bytes, same ETag — the ETag is over content, not type. + assert.Equal(t, jsonResp.Body.Bytes(), huResp.Body.Bytes()) + assert.Equal(t, jsonResp.Header().Get("Etag"), huResp.Header().Get("Etag")) +} + +func TestAPIv2ACLSetCanonicalJSON(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + set := setACL(t, api, allowAllPolicy, "application/json") + require.Equalf(t, http.StatusOK, set.Code, "body: %s", set.Body) + setETag := set.Header().Get("Etag") + assert.NotEmpty(t, setETag) + + // (a) tool's own get reflects it, same ETag. + get := api.Get("/api/v2/tailnet/-/acl") + assert.Contains(t, get.Body.String(), `"action":"accept"`) + assert.Equal(t, setETag, get.Header().Get("Etag")) + + // (b) server-side: exact stored bytes. + assert.JSONEq(t, allowAllPolicy, storedPolicy(t, app)) + + // (c) ETag is the sha256 of those bytes. + assert.Equal(t, policyETagOf(allowAllPolicy), setETag) +} + +func TestAPIv2ACLSetHuJSONWithComments(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + // Line comment + trailing comma: valid HuJSON, invalid strict JSON. The + // server accepts it (the SDK sends the policy file this way) and standardizes + // it on store; the ACL content survives even though comments are blanked. + policy := "{\n // allow all\n \"acls\": [{\"action\":\"accept\",\"src\":[\"*\"],\"dst\":[\"*:*\"]}],\n}" + + set := setACL(t, api, policy, "application/hujson") + require.Equalf(t, http.StatusOK, set.Code, "body: %s", set.Body) + + // Server-side: stored, parseable, ACL intact. + stored := storedPolicy(t, app) + assert.Contains(t, stored, `"action":"accept"`) + + // GET round-trips the stored form and its content-addressed ETag. + raw := api.Get("/api/v2/tailnet/-/acl", "Accept: application/hujson") + assert.Equal(t, stored, raw.Body.String()) + assert.Equal(t, policyETagOf(stored), raw.Header().Get("Etag")) +} + +func TestAPIv2ACLETagChangesOnChangeStableOnNoop(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + etag1 := setACL(t, api, allowAllPolicy, "application/json").Header().Get("Etag") + stored1 := storedPolicy(t, app) + + p2 := `{"hosts":{"h":"100.64.0.1"},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + etag2 := setACL(t, api, p2, "application/json").Header().Get("Etag") + assert.NotEqual(t, etag1, etag2, "etag changes when policy changes") + assert.NotEqual(t, stored1, storedPolicy(t, app)) + + // No-op re-set keeps the ETag stable. + etag3 := setACL(t, api, p2, "application/json").Header().Get("Etag") + assert.Equal(t, etag2, etag3) + assert.Equal(t, p2, storedPolicy(t, app)) +} + +func TestAPIv2ACLIfMatchPreconditions(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + etag := setACL(t, api, allowAllPolicy, "application/json").Header().Get("Etag") + + p2 := `{"hosts":{"h":"100.64.0.1"},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + + // Match -> 200, applies. + require.Equal(t, http.StatusOK, setACL(t, api, p2, "application/json", "If-Match: "+etag).Code) + assert.Equal(t, p2, storedPolicy(t, app)) + + // Mismatch -> 412, server unchanged. + before := storedPolicy(t, app) + assert.Equal(t, http.StatusPreconditionFailed, + setACL(t, api, allowAllPolicy, "application/json", `If-Match: "deadbeef"`).Code) + assert.Equal(t, before, storedPolicy(t, app), "rejected write left the policy untouched") + + // Absent -> unconditional 200. + require.Equal(t, http.StatusOK, setACL(t, api, allowAllPolicy, "application/json").Code) + assert.JSONEq(t, allowAllPolicy, storedPolicy(t, app)) +} + +func TestAPIv2ACLIfMatchTsDefault(t *testing.T) { + // No policy set: ts-default matches the allow-all default -> 200. + app := createTestApp(t) + api := registerAPIV2(t, app) + require.Equal(t, http.StatusOK, + setACL(t, api, allowAllPolicy, "application/json", `If-Match: "ts-default"`).Code) + assert.JSONEq(t, allowAllPolicy, storedPolicy(t, app)) + + // A non-default policy is set: ts-default no longer matches -> 412. + assert.Equal(t, http.StatusPreconditionFailed, + setACL(t, api, `{"hosts":{"h":"100.64.0.1"},"acls":[]}`, "application/json", `If-Match: "ts-default"`).Code) +} + +func TestAPIv2ACLInvalidPolicyAtomicity(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + good := setACL(t, api, allowAllPolicy, "application/json") + require.Equal(t, http.StatusOK, good.Code) + goodETag := good.Header().Get("Etag") + + // Malformed body -> 400, and the stored policy is unchanged (atomicity). + bad := setACL(t, api, `{ this is not valid`, "application/json") + assert.Equal(t, http.StatusBadRequest, bad.Code) + assert.Contains(t, bad.Body.String(), `"message"`) + assert.JSONEq(t, allowAllPolicy, storedPolicy(t, app)) + + // Wire-level: GET still serves the good policy + its ETag (no drift). + get := api.Get("/api/v2/tailnet/-/acl") + assert.Equal(t, goodETag, get.Header().Get("Etag")) + assert.JSONEq(t, allowAllPolicy, storedPolicy(t, app)) +} + +func TestAPIv2ACLNonDefaultTailnet404(t *testing.T) { + api := registerAPIV2(t, createTestApp(t)) + + assert.Equal(t, http.StatusNotFound, api.Get("/api/v2/tailnet/example.com/acl").Code) + assert.Equal(t, http.StatusNotFound, + api.Post("/api/v2/tailnet/example.com/acl", bytes.NewReader([]byte(allowAllPolicy))).Code) +} + +func TestAPIv2ACLFileModeReadOnly(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "acl.hujson") + fileBytes := "{\n // file-managed\n \"acls\": [{\"action\":\"accept\",\"src\":[\"*\"],\"dst\":[\"*:*\"]}],\n}" + require.NoError(t, os.WriteFile(path, []byte(fileBytes), 0o600)) + + cfg := &types.Config{Policy: types.PolicyConfig{Mode: types.PolicyModeFile, Path: path}} + _, api := humatest.New(t, apiv2.Config()) + apiv2.Register(api, apiv2.Backend{Cfg: cfg}) + + // GET serves the file bytes + their ETag. + get := api.Get("/api/v2/tailnet/-/acl") + require.Equalf(t, http.StatusOK, get.Code, "body: %s", get.Body) + assert.Equal(t, fileBytes, get.Body.String()) + assert.Equal(t, policyETagOf(fileBytes), get.Header().Get("Etag")) + + // POST is rejected in file mode; the file is unchanged. + set := setACL(t, api, allowAllPolicy, "application/json") + assert.Equal(t, http.StatusBadRequest, set.Code) + assert.Contains(t, set.Body.String(), "database") + + after, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, fileBytes, string(after)) +} diff --git a/hscontrol/apiv2_devices_test.go b/hscontrol/apiv2_devices_test.go new file mode 100644 index 000000000..24c0d77a4 --- /dev/null +++ b/hscontrol/apiv2_devices_test.go @@ -0,0 +1,343 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + "time" + + "github.com/danielgtaylor/huma/v2/humatest" + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// registerAPIV2 builds a humatest API with the v2 operations registered over the +// app's state, with no auth middleware (the contract tests exercise wire shapes; +// auth is covered by TestAPIv2). The full Backend (Change + Cfg) is wired so the +// device/acl write handlers work. +func registerAPIV2(t *testing.T, app *Headscale) humatest.TestAPI { + t.Helper() + + _, api := humatest.New(t, apiv2.Config()) + apiv2.Register(api, apiv2.Backend{State: app.state, Change: app.Change, Cfg: app.cfg}) + + return api +} + +// deviceTestEnv is one seeded, registered, user-owned node plus the v2 API. +type deviceTestEnv struct { + app *Headscale + api humatest.TestAPI + user *types.User + nodeID types.NodeID + deviceID string +} + +// newDeviceTestEnv seeds a registered user-owned node into the NodeStore, with +// tag:ci and tag:prod declared in policy so tagging is permitted. +func newDeviceTestEnv(t *testing.T) deviceTestEnv { + t.Helper() + + app := createTestApp(t) + user := app.state.CreateUserForTest("dut-user") + + _, err := app.state.SetPolicy([]byte( + `{"tagOwners":{"tag:ci":["` + user.Name + `@"],"tag:prod":["` + user.Name + `@"]},` + + `"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`, + )) + require.NoError(t, err) + + node := app.state.CreateRegisteredNodeForTest(user, "contract-dut") + node.User = user + view := app.state.PutNodeInStoreForTest(*node) + + return deviceTestEnv{ + app: app, + api: registerAPIV2(t, app), + user: user, + nodeID: view.ID(), + deviceID: strconv.FormatUint(uint64(view.ID()), 10), + } +} + +// srvNode reloads the node from the NodeStore — the server-side source of truth. +func (e deviceTestEnv) srvNode(t *testing.T) types.NodeView { + t.Helper() + + v, ok := e.app.state.GetNodeByID(e.nodeID) + require.True(t, ok, "node must exist in the NodeStore") + require.True(t, v.Valid()) + + return v +} + +// seedExpiry stamps a future expiry so set-key(disable) is a real transition. +func (e deviceTestEnv) seedExpiry(t *testing.T, at time.Time) { + t.Helper() + + _, _, err := e.app.state.SetNodeExpiry(e.nodeID, &at) + require.NoError(t, err) +} + +func getDevice(t *testing.T, api humatest.TestAPI, deviceID string) apiv2.Device { + t.Helper() + + resp := api.Get("/api/v2/device/" + deviceID) + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var dev apiv2.Device + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dev)) + + return dev +} + +func getDeviceRoutes(t *testing.T, api humatest.TestAPI, deviceID string) apiv2.DeviceRoutes { + t.Helper() + + resp := api.Get("/api/v2/device/" + deviceID + "/routes") + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var routes apiv2.DeviceRoutes + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &routes)) + + return routes +} + +func approvedRouteStrings(nv types.NodeView) []string { + return util.PrefixesToString(nv.ApprovedRoutes().AsSlice()) +} + +func TestAPIv2Device_Get(t *testing.T) { + e := newDeviceTestEnv(t) + + resp := e.api.Get("/api/v2/device/" + e.deviceID + "?fields=all") + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var dev apiv2.Device + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dev)) + assert.Equal(t, e.deviceID, dev.ID) + assert.Equal(t, e.deviceID, dev.NodeID) + assert.Equal(t, "contract-dut", dev.Hostname) + assert.Equal(t, "contract-dut", dev.Name) + assert.True(t, dev.Authorized) + assert.Equal(t, e.user.Username(), dev.User) + assert.NotNil(t, dev.Tags) + assert.True(t, dev.KeyExpiryDisabled, "seeded node has no expiry") + assert.NotEmpty(t, dev.Addresses) + + // Server-side cross-check. + n := e.srvNode(t) + assert.Equal(t, n.GivenName(), dev.Name) + assert.False(t, n.IsTagged()) + assert.True(t, n.User().Valid()) + assert.False(t, n.Expiry().Valid()) + assert.Equal(t, n.IPsAsString(), dev.Addresses) +} + +func TestAPIv2Device_Get_UnknownID_404(t *testing.T) { + e := newDeviceTestEnv(t) + + assert.Equal(t, http.StatusNotFound, e.api.Get("/api/v2/device/999999").Code) + assert.Equal(t, http.StatusNotFound, e.api.Get("/api/v2/device/not-a-number").Code) +} + +func TestAPIv2Device_List(t *testing.T) { + e := newDeviceTestEnv(t) + + list := func() []apiv2.Device { + t.Helper() + + resp := e.api.Get("/api/v2/tailnet/-/devices") + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var out struct { + Devices []apiv2.Device `json:"devices"` + } + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &out)) + + return out.Devices + } + + assert.True(t, containsDeviceID(list(), e.deviceID)) + assert.Len(t, list(), e.app.state.ListNodes().Len(), "list count == ListNodes") + + // A second node appears; deleting it removes it from both the list and state. + node2 := e.app.state.CreateRegisteredNodeForTest(e.user, "contract-dut-2") + node2.User = e.user + view2 := e.app.state.PutNodeInStoreForTest(*node2) + id2 := strconv.FormatUint(uint64(view2.ID()), 10) + assert.True(t, containsDeviceID(list(), id2)) + + require.Equal(t, http.StatusOK, e.api.Delete("/api/v2/device/"+id2).Code) + assert.False(t, containsDeviceID(list(), id2)) + + _, ok := e.app.state.GetNodeByID(view2.ID()) + assert.False(t, ok) + + assert.Equal(t, http.StatusNotFound, e.api.Get("/api/v2/tailnet/example.com/devices").Code) +} + +func TestAPIv2Device_SetName(t *testing.T) { + e := newDeviceTestEnv(t) + + require.Equal(t, http.StatusOK, + e.api.Post("/api/v2/device/"+e.deviceID+"/name", map[string]any{"name": "renamed-dut"}).Code) + assert.Equal(t, "renamed-dut", getDevice(t, e.api, e.deviceID).Name) + assert.Equal(t, "renamed-dut", e.srvNode(t).GivenName()) + + // Rename again. + require.Equal(t, http.StatusOK, + e.api.Post("/api/v2/device/"+e.deviceID+"/name", map[string]any{"name": "renamed-again"}).Code) + assert.Equal(t, "renamed-again", getDevice(t, e.api, e.deviceID).Name) + assert.Equal(t, "renamed-again", e.srvNode(t).GivenName()) + + // Invalid DNS label is rejected; the name is unchanged. + bad := e.api.Post("/api/v2/device/"+e.deviceID+"/name", map[string]any{"name": "Invalid_Name!"}) + assert.Equal(t, http.StatusBadRequest, bad.Code) + assert.Contains(t, bad.Body.String(), `"message"`) + assert.Equal(t, "renamed-again", e.srvNode(t).GivenName()) +} + +func TestAPIv2Device_SetTags(t *testing.T) { + e := newDeviceTestEnv(t) + + setTags := func(tags []string) int { + return e.api.Post("/api/v2/device/"+e.deviceID+"/tags", map[string]any{"tags": tags}).Code + } + + // One tag flips the node to tag-owned. + require.Equal(t, http.StatusOK, setTags([]string{"tag:ci"})) + assert.Equal(t, []string{"tag:ci"}, getDevice(t, e.api, e.deviceID).Tags) + n := e.srvNode(t) + assert.True(t, n.IsTagged()) + assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice()) + assert.False(t, n.User().Valid()) + assert.Equal(t, types.TaggedDevices.Username(), getDevice(t, e.api, e.deviceID).User) + + // Two tags (sorted). + require.Equal(t, http.StatusOK, setTags([]string{"tag:ci", "tag:prod"})) + assert.Equal(t, []string{"tag:ci", "tag:prod"}, getDevice(t, e.api, e.deviceID).Tags) + assert.Equal(t, []string{"tag:ci", "tag:prod"}, e.srvNode(t).Tags().AsSlice()) + + // A different single tag replaces the set. + require.Equal(t, http.StatusOK, setTags([]string{"tag:prod"})) + assert.Equal(t, []string{"tag:prod"}, e.srvNode(t).Tags().AsSlice()) + + // Remove-all is a no-op: tags survive. + require.Equal(t, http.StatusOK, setTags([]string{})) + assert.Equal(t, []string{"tag:prod"}, e.srvNode(t).Tags().AsSlice()) + assert.True(t, e.srvNode(t).IsTagged()) + + // An undeclared tag is rejected with 400 (mapError now maps the sentinel), + // and the server-side tags are unchanged. + assert.Equal(t, http.StatusBadRequest, setTags([]string{"tag:nope"})) + assert.Equal(t, []string{"tag:prod"}, e.srvNode(t).Tags().AsSlice()) +} + +func TestAPIv2Device_SetKey(t *testing.T) { + e := newDeviceTestEnv(t) + e.seedExpiry(t, time.Now().Add(24*time.Hour)) + + require.True(t, e.srvNode(t).Expiry().Valid(), "precondition: node has an expiry") + assert.False(t, getDevice(t, e.api, e.deviceID).KeyExpiryDisabled) + + require.Equal(t, http.StatusOK, + e.api.Post("/api/v2/device/"+e.deviceID+"/key", map[string]any{"keyExpiryDisabled": true}).Code) + assert.True(t, getDevice(t, e.api, e.deviceID).KeyExpiryDisabled) + assert.False(t, e.srvNode(t).Expiry().Valid(), "expiry cleared") + + // Re-enable is accepted as a no-op; expiry stays cleared. + require.Equal(t, http.StatusOK, + e.api.Post("/api/v2/device/"+e.deviceID+"/key", map[string]any{"keyExpiryDisabled": false}).Code) + assert.True(t, getDevice(t, e.api, e.deviceID).KeyExpiryDisabled) + assert.False(t, e.srvNode(t).Expiry().Valid()) +} + +func TestAPIv2Device_SetRoutes(t *testing.T) { + e := newDeviceTestEnv(t) + + setRoutes := func(routes []string) apiv2.DeviceRoutes { + t.Helper() + + resp := e.api.Post("/api/v2/device/"+e.deviceID+"/routes", map[string]any{"routes": routes}) + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var dr apiv2.DeviceRoutes + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &dr)) + + return dr + } + + // One route: enabled reflects it; advertised stays empty (nothing announced). + dr := setRoutes([]string{"10.0.0.0/24"}) + assert.Contains(t, dr.Enabled, "10.0.0.0/24") + assert.Empty(t, dr.Advertised) + assert.Contains(t, getDeviceRoutes(t, e.api, e.deviceID).Enabled, "10.0.0.0/24") + assert.Contains(t, approvedRouteStrings(e.srvNode(t)), "10.0.0.0/24") + assert.Empty(t, e.srvNode(t).AnnouncedRoutes()) + + // Two routes. + setRoutes([]string{"10.0.0.0/24", "192.168.0.0/24"}) + + approved := approvedRouteStrings(e.srvNode(t)) + assert.Contains(t, approved, "10.0.0.0/24") + assert.Contains(t, approved, "192.168.0.0/24") + + // Exit route expands to both families. + setRoutes([]string{"0.0.0.0/0"}) + + approved = approvedRouteStrings(e.srvNode(t)) + assert.Contains(t, approved, "0.0.0.0/0") + assert.Contains(t, approved, "::/0") + + // Clear. + dr = setRoutes([]string{}) + assert.Empty(t, dr.Enabled) + assert.Empty(t, approvedRouteStrings(e.srvNode(t))) + + // Malformed prefix is rejected; routes unchanged. + bad := e.api.Post("/api/v2/device/"+e.deviceID+"/routes", map[string]any{"routes": []string{"not-a-cidr"}}) + assert.Equal(t, http.StatusBadRequest, bad.Code) + assert.Empty(t, approvedRouteStrings(e.srvNode(t))) +} + +func TestAPIv2Device_SetAuthorized(t *testing.T) { + e := newDeviceTestEnv(t) + + require.Equal(t, http.StatusOK, + e.api.Post("/api/v2/device/"+e.deviceID+"/authorized", map[string]any{"authorized": true}).Code) + assert.True(t, getDevice(t, e.api, e.deviceID).Authorized) + + // De-authorize is rejected; the node stays present and authorized. + bad := e.api.Post("/api/v2/device/"+e.deviceID+"/authorized", map[string]any{"authorized": false}) + assert.Equal(t, http.StatusBadRequest, bad.Code) + assert.Contains(t, bad.Body.String(), `"message"`) + assert.True(t, getDevice(t, e.api, e.deviceID).Authorized) +} + +func TestAPIv2Device_Delete(t *testing.T) { + e := newDeviceTestEnv(t) + + require.Equal(t, http.StatusOK, e.api.Delete("/api/v2/device/"+e.deviceID).Code) + assert.Equal(t, http.StatusNotFound, e.api.Get("/api/v2/device/"+e.deviceID).Code) + + _, ok := e.app.state.GetNodeByID(e.nodeID) + assert.False(t, ok, "node gone from the NodeStore") + + // Delete again is 404. + assert.Equal(t, http.StatusNotFound, e.api.Delete("/api/v2/device/"+e.deviceID).Code) +} + +func containsDeviceID(devs []apiv2.Device, id string) bool { + for _, d := range devs { + if d.ID == id || d.NodeID == id { + return true + } + } + + return false +} diff --git a/hscontrol/apiv2_keys_test.go b/hscontrol/apiv2_keys_test.go new file mode 100644 index 000000000..679eb40fe --- /dev/null +++ b/hscontrol/apiv2_keys_test.go @@ -0,0 +1,312 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + "time" + + "github.com/danielgtaylor/huma/v2/humatest" + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// taggedCaps builds capabilities for a tagged auth key. +func taggedCaps(tags ...string) apiv2.KeyCapabilities { + return apiv2.KeyCapabilities{ + Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{ + Reusable: true, + Preauthorized: true, + Tags: tags, + }}, + } +} + +// newKeyTestAPI builds an app + v2 API with NO owner user in context. Without the +// auth middleware ownerUser(ctx) is empty, so only the tagged-key path creates; +// the user-owned path (which needs an owning API key) is covered by TestAPIv2. +func newKeyTestAPI(t *testing.T) (*Headscale, humatest.TestAPI) { + t.Helper() + + app := createTestApp(t) + + _, api := humatest.New(t, apiv2.Config()) + apiv2.Register(api, apiv2.Backend{State: app.state}) + + return app, api +} + +// createKey POSTs a key and decodes the create response. +func createKey(t *testing.T, api humatest.TestAPI, req apiv2.CreateKeyRequest) apiv2.Key { + t.Helper() + + resp := api.Post("/api/v2/tailnet/-/keys", req) + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var key apiv2.Key + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &key)) + + return key +} + +// getKey GETs a key by id and decodes it. +func getKey(t *testing.T, api humatest.TestAPI, id string) apiv2.Key { + t.Helper() + + resp := api.Get("/api/v2/tailnet/-/keys/" + id) + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var key apiv2.Key + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &key)) + + return key +} + +// listKeys GETs the key list and returns the enveloped slice. +func listKeys(t *testing.T, api humatest.TestAPI) []apiv2.Key { + t.Helper() + + resp := api.Get("/api/v2/tailnet/-/keys") + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var list struct { + Keys []apiv2.Key `json:"keys"` + } + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &list)) + + return list.Keys +} + +func containsKeyID(keys []apiv2.Key, id string) bool { + for _, k := range keys { + if k.ID == id { + return true + } + } + + return false +} + +// srvKey is the server-side ground truth: the stored PreAuthKey, found by its +// stringified id through ListPreAuthKeys (User is preloaded). +func srvKey(t *testing.T, app *Headscale, id string) types.PreAuthKey { + t.Helper() + + want, err := strconv.ParseUint(id, 10, 64) + require.NoError(t, err) + + keys, err := app.state.ListPreAuthKeys() + require.NoError(t, err) + + for _, k := range keys { + if k.ID == want { + return k + } + } + + t.Fatalf("pre-auth key %s not found server-side", id) + + return types.PreAuthKey{} +} + +func keyCount(t *testing.T, app *Headscale) int { + t.Helper() + + keys, err := app.state.ListPreAuthKeys() + require.NoError(t, err) + + return len(keys) +} + +func TestAPIv2Key_Create_Tagged(t *testing.T) { + app, api := newKeyTestAPI(t) + + created := createKey(t, api, apiv2.CreateKeyRequest{ + Description: "dev access", + ExpirySeconds: 86400, + Capabilities: taggedCaps("tag:test"), + }) + + // (a) create response — Tailscale Key shape, exact seconds (int64 wire). + assert.Equal(t, "auth", created.KeyType) + assert.NotEmpty(t, created.ID) + assert.NotEmpty(t, created.Key, "secret returned on create") + assert.Equal(t, "dev access", created.Description) + assert.Equal(t, int64(86400), created.ExpirySeconds, "seconds, not nanoseconds") + assert.Empty(t, created.UserID, "tagged key presents no owner") + assert.Equal(t, []string{"tag:test"}, created.Capabilities.Devices.Create.Tags) + assert.True(t, created.Capabilities.Devices.Create.Reusable) + assert.True(t, created.Capabilities.Devices.Create.Preauthorized, "echoed on create") + assert.NotNil(t, created.Expires) + assert.False(t, created.Invalid) + + // (c) server-side — the stored key. + pak := srvKey(t, app, created.ID) + assert.True(t, pak.Reusable) + assert.False(t, pak.Ephemeral) + assert.Equal(t, []string{"tag:test"}, pak.Tags) + assert.Equal(t, "dev access", pak.Description) + assert.Nil(t, pak.User, "tagged key has no owning user") + require.NotNil(t, pak.CreatedAt) + require.NotNil(t, pak.Expiration) + assert.InDelta(t, 86400, pak.Expiration.Sub(*pak.CreatedAt).Seconds(), 2) +} + +func TestAPIv2Key_Create_Permutations(t *testing.T) { + tests := []struct { + name string + req apiv2.CreateKeyRequest + wantReusable bool + wantEphemeral bool + wantDesc string + wantSeconds float64 + }{ + { + name: "single-use", + req: apiv2.CreateKeyRequest{Capabilities: apiv2.KeyCapabilities{ + Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{Tags: []string{"tag:test"}}}, + }}, + wantSeconds: 7776000, + }, + { + name: "reusable", + req: apiv2.CreateKeyRequest{Capabilities: taggedCaps("tag:test")}, + wantReusable: true, + wantSeconds: 7776000, + }, + { + name: "ephemeral", + req: apiv2.CreateKeyRequest{Capabilities: apiv2.KeyCapabilities{ + Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{ + Ephemeral: true, + Tags: []string{"tag:test"}, + }}, + }}, + wantEphemeral: true, + wantSeconds: 7776000, + }, + { + name: "with description", + req: apiv2.CreateKeyRequest{Description: "ci", Capabilities: taggedCaps("tag:test")}, + wantReusable: true, + wantDesc: "ci", + wantSeconds: 7776000, + }, + { + name: "explicit expiry", + req: apiv2.CreateKeyRequest{ExpirySeconds: 3600, Capabilities: taggedCaps("tag:test")}, + wantReusable: true, + wantSeconds: 3600, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app, api := newKeyTestAPI(t) + + created := createKey(t, api, tt.req) + assert.Equal(t, tt.wantReusable, created.Capabilities.Devices.Create.Reusable) + assert.Equal(t, tt.wantEphemeral, created.Capabilities.Devices.Create.Ephemeral) + assert.Equal(t, tt.wantDesc, created.Description) + assert.InDelta(t, tt.wantSeconds, float64(created.ExpirySeconds), 2) + + pak := srvKey(t, app, created.ID) + assert.Equal(t, tt.wantReusable, pak.Reusable) + assert.Equal(t, tt.wantEphemeral, pak.Ephemeral) + assert.Equal(t, tt.wantDesc, pak.Description) + require.NotNil(t, pak.CreatedAt) + require.NotNil(t, pak.Expiration) + assert.InDelta(t, tt.wantSeconds, pak.Expiration.Sub(*pak.CreatedAt).Seconds(), 2) + }) + } +} + +func TestAPIv2Key_Get(t *testing.T) { + _, api := newKeyTestAPI(t) + + created := createKey(t, api, apiv2.CreateKeyRequest{ + Description: "dev access", + ExpirySeconds: 86400, + Capabilities: taggedCaps("tag:test"), + }) + + got := getKey(t, api, created.ID) + assert.Equal(t, created.ID, got.ID) + assert.Empty(t, got.Key, "secret omitted on get") + assert.Equal(t, "dev access", got.Description) + assert.Equal(t, int64(86400), got.ExpirySeconds, "stable across get") + assert.True(t, got.Capabilities.Devices.Create.Reusable) + assert.True(t, got.Capabilities.Devices.Create.Preauthorized, "Headscale always preauthorizes") + assert.Equal(t, []string{"tag:test"}, got.Capabilities.Devices.Create.Tags) + assert.False(t, got.Invalid) + + // Unknown id and bad tailnet both 404 with the Tailscale error body. + assert.Equal(t, http.StatusNotFound, api.Get("/api/v2/tailnet/-/keys/999999").Code) + bad := api.Get("/api/v2/tailnet/example.com/keys/" + created.ID) + assert.Equal(t, http.StatusNotFound, bad.Code) + assert.Contains(t, bad.Body.String(), `"message"`) +} + +func TestAPIv2Key_List(t *testing.T) { + app, api := newKeyTestAPI(t) + + k1 := createKey(t, api, apiv2.CreateKeyRequest{Capabilities: taggedCaps("tag:test")}) + k2 := createKey(t, api, apiv2.CreateKeyRequest{Capabilities: taggedCaps("tag:test")}) + + keys := listKeys(t, api) + assert.True(t, containsKeyID(keys, k1.ID)) + assert.True(t, containsKeyID(keys, k2.ID)) + + // Server-side has both too. + assert.GreaterOrEqual(t, keyCount(t, app), 2) + + bad := api.Get("/api/v2/tailnet/example.com/keys") + assert.Equal(t, http.StatusNotFound, bad.Code) + assert.Contains(t, bad.Body.String(), `"message"`) +} + +func TestAPIv2Key_Delete(t *testing.T) { + app, api := newKeyTestAPI(t) + + created := createKey(t, api, apiv2.CreateKeyRequest{Capabilities: taggedCaps("tag:test")}) + + require.Equal(t, http.StatusOK, api.Delete("/api/v2/tailnet/-/keys/"+created.ID).Code) + + // DELETE soft-revokes (Tailscale-faithful): the key stays retrievable with + // invalid set and a revoked timestamp, and is still listed. + got := getKey(t, api, created.ID) + assert.True(t, got.Invalid, "revoked key reports invalid") + assert.NotNil(t, got.Revoked, "revoked timestamp is populated") + assert.True(t, containsKeyID(listKeys(t, api), created.ID), "revoked key still listed") + + // Server-side: row kept, revoked stamped. + pak := srvKey(t, app, created.ID) + require.NotNil(t, pak.Revoked) + require.Error(t, pak.Validate(), "revoked key is invalid") + + // Delete again -> 404 (already revoked). + assert.Equal(t, http.StatusNotFound, api.Delete("/api/v2/tailnet/-/keys/"+created.ID).Code) + + // The collector hard-deletes keys revoked before the cutoff. + reaped, err := app.state.DestroyRevokedPreAuthKeysBefore(time.Now().Add(time.Minute)) + require.NoError(t, err) + assert.GreaterOrEqual(t, reaped, 1) + assert.False(t, containsKeyID(listKeys(t, api), created.ID), "collector removed the revoked key") + assert.Equal(t, http.StatusNotFound, api.Get("/api/v2/tailnet/-/keys/"+created.ID).Code) +} + +func TestAPIv2Key_Create_NoTags_NoOwner_400(t *testing.T) { + app, api := newKeyTestAPI(t) + + before := keyCount(t, app) + + resp := api.Post("/api/v2/tailnet/-/keys", apiv2.CreateKeyRequest{Capabilities: apiv2.KeyCapabilities{}}) + assert.Equal(t, http.StatusBadRequest, resp.Code) + assert.Contains(t, resp.Body.String(), `"message"`) + + // No orphan key was created. + assert.Equal(t, before, keyCount(t, app)) +} diff --git a/hscontrol/apiv2_settings_test.go b/hscontrol/apiv2_settings_test.go new file mode 100644 index 000000000..a46e83d78 --- /dev/null +++ b/hscontrol/apiv2_settings_test.go @@ -0,0 +1,146 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/danielgtaylor/huma/v2/humatest" + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// settingsAPIWithConfig registers the v2 API over a Backend carrying cfg and a +// zero State — the settings GET reads only b.Cfg, so the other handlers (which +// would need State) are simply never hit. +func settingsAPIWithConfig(t *testing.T, cfg *types.Config) humatest.TestAPI { + t.Helper() + + _, api := humatest.New(t, apiv2.Config()) + apiv2.Register(api, apiv2.Backend{Cfg: cfg}) + + return api +} + +func getSettings(t *testing.T, api humatest.TestAPI) apiv2.TailnetSettings { + t.Helper() + + resp := api.Get("/api/v2/tailnet/-/settings") + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var s apiv2.TailnetSettings + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &s)) + + return s +} + +// TestAPIv2SettingsComputedFields validates the GET mapping for each field that +// is computed from config — the branches the default-config roundtrip never +// exercises. Expectations live in struct fields, not name branches. +func TestAPIv2SettingsComputedFields(t *testing.T) { + tests := []struct { + name string + cfg types.Config + wantHTTPS bool + wantKeyDurationDays int + wantACLsExtManaged bool + }{ + { + name: "defaults", + cfg: types.Config{}, + }, + { + name: "https via cert path", + cfg: types.Config{TLS: types.TLSConfig{CertPath: "/x/cert.pem"}}, + wantHTTPS: true, + }, + { + name: "https via letsencrypt", + cfg: types.Config{ + TLS: types.TLSConfig{LetsEncrypt: types.LetsEncryptConfig{Hostname: "hs.example.com"}}, + }, + wantHTTPS: true, + }, + { + name: "key duration 7d", + cfg: types.Config{Node: types.NodeConfig{Expiry: 7 * 24 * time.Hour}}, + wantKeyDurationDays: 7, + }, + { + name: "key duration 90d", + cfg: types.Config{Node: types.NodeConfig{Expiry: 90 * 24 * time.Hour}}, + wantKeyDurationDays: 90, + }, + { + name: "key duration truncates", + cfg: types.Config{Node: types.NodeConfig{Expiry: 36 * time.Hour}}, + wantKeyDurationDays: 1, + }, + { + name: "acls externally managed in file mode", + cfg: types.Config{Policy: types.PolicyConfig{Mode: types.PolicyModeFile}}, + wantACLsExtManaged: true, + }, + { + name: "acls db mode", + cfg: types.Config{Policy: types.PolicyConfig{Mode: types.PolicyModeDB}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := getSettings(t, settingsAPIWithConfig(t, &tt.cfg)) + + assert.Equal(t, tt.wantHTTPS, s.HTTPSEnabled) + assert.Equal(t, tt.wantKeyDurationDays, s.DevicesKeyDurationDays) + assert.Equal(t, tt.wantACLsExtManaged, s.ACLsExternallyManagedOn) + assert.Equal(t, "none", s.UsersRoleAllowedToJoinExternalTailnets) + }) + } +} + +// TestAPIv2SettingsConstantOffFields pins the honestly-hardcoded-off fields: +// even with every config knob set, they must not pick up signal. +func TestAPIv2SettingsConstantOffFields(t *testing.T) { + cfg := types.Config{ + TLS: types.TLSConfig{CertPath: "/x/cert.pem"}, + Node: types.NodeConfig{Expiry: 90 * 24 * time.Hour}, + Policy: types.PolicyConfig{Mode: types.PolicyModeFile}, + } + + s := getSettings(t, settingsAPIWithConfig(t, &cfg)) + + assert.False(t, s.DevicesApprovalOn) + assert.False(t, s.DevicesAutoUpdatesOn) + assert.False(t, s.UsersApprovalOn) + assert.False(t, s.NetworkFlowLoggingOn) + assert.False(t, s.RegionalRoutingOn) + assert.False(t, s.PostureIdentityCollectionOn) + assert.Empty(t, s.ACLsExternalLink) +} + +// TestAPIv2SettingsPatchUnsupported confirms writes are rejected and inert. +func TestAPIv2SettingsPatchUnsupported(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + patch := api.Patch("/api/v2/tailnet/-/settings", map[string]any{"devicesApprovalOn": true}) + assert.Equal(t, http.StatusNotImplemented, patch.Code) + assert.Contains(t, patch.Body.String(), `"message"`) + + // The rejected PATCH did not mutate anything. + assert.False(t, getSettings(t, api).DevicesApprovalOn) +} + +// TestAPIv2SettingsNonDefaultTailnet404 — the tailnet check runs before the +// 501, so a bad tailnet is 404 on both verbs. +func TestAPIv2SettingsNonDefaultTailnet404(t *testing.T) { + api := settingsAPIWithConfig(t, &types.Config{}) + + assert.Equal(t, http.StatusNotFound, api.Get("/api/v2/tailnet/example.com/settings").Code) + assert.Equal(t, http.StatusNotFound, + api.Patch("/api/v2/tailnet/example.com/settings", map[string]any{}).Code) +} From 33a65052c9aa0bd492b937408b0cf4e618a1bdc8 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 20 Jun 2026 20:16:51 +0000 Subject: [PATCH 087/127] servertest: roundtrip the v2 API through go-client, tscli, and opentofu Exercise the full surface against the three real clients on a live server, cross-checking get-after-set, server state, and no Terraform drift. --- hscontrol/servertest/apiv2_devices_test.go | 457 +++++++++++++++++++++ hscontrol/servertest/apiv2_test.go | 427 +++++++++++++++++++ 2 files changed, 884 insertions(+) create mode 100644 hscontrol/servertest/apiv2_devices_test.go create mode 100644 hscontrol/servertest/apiv2_test.go diff --git a/hscontrol/servertest/apiv2_devices_test.go b/hscontrol/servertest/apiv2_devices_test.go new file mode 100644 index 000000000..5d85a46f9 --- /dev/null +++ b/hscontrol/servertest/apiv2_devices_test.go @@ -0,0 +1,457 @@ +package servertest_test + +import ( + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/juanfont/headscale/hscontrol/servertest" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + tsclient "tailscale.com/client/tailscale/v2" +) + +// baselinePolicy declares tag:ci so device SetTags is permitted. Every policy +// the device/acl tests write keeps tag:ci, so subtest order does not matter. +const baselinePolicy = `{"tagOwners":{"tag:ci":["apiv2@"]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + +// setBaselinePolicy installs baselinePolicy into both the policy manager and the +// database so device tagging and the ACL reads work. +func setBaselinePolicy(t *testing.T, srv *servertest.TestServer) { + t.Helper() + + st := srv.State() + + _, err := st.SetPolicy([]byte(baselinePolicy)) + require.NoError(t, err) + + _, err = st.SetPolicyInDB(baselinePolicy) + require.NoError(t, err) + + _, err = st.ReloadPolicy() + require.NoError(t, err) +} + +func goClient(t *testing.T, baseURL, apiKey string) *tsclient.Client { + t.Helper() + + base, err := url.Parse(baseURL) + require.NoError(t, err) + + return &tsclient.Client{BaseURL: base, APIKey: apiKey, Tailnet: "-"} +} + +// srvNodeView reads the node straight from the server's NodeStore — the +// authoritative state between client steps. Handlers mutate the NodeStore +// synchronously before responding, so a read right after a 2xx is consistent. +func srvNodeView(t *testing.T, srv *servertest.TestServer, id types.NodeID) types.NodeView { + t.Helper() + + v, ok := srv.State().GetNodeByID(id) + require.Truef(t, ok, "node %d must exist server-side", id) + require.True(t, v.Valid()) + + return v +} + +func approvedRoutesOf(nv types.NodeView) []string { + return util.PrefixesToString(nv.ApprovedRoutes().AsSlice()) +} + +func nodeListed(srv *servertest.TestServer, id types.NodeID) bool { + for _, n := range srv.State().ListNodes().All() { + if n.ID() == id { + return true + } + } + + return false +} + +// apiv2DevicesGoClient drives the device lifecycle through the official SDK, +// validating each mutation three ways: the tool's own get-after-set, the +// server-side NodeStore, and (where relevant) permutations. Tagging is done late +// because it clears user ownership; delete is last. +func apiv2DevicesGoClient(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, id types.NodeID) { + t.Helper() + + ctx := t.Context() + dr := goClient(t, baseURL, apiKey).Devices() + deviceID := strconv.FormatUint(uint64(id), 10) + + // Get — tool and server agree on identity and addresses. + dev, err := dr.Get(ctx, deviceID) + require.NoError(t, err) + assert.Equal(t, deviceID, dev.NodeID) + assert.NotEmpty(t, dev.Addresses) + assert.True(t, dev.Authorized) + assert.Equal(t, srvNodeView(t, srv, id).IPsAsString(), dev.Addresses) + assert.Equal(t, srvNodeView(t, srv, id).GivenName(), dev.Name) + + // List — present in both the tool list and the server's node list. + devs, err := dr.List(ctx) + require.NoError(t, err) + assert.True(t, containsDevice(devs, deviceID), "created device present in list") + assert.True(t, nodeListed(srv, id)) + + // SetName — get-after-set + server-side, then a second rename. + require.NoError(t, dr.SetName(ctx, deviceID, "renamed-go")) + dev, err = dr.Get(ctx, deviceID) + require.NoError(t, err) + assert.Equal(t, "renamed-go", dev.Name) + assert.Equal(t, "renamed-go", srvNodeView(t, srv, id).GivenName()) + + require.NoError(t, dr.SetName(ctx, deviceID, "renamed-go-2")) + assert.Equal(t, "renamed-go-2", srvNodeView(t, srv, id).GivenName()) + + // SetSubnetRoutes — one, two, then exit (expands to both families). + require.NoError(t, dr.SetSubnetRoutes(ctx, deviceID, []string{"10.0.0.0/24"})) + routes, err := dr.SubnetRoutes(ctx, deviceID) + require.NoError(t, err) + assert.Contains(t, routes.Enabled, "10.0.0.0/24") + assert.Contains(t, approvedRoutesOf(srvNodeView(t, srv, id)), "10.0.0.0/24") + assert.Empty(t, srvNodeView(t, srv, id).AnnouncedRoutes(), "route enabled without being announced") + + require.NoError(t, dr.SetSubnetRoutes(ctx, deviceID, []string{"10.0.0.0/24", "192.168.0.0/24"})) + approved := approvedRoutesOf(srvNodeView(t, srv, id)) + assert.Contains(t, approved, "10.0.0.0/24") + assert.Contains(t, approved, "192.168.0.0/24") + + // A single exit prefix expands to both families on the server. + require.NoError(t, dr.SetSubnetRoutes(ctx, deviceID, []string{"0.0.0.0/0"})) + approved = approvedRoutesOf(srvNodeView(t, srv, id)) + assert.Contains(t, approved, "0.0.0.0/0") + assert.Contains(t, approved, "::/0") + + // SetKey — seed a real expiry first so disabling it is a state transition. + future := time.Now().Add(24 * time.Hour) + _, _, err = srv.State().SetNodeExpiry(id, &future) + require.NoError(t, err) + require.True(t, srvNodeView(t, srv, id).Expiry().Valid()) + + require.NoError(t, dr.SetKey(ctx, deviceID, tsclient.DeviceKey{KeyExpiryDisabled: true})) + dev, err = dr.Get(ctx, deviceID) + require.NoError(t, err) + assert.True(t, dev.KeyExpiryDisabled) + assert.False(t, srvNodeView(t, srv, id).Expiry().Valid()) + + // Re-enable is a no-op; expiry stays cleared. + require.NoError(t, dr.SetKey(ctx, deviceID, tsclient.DeviceKey{KeyExpiryDisabled: false})) + assert.False(t, srvNodeView(t, srv, id).Expiry().Valid()) + + // SetTags — flips ownership to the tags; the user is dropped. + require.NoError(t, dr.SetTags(ctx, deviceID, []string{"tag:ci"})) + dev, err = dr.Get(ctx, deviceID) + require.NoError(t, err) + assert.Equal(t, []string{"tag:ci"}, dev.Tags) + assert.Equal(t, types.TaggedDevices.Username(), dev.User) + n := srvNodeView(t, srv, id) + assert.True(t, n.IsTagged()) + assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice()) + assert.False(t, n.User().Valid()) + + // Re-tagging with the same tag is idempotent. + require.NoError(t, dr.SetTags(ctx, deviceID, []string{"tag:ci"})) + assert.Equal(t, []string{"tag:ci"}, srvNodeView(t, srv, id).Tags().AsSlice()) + + // SetAuthorized(true) is a no-op success; de-auth is rejected and inert. + require.NoError(t, dr.SetAuthorized(ctx, deviceID, true)) + dev, err = dr.Get(ctx, deviceID) + require.NoError(t, err) + assert.True(t, dev.Authorized) + + require.Error(t, dr.SetAuthorized(ctx, deviceID, false), "de-authorization is unsupported") + assert.True(t, srvNodeView(t, srv, id).Valid(), "rejected de-auth left the node present") + + // Delete — gone from the tool and the server. + require.NoError(t, dr.Delete(ctx, deviceID)) + _, err = dr.Get(ctx, deviceID) + assert.Truef(t, tsclient.IsNotFound(err), "get after delete should be 404, got %v", err) + + _, ok := srv.State().GetNodeByID(id) + assert.False(t, ok, "deleted node is gone server-side") +} + +func containsDevice(devs []tsclient.Device, id string) bool { + for _, d := range devs { + if d.NodeID == id || d.ID == id { + return true + } + } + + return false +} + +// apiv2ACLGoClient round-trips the policy file: read, raw-read, conditional set. +func apiv2ACLGoClient(t *testing.T, baseURL, apiKey string) { + t.Helper() + + ctx := t.Context() + pf := goClient(t, baseURL, apiKey).PolicyFile() + + acl, err := pf.Get(ctx) + require.NoError(t, err) + assert.NotEmpty(t, acl.ETag, "GET /acl carries an ETag") + + raw, err := pf.Raw(ctx) + require.NoError(t, err) + assert.NotEmpty(t, raw.HuJSON) + + // Set with the current etag as If-Match; keep tag:ci. + require.NoError(t, pf.Set(ctx, baselinePolicy, raw.ETag)) + + updated, err := pf.Raw(ctx) + require.NoError(t, err) + assert.Contains(t, updated.HuJSON, "tag:ci") +} + +// apiv2SettingsGoClient reads the tailnet settings (write is unsupported). +func apiv2SettingsGoClient(t *testing.T, baseURL, apiKey string) { + t.Helper() + + settings, err := goClient(t, baseURL, apiKey).TailnetSettings().Get(t.Context()) + require.NoError(t, err) + assert.Equal(t, "none", string(settings.UsersRoleAllowedToJoinExternalTailnets)) +} + +// tscliRun runs tscli and fails the test on a non-zero exit. +type tscliRun func(args ...string) string + +// tscliRunner returns runners for tscli against the local server: one that +// requires success, and one that tolerates a non-zero exit (for the expected +// 404 after a delete). +func tscliRunner(t *testing.T, baseURL, apiKey string) (tscliRun, func(args ...string) error) { + t.Helper() + + bin, err := exec.LookPath("tscli") + require.NoErrorf(t, err, "tscli is required for TestAPIv2 (provided by the nix dev shell)") + + env := append( + os.Environ(), + "TSCLI_BASE_URL="+baseURL, + "TAILSCALE_API_KEY="+apiKey, + "TAILSCALE_TAILNET=-", + ) + + cmd := func(args ...string) *exec.Cmd { + c := exec.CommandContext(t.Context(), bin, args...) + c.Env = env + + return c + } + + run := func(args ...string) string { + t.Helper() + + out, err := cmd(args...).CombinedOutput() + require.NoErrorf(t, err, "tscli %s\n%s", strings.Join(args, " "), out) + + return string(out) + } + + runAllowErr := func(args ...string) error { + t.Helper() + + return cmd(args...).Run() + } + + return run, runAllowErr +} + +// apiv2DevicesTSCLI drives the device verbs through tscli, asserting each +// mutation via get-after-set (tscli's own json output) and the server NodeStore. +func apiv2DevicesTSCLI(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, id types.NodeID) { + t.Helper() + + run, runAllowErr := tscliRunner(t, baseURL, apiKey) + deviceID := strconv.FormatUint(uint64(id), 10) + + assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), deviceID) + assert.True(t, nodeListed(srv, id)) + + // Name. + run("set", "device", "name", "--device", deviceID, "--name", "renamed-tscli") + assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), "renamed-tscli") + assert.Equal(t, "renamed-tscli", srvNodeView(t, srv, id).GivenName()) + + // Routes — one, two, then exit (both families). + run("set", "device", "routes", "--device", deviceID, "--route", "10.0.0.0/24") + assert.Contains(t, run("list", "routes", "--device", deviceID, "-o", "json"), "10.0.0.0/24") + assert.Contains(t, approvedRoutesOf(srvNodeView(t, srv, id)), "10.0.0.0/24") + + run("set", "device", "routes", "--device", deviceID, "--route", "10.0.0.0/24", "--route", "192.168.0.0/24") + + approved := approvedRoutesOf(srvNodeView(t, srv, id)) + assert.Contains(t, approved, "10.0.0.0/24") + assert.Contains(t, approved, "192.168.0.0/24") + + run("set", "device", "routes", "--device", deviceID, "--route", "0.0.0.0/0") + + exitApproved := approvedRoutesOf(srvNodeView(t, srv, id)) + assert.Contains(t, exitApproved, "0.0.0.0/0") + assert.Contains(t, exitApproved, "::/0") + + // Key — seed a real expiry first so disabling it is a transition. + future := time.Now().Add(24 * time.Hour) + _, _, err := srv.State().SetNodeExpiry(id, &future) + require.NoError(t, err) + + run("set", "device", "key", "--device", deviceID, "--disable-expiry") + assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), `"keyExpiryDisabled": true`) + assert.False(t, srvNodeView(t, srv, id).Expiry().Valid()) + + // Tags — flips to tag ownership. + run("set", "device", "tags", "--device", deviceID, "--tag", "tag:ci") + assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), "tag:ci") + n := srvNodeView(t, srv, id) + assert.True(t, n.IsTagged()) + assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice()) + assert.False(t, n.User().Valid()) + + // Authorization — approve is a no-op success. + run("set", "device", "authorization", "--device", deviceID, "--approve") + assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), `"authorized": true`) + + // Delete — gone from tscli and the server. + run("delete", "device", "--device", deviceID) + require.Error(t, runAllowErr("get", "device", "--device", deviceID, "-o", "json"), "get after delete should fail") + + _, ok := srv.State().GetNodeByID(id) + assert.False(t, ok) +} + +// apiv2ACLTSCLI reads and writes the policy file through tscli. +func apiv2ACLTSCLI(t *testing.T, baseURL, apiKey string) { + t.Helper() + + run, _ := tscliRunner(t, baseURL, apiKey) + + assert.Contains(t, run("get", "policy", "--json"), "acls") + + dir := t.TempDir() + polFile := filepath.Join(dir, "policy.hujson") + require.NoError(t, os.WriteFile(polFile, []byte(baselinePolicy), 0o600)) + run("set", "policy", "--file", polFile) +} + +// apiv2SettingsTSCLI reads the tailnet settings through tscli. +func apiv2SettingsTSCLI(t *testing.T, baseURL, apiKey string) { + t.Helper() + + run, _ := tscliRunner(t, baseURL, apiKey) + assert.Contains(t, run("get", "settings", "-o", "json"), "devicesKeyDurationDays") +} + +// devicesACLTFConfig exercises Terraform device + ACL data sources AND resources. +// %s is the test node's hostname (the tailscale_device data source key). +const devicesACLTFConfig = ` +terraform { + required_providers { + tailscale = { + source = "tailscale/tailscale" + version = "~> 0.21" + } + } +} + +provider "tailscale" {} + +resource "tailscale_acl" "policy" { + acl = jsonencode({ + tagOwners = { "tag:ci" = ["apiv2@"] } + acls = [{ action = "accept", src = ["*"], dst = ["*:*"] }] + }) + overwrite_existing_content = true +} + +data "tailscale_device" "dut" { + hostname = "%s" + wait_for = "30s" +} + +data "tailscale_devices" "all" {} + +data "tailscale_acl" "current" { + depends_on = [tailscale_acl.policy] +} + +resource "tailscale_device_authorization" "dut" { + device_id = data.tailscale_device.dut.node_id + authorized = true +} + +resource "tailscale_device_tags" "dut" { + device_id = data.tailscale_device.dut.node_id + tags = ["tag:ci"] + depends_on = [tailscale_acl.policy] +} + +resource "tailscale_device_key" "dut" { + device_id = data.tailscale_device.dut.node_id + key_expiry_disabled = true +} + +resource "tailscale_device_subnet_routes" "dut" { + device_id = data.tailscale_device.dut.node_id + routes = ["10.0.0.0/24"] +} + +output "dut_node_id" { value = data.tailscale_device.dut.node_id } +output "dut_addresses" { value = data.tailscale_device.dut.addresses } +output "dut_authorized" { value = data.tailscale_device.dut.authorized } +output "device_count" { value = length(data.tailscale_devices.all.devices) } +output "acl_hujson" { value = data.tailscale_acl.current.hujson } +output "enabled_routes" { value = tailscale_device_subnet_routes.dut.routes } +` + +// apiv2DevicesACLTerraform runs a tofu init/apply/destroy over the device and +// ACL data sources and resources, asserting no post-apply drift, the data-source +// outputs (read path) against the server truth, and the resulting server state +// (write path). parallelism=1 avoids racing concurrent mutations on the one +// shared node. +func apiv2DevicesACLTerraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey, hostname string, id types.NodeID) { + t.Helper() + + tf := newTofu(t, baseURL, apiKey, fmt.Sprintf(devicesACLTFConfig, hostname)) + + tf.run("init", "-no-color", "-input=false") + tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + + // Data sources resolved to real values that match the server. + outputs := tf.outputs() + assert.Equal(t, strconv.FormatUint(uint64(id), 10), outputs.str(t, "dut_node_id")) + assert.ElementsMatch(t, srvNodeView(t, srv, id).IPsAsString(), outputs.strSlice(t, "dut_addresses")) + outputs.jsonEq(t, "dut_authorized", true) + assert.Equal(t, srv.State().ListNodes().Len(), int(outputs.num(t, "device_count"))) + assert.Contains(t, outputs.str(t, "acl_hujson"), "tag:ci") + assert.Contains(t, outputs.strSlice(t, "enabled_routes"), "10.0.0.0/24") + + // Server-side: the resources actually applied (write path). + n := srvNodeView(t, srv, id) + assert.True(t, n.IsTagged()) + assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice()) + assert.Contains(t, approvedRoutesOf(n), "10.0.0.0/24") + assert.False(t, n.Expiry().Valid()) + + pol, err := srv.State().GetPolicy() + require.NoError(t, err) + assert.Contains(t, pol.Data, "tag:ci", "tailscale_acl wrote the policy") + + // A converged config must produce an empty plan — drift is a read/write bug. + tf.assertNoDrift() + + // destroy resets the policy; the node is a data source, so it persists. + // Tags/expiry teardown are no-ops on Headscale, so they are not reverted. + tf.run("destroy", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + + _, ok := srv.State().GetNodeByID(id) + assert.True(t, ok, "data-source node persists across destroy") +} diff --git a/hscontrol/servertest/apiv2_test.go b/hscontrol/servertest/apiv2_test.go new file mode 100644 index 000000000..eadc6b3ef --- /dev/null +++ b/hscontrol/servertest/apiv2_test.go @@ -0,0 +1,427 @@ +package servertest_test + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/juanfont/headscale/hscontrol/servertest" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + tsclient "tailscale.com/client/tailscale/v2" +) + +// TestAPIv2 proves the v2 API's Tailscale-compatible (ported) endpoints against the three real +// clients it exists to support: the official Go SDK, tscli, and the Tailscale +// Terraform provider (via OpenTofu). All three run against one Headscale bound +// to a real loopback port, authenticating with a user-owned API key. +// +// Every mutation is validated three ways — the tool's own get-after-set, the +// server-side NodeStore/state, and (for Terraform) a no-change plan proving no +// drift — so a server/provider read-write mismatch fails loudly. +// +// tscli and tofu are required, not optional: they ship in the nix dev shell, so +// a missing binary means a broken environment and the test fails rather than +// silently skipping. +func TestAPIv2(t *testing.T) { + srv := servertest.NewServer(t, servertest.WithRealListener()) + owner := srv.CreateUser(t, "apiv2") + apiKey := srv.CreateAPIKey(t, owner) + + // tag:ci must exist in policy for device SetTags; every policy the tests + // write keeps it, so subtest order is irrelevant. Terraform runs last so its + // ACL teardown does not strand the others. + setBaselinePolicy(t, srv) + + t.Run("GoClient", func(t *testing.T) { + apiv2GoClient(t, srv, srv.URL, apiKey, owner) + + node := srv.CreateRegisteredNode(t, owner, "dut-go") + apiv2DevicesGoClient(t, srv, srv.URL, apiKey, node.ID()) + apiv2ACLGoClient(t, srv.URL, apiKey) + apiv2SettingsGoClient(t, srv.URL, apiKey) + }) + + t.Run("TSCLI", func(t *testing.T) { + apiv2TSCLI(t, srv, srv.URL, apiKey) + + node := srv.CreateRegisteredNode(t, owner, "dut-tscli") + apiv2DevicesTSCLI(t, srv, srv.URL, apiKey, node.ID()) + apiv2ACLTSCLI(t, srv.URL, apiKey) + apiv2SettingsTSCLI(t, srv.URL, apiKey) + }) + + t.Run("Terraform", func(t *testing.T) { + apiv2Terraform(t, srv, srv.URL, apiKey, owner) + + node := srv.CreateRegisteredNode(t, owner, "dut-tf") + apiv2DevicesACLTerraform(t, srv, srv.URL, apiKey, node.Hostname(), node.ID()) + }) +} + +// apiv2GoClient exercises the official SDK with untagged (user-owned) keys — the +// default Terraform/tscli path — validating each operation against the server's +// stored PreAuthKey, plus ephemeral and default-expiry permutations. +func apiv2GoClient(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) { + t.Helper() + + ctx := t.Context() + keys := goClient(t, baseURL, apiKey).Keys() + wantOwner := strconv.FormatUint(uint64(owner.ID), 10) + + var req tsclient.CreateKeyRequest + + req.Description = "go-client" + req.ExpirySeconds = 3600 + req.Capabilities.Devices.Create.Reusable = true + + created, err := keys.CreateAuthKey(ctx, req) + require.NoError(t, err) + assert.NotEmpty(t, created.ID) + assert.NotEmpty(t, created.Key, "secret returned on create") + assert.Equal(t, "go-client", created.Description) + assert.Equal(t, wantOwner, created.UserID, "user-owned key reports its owner") + + // Server-side: the stored key matches the request and is owned by the user. + pak := srvPreAuthKey(t, srv, created.ID) + assert.True(t, pak.Reusable) + assert.False(t, pak.Ephemeral) + assert.Empty(t, pak.Tags, "no tags -> user-owned") + require.NotNil(t, pak.User) + assert.Equal(t, owner.ID, pak.User.ID) + assert.Equal(t, "go-client", pak.Description) + require.NotNil(t, pak.CreatedAt) + require.NotNil(t, pak.Expiration) + assert.InDelta(t, 3600, pak.Expiration.Sub(*pak.CreatedAt).Seconds(), 5) + + got, err := keys.Get(ctx, created.ID) + require.NoError(t, err) + assert.Equal(t, created.ID, got.ID) + assert.Empty(t, got.Key, "secret omitted on get") + assert.Equal(t, "go-client", got.Description) + assert.False(t, got.Invalid) + // The SDK decodes our integer expirySeconds into a Duration of nanoseconds, + // so never assert it numerically; the lifetime rides on Expires-Created. + assert.InDelta(t, 3600, got.Expires.Sub(got.Created).Seconds(), 5) + + list, err := keys.List(ctx, true) + require.NoError(t, err) + assert.True(t, containsKeyID(list, created.ID), "created key present in list") + + // DELETE soft-revokes (Tailscale-faithful): the key stays retrievable, now + // invalid, until the collector reaps it. + require.NoError(t, keys.Delete(ctx, created.ID)) + revoked, err := keys.Get(ctx, created.ID) + require.NoError(t, err, "revoked key stays retrievable") + assert.True(t, revoked.Invalid, "revoked key reports invalid") + require.NotNil(t, srvPreAuthKey(t, srv, created.ID).Revoked, "key soft-revoked server-side") + + // Permutation — ephemeral key. + var ephReq tsclient.CreateKeyRequest + + ephReq.Capabilities.Devices.Create.Ephemeral = true + eph, err := keys.CreateAuthKey(ctx, ephReq) + require.NoError(t, err) + assert.True(t, srvPreAuthKey(t, srv, eph.ID).Ephemeral) + require.NoError(t, keys.Delete(ctx, eph.ID)) + + // Permutation — default expiry (omit ExpirySeconds -> 90 days). + def, err := keys.CreateAuthKey(ctx, tsclient.CreateKeyRequest{}) + require.NoError(t, err) + defKey := srvPreAuthKey(t, srv, def.ID) + require.NotNil(t, defKey.CreatedAt) + require.NotNil(t, defKey.Expiration) + assert.InDelta(t, 7776000, defKey.Expiration.Sub(*defKey.CreatedAt).Seconds(), 5) + require.NoError(t, keys.Delete(ctx, def.ID)) +} + +func containsKeyID(keys []tsclient.Key, id string) bool { + for _, k := range keys { + if k.ID == id { + return true + } + } + + return false +} + +// srvPreAuthKey is the server-side ground truth for a key id; it fails the test +// if the key is absent. +func srvPreAuthKey(t *testing.T, srv *servertest.TestServer, id string) types.PreAuthKey { + t.Helper() + + pak := findPAKByID(t, srv, id) + require.NotNilf(t, pak, "pre-auth key %s not found server-side", id) + + return *pak +} + +// findPAKByID returns the stored key with the given stringified id, or nil. +func findPAKByID(t *testing.T, srv *servertest.TestServer, id string) *types.PreAuthKey { + t.Helper() + + want, err := strconv.ParseUint(id, 10, 64) + require.NoError(t, err) + + keys, err := srv.State().ListPreAuthKeys() + require.NoError(t, err) + + for i := range keys { + if keys[i].ID == want { + return &keys[i] + } + } + + return nil +} + +// apiv2TSCLI exercises tscli with a tagged key, validating server-side that the +// stored key carries the requested tags and metadata. +func apiv2TSCLI(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string) { + t.Helper() + + run, _ := tscliRunner(t, baseURL, apiKey) + + out := run( + "create", "key", + "--type", "authkey", + "--description", "tscli", + "--expiry", "1h", + "--reusable", + "--tags", "tag:ci", + "-o", "json", + ) + + var created struct { + ID string `json:"id"` + Key string `json:"key"` + } + require.NoErrorf(t, json.Unmarshal([]byte(out), &created), "tscli create output: %s", out) + assert.NotEmpty(t, created.ID) + assert.NotEmpty(t, created.Key, "secret returned on create") + + // Server-side: tagged, reusable, described, no owner. + pak := srvPreAuthKey(t, srv, created.ID) + assert.Equal(t, []string{"tag:ci"}, pak.Tags) + assert.True(t, pak.Reusable) + assert.Equal(t, "tscli", pak.Description) + assert.Nil(t, pak.User, "tagged key has no owning user") + + getOut := run("get", "key", "--key", created.ID, "-o", "json") + + var got struct { + Key string `json:"key"` + } + require.NoError(t, json.Unmarshal([]byte(getOut), &got)) + assert.Empty(t, got.Key, "secret omitted on get") + + assert.Contains(t, run("list", "keys", "--all", "-o", "json"), created.ID) + + // DELETE soft-revokes: the key stays retrievable (invalid) server-side until + // the collector reaps it. + run("delete", "key", "--key", created.ID) + assert.Contains(t, run("get", "key", "--key", created.ID, "-o", "json"), `"invalid": true`) + require.NotNil(t, srvPreAuthKey(t, srv, created.ID).Revoked, "key soft-revoked server-side") +} + +// terraformConfig drives the tailscale_tailnet_key resource against the local +// server. No tags, so the key is owned by the API key's user — the default +// Terraform path. Outputs expose the provider's read-back for value + drift +// checks. +const terraformConfig = ` +terraform { + required_providers { + tailscale = { + source = "tailscale/tailscale" + version = "~> 0.21" + } + } +} + +provider "tailscale" {} + +resource "tailscale_tailnet_key" "test" { + reusable = true + ephemeral = false + preauthorized = true + expiry = 3600 + description = "tofu-roundtrip" +} + +output "key_id" { value = tailscale_tailnet_key.test.id } +output "key_reusable" { value = tailscale_tailnet_key.test.reusable } +output "key_ephemeral" { value = tailscale_tailnet_key.test.ephemeral } +output "key_description" { value = tailscale_tailnet_key.test.description } +` + +// apiv2Terraform runs a tofu init→apply→(no-drift)→destroy roundtrip on a +// tailnet key, cross-checking the provider outputs and the server's stored key. +func apiv2Terraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) { + t.Helper() + + tf := newTofu(t, baseURL, apiKey, terraformConfig) + + tf.run("init", "-no-color", "-input=false") + tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + + outputs := tf.outputs() + keyID := outputs.str(t, "key_id") + require.NotEmpty(t, keyID) + outputs.jsonEq(t, "key_reusable", true) + outputs.jsonEq(t, "key_ephemeral", false) + outputs.jsonEq(t, "key_description", "tofu-roundtrip") + + // Server-side: the key exists, user-owned, with the requested attributes. + pak := srvPreAuthKey(t, srv, keyID) + assert.Equal(t, "tofu-roundtrip", pak.Description) + assert.True(t, pak.Reusable) + assert.False(t, pak.Ephemeral) + assert.Empty(t, pak.Tags, "no tags -> user-owned key") + require.NotNil(t, pak.User) + assert.Equal(t, owner.ID, pak.User.ID) + require.NotNil(t, pak.CreatedAt) + require.NotNil(t, pak.Expiration) + assert.InDelta(t, 3600, pak.Expiration.Sub(*pak.CreatedAt).Seconds(), 5) + + // A converged config must produce an empty plan — drift is a read/write bug. + tf.assertNoDrift() + + // destroy DELETEs the key, which soft-revokes it: the row is kept (revoked) + // until the collector reaps it. + tf.run("destroy", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + require.NotNil(t, srvPreAuthKey(t, srv, keyID).Revoked, "key revoked after destroy") +} + +// tofu binds a tofu binary, working dir, and env for a single workspace. cmd is +// a closure capturing the looked-up binary so subprocess construction stays in +// one place. +type tofu struct { + t *testing.T + cmd func(args ...string) *exec.Cmd +} + +func newTofu(t *testing.T, baseURL, apiKey, config string) *tofu { + t.Helper() + + bin, err := exec.LookPath("tofu") + require.NoErrorf(t, err, "tofu is required for TestAPIv2 (provided by the nix dev shell)") + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(config), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "plugin-cache"), 0o755)) + + env := append( + os.Environ(), + "TAILSCALE_BASE_URL="+baseURL, + "TAILSCALE_API_KEY="+apiKey, + "TAILSCALE_TAILNET=-", + // Keep provider plugins inside the temp dir so the run is self-contained. + "TF_PLUGIN_CACHE_DIR="+filepath.Join(dir, "plugin-cache"), + ) + + cmd := func(args ...string) *exec.Cmd { + c := exec.CommandContext(t.Context(), bin, args...) + c.Dir = dir + c.Env = env + + return c + } + + return &tofu{t: t, cmd: cmd} +} + +func (tf *tofu) run(args ...string) string { + tf.t.Helper() + + out, err := tf.cmd(args...).CombinedOutput() + require.NoErrorf(tf.t, err, "tofu %s\n%s", strings.Join(args, " "), out) + + return string(out) +} + +// assertNoDrift fails if a no-change plan reports changes. plan +// -detailed-exitcode returns 0 = no changes, 1 = error, 2 = drift. +func (tf *tofu) assertNoDrift() { + tf.t.Helper() + + out, err := tf.cmd("plan", "-detailed-exitcode", "-no-color", "-input=false", "-parallelism=1").CombinedOutput() + if err == nil { + return + } + + var exit *exec.ExitError + require.ErrorAsf(tf.t, err, &exit, "tofu plan\n%s", out) + require.Equalf(tf.t, 0, exit.ExitCode(), + "no-change plan after apply must be empty; drift means a provider read disagrees with desired state:\n%s", out) +} + +func (tf *tofu) outputs() tofuOutputs { + tf.t.Helper() + + out := tf.run("output", "-json", "-no-color") + + var raw map[string]struct { + Value json.RawMessage `json:"value"` + } + require.NoError(tf.t, json.Unmarshal([]byte(out), &raw)) + + o := make(tofuOutputs, len(raw)) + for k, v := range raw { + o[k] = v.Value + } + + return o +} + +// tofuOutputs is the decoded `tofu output -json`, keyed by output name. +type tofuOutputs map[string]json.RawMessage + +func (o tofuOutputs) raw(t *testing.T, key string) json.RawMessage { + t.Helper() + + v, ok := o[key] + require.Truef(t, ok, "output %q missing", key) + + return v +} + +func (o tofuOutputs) str(t *testing.T, key string) string { + t.Helper() + + var s string + require.NoError(t, json.Unmarshal(o.raw(t, key), &s)) + + return s +} + +func (o tofuOutputs) num(t *testing.T, key string) float64 { + t.Helper() + + var n float64 + require.NoError(t, json.Unmarshal(o.raw(t, key), &n)) + + return n +} + +func (o tofuOutputs) strSlice(t *testing.T, key string) []string { + t.Helper() + + var s []string + require.NoError(t, json.Unmarshal(o.raw(t, key), &s)) + + return s +} + +// jsonEq asserts the output decodes equal to want (handles bools/strings/numbers). +func (o tofuOutputs) jsonEq(t *testing.T, key string, want any) { + t.Helper() + + wantJSON, err := json.Marshal(want) + require.NoError(t, err) + require.JSONEq(t, string(wantJSON), string(o.raw(t, key))) +} From a066a06befc39d6409c82ec5c4cf4ef684dc37f7 Mon Sep 17 00:00:00 2001 From: Florian Preinstorfer Date: Wed, 24 Jun 2026 08:00:57 +0200 Subject: [PATCH 088/127] Fix invalid ip syntax The "ip" field needs to be a list. --- docs/ref/policy.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/ref/policy.md b/docs/ref/policy.md index 3529a81ee..f326d7ecb 100644 --- a/docs/ref/policy.md +++ b/docs/ref/policy.md @@ -158,17 +158,17 @@ devices. Can only be used in policy destinations. { "src": ["boss@"], "dst": ["boss@"], - "ip": "*" + "ip": ["*"] }, { "src": ["dev1@"], "dst": ["dev1@"], - "ip": "*" + "ip": ["*"] }, { "src": ["intern1@"], "dst": ["intern1@"], - "ip": "*" + "ip": ["*"] } ] } From ec0cb6f63a00aa55bb25047eb9aa74659b85e734 Mon Sep 17 00:00:00 2001 From: Baptiste Fontaine Date: Wed, 24 Jun 2026 09:44:30 +0200 Subject: [PATCH 089/127] docs: fix typos --- docs/setup/upgrade.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/setup/upgrade.md b/docs/setup/upgrade.md index e12359cf3..8176e933a 100644 --- a/docs/setup/upgrade.md +++ b/docs/setup/upgrade.md @@ -2,7 +2,7 @@ !!! tip "Required update path" - Its required to update from one stable version to the next (e.g. 0.26.0 → 0.27.1 → 0.28.0) without skipping minor + It's required to update from one stable version to the next (e.g. 0.26.0 → 0.27.1 → 0.28.0) without skipping minor versions in between. You should always pick the latest available patch release. Update an existing Headscale installation to a new version: @@ -23,7 +23,7 @@ upgrading. A full backup of Headscale depends on your individual setup, but belo === "Standard installation" - A installation that follows our [official releases](install/official.md) setup guide uses the following paths: + An installation that follows our [official releases](install/official.md) setup guide uses the following paths: - [Configuration file](../ref/configuration.md): `/etc/headscale/config.yaml` - Data directory: `/var/lib/headscale` @@ -37,7 +37,7 @@ upgrading. A full backup of Headscale depends on your individual setup, but belo === "Container" - A installation that follows our [container](install/container.md) setup guide uses a single source volume directory + An installation that follows our [container](install/container.md) setup guide uses a single source volume directory that contains the configuration file, data directory and the SQLite database. ```console From 475a5ead0cb4bf3a12d843f75b511798d1049411 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sun, 21 Jun 2026 04:20:36 +0000 Subject: [PATCH 090/127] api/v2: add user get and list (Tailscale compat) Backs the tailscale_user and tailscale_users data sources. getUser maps gorm/ErrUserNotFound to 404; unmodelled fields are honest constants, deviceCount/lastSeen/currentlyConnected aggregate the user's nodes. --- hscontrol/api/v2/api.go | 3 + hscontrol/api/v2/errors.go | 1 + hscontrol/api/v2/users.go | 175 +++++++++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 hscontrol/api/v2/users.go diff --git a/hscontrol/api/v2/api.go b/hscontrol/api/v2/api.go index 02d9a5c6c..a2a8b887e 100644 --- a/hscontrol/api/v2/api.go +++ b/hscontrol/api/v2/api.go @@ -244,6 +244,9 @@ const ( ScopeFeatureSettings Scope = "feature_settings" ScopeFeatureSettingsRead Scope = "feature_settings:read" + + ScopeUsers Scope = "users" + ScopeUsersRead Scope = "users:read" ) // scopeMetaKey keys the per-operation required Scope in huma.Operation.Metadata. diff --git a/hscontrol/api/v2/errors.go b/hscontrol/api/v2/errors.go index 78241b3d4..7bd25c4f5 100644 --- a/hscontrol/api/v2/errors.go +++ b/hscontrol/api/v2/errors.go @@ -66,6 +66,7 @@ func mapError(msg string, err error) error { switch { case errors.Is(err, gorm.ErrRecordNotFound), errors.Is(err, db.ErrPreAuthKeyNotFound), + errors.Is(err, db.ErrUserNotFound), errors.Is(err, state.ErrNodeNotFound): return huma.Error404NotFound(msg, err) diff --git a/hscontrol/api/v2/users.go b/hscontrol/api/v2/users.go new file mode 100644 index 000000000..c3d94526d --- /dev/null +++ b/hscontrol/api/v2/users.go @@ -0,0 +1,175 @@ +package apiv2 + +import ( + "context" + "net/http" + "strconv" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/types" +) + +func init() { + registrations = append(registrations, registerUsers) +} + +// Headscale models none of type/role/status and has a single tailnet, so these +// fields are fixed strings. Every account is an active member. +const ( + userTypeMember = "member" + userRoleMember = "member" + userStatusActive = "active" + singleTailnetID = "1" + + // tagTailscaleCompat marks operations ported from the Tailscale API. + tagTailscaleCompat = "Tailscale compat" +) + +// User is the Tailscale user response. Identity fields map from the Headscale +// user; type/role/status/tailnetId are constants (see above); the device fields +// are aggregated from the user's nodes. +type User struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + LoginName string `json:"loginName"` + ProfilePicURL string `json:"profilePicUrl"` + TailnetID string `json:"tailnetId"` + Created time.Time `json:"created"` + Type string `json:"type"` + Role string `json:"role"` + Status string `json:"status"` + DeviceCount int `json:"deviceCount"` + LastSeen time.Time `json:"lastSeen"` + CurrentlyConnected bool `json:"currentlyConnected"` +} + +type ( + userByIDInput struct { + UserID string `doc:"User id (the decimal user id)." path:"id"` + } + listUsersInput struct { + Tailnet string `doc:"Tailnet; must be \"-\" (the single Headscale tailnet)." path:"tailnet"` + Type string `doc:"Filter by user type; Headscale users are all \"member\"." query:"type"` + Role string `doc:"Filter by user role; Headscale users are all \"member\"." query:"role"` + } + + userOutput struct{ Body User } + listUsersOutput struct { + Body struct { + Users []User `json:"users" nullable:"false"` + } + } +) + +func registerUsers(api huma.API, b Backend) { + usersTags := []string{"Users", tagTailscaleCompat} + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "getUser", + Method: http.MethodGet, + Path: "/api/v2/users/{id}", + Summary: "Get a user", + Tags: usersTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeUsersRead), func(ctx context.Context, in *userByIDInput) (*userOutput, error) { + view, err := lookupUser(b, in.UserID) + if err != nil { + return nil, err + } + + return &userOutput{Body: userFromView(b, view)}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "listUsers", + Method: http.MethodGet, + Path: "/api/v2/tailnet/{tailnet}/users", + Summary: "List users", + Tags: usersTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeUsersRead), func(ctx context.Context, in *listUsersInput) (*listUsersOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + out := &listUsersOutput{} + out.Body.Users = []User{} + + // Headscale has only "member" users. A filter for any other type/role + // matches nothing, so return the empty envelope. + if !matchesMember(in.Type) || !matchesMember(in.Role) { + return out, nil + } + + users, err := b.State.ListAllUsers() + if err != nil { + return nil, huma.Error500InternalServerError("listing users", err) + } + + out.Body.Users = make([]User, 0, len(users)) + for i := range users { + out.Body.Users = append(out.Body.Users, userFromView(b, users[i].View())) + } + + return out, nil + }) +} + +// lookupUser resolves a user id to its UserView, mapping a malformed or unknown +// id to 404 (the Tailscale SDK keys IsNotFound off the status code), exactly as +// lookupNode does for devices. +func lookupUser(b Backend, rawID string) (types.UserView, error) { + id, err := parseID(rawID, "user") + if err != nil { + return types.UserView{}, err + } + + user, err := b.State.GetUserByID(types.UserID(id)) + if err != nil { + return types.UserView{}, mapError("looking up user", err) + } + + return user.View(), nil +} + +// matchesMember reports whether an optional type/role filter selects Headscale's +// only user kind. An empty value means "no filter". +func matchesMember(filter string) bool { + return filter == "" || filter == userTypeMember +} + +// userFromView maps a Headscale user onto the Tailscale User through the +// UserView accessors. deviceCount, lastSeen, and currentlyConnected are +// aggregated from the user's nodes in the NodeStore. +func userFromView(b Backend, view types.UserView) User { + u := User{ + ID: strconv.FormatUint(uint64(view.ID()), 10), + DisplayName: view.Display(), + LoginName: view.Username(), + ProfilePicURL: view.ProfilePicURL(), + TailnetID: singleTailnetID, + Created: view.CreatedAt(), + Type: userTypeMember, + Role: userRoleMember, + Status: userStatusActive, + } + + nodes := b.State.ListNodesByUser(types.UserID(view.ID())) + u.DeviceCount = nodes.Len() + + for _, node := range nodes.All() { + if node.IsOnline().Valid() && node.IsOnline().Get() { + u.CurrentlyConnected = true + } + + if ls := node.LastSeen(); ls.Valid() && ls.Get().After(u.LastSeen) { + u.LastSeen = ls.Get() + } + } + + return u +} From 961b15313f5ff6c9addabb1a4d407eb68fc9b95a Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sun, 21 Jun 2026 04:20:54 +0000 Subject: [PATCH 091/127] hscontrol: contract-test the v2 users endpoint Pins the wire shape: honest constants, deviceCount aggregation, the {"users":[...]} envelope, ignored type/role filters, and 404 on unknown/non-numeric/pseudo-user ids. --- hscontrol/apiv2_users_test.go | 188 ++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 hscontrol/apiv2_users_test.go diff --git a/hscontrol/apiv2_users_test.go b/hscontrol/apiv2_users_test.go new file mode 100644 index 000000000..38efd9a68 --- /dev/null +++ b/hscontrol/apiv2_users_test.go @@ -0,0 +1,188 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + + "github.com/danielgtaylor/huma/v2/humatest" + "github.com/google/go-cmp/cmp" + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// userID stringifies a test user's id the way the API emits it. +func userID(u *types.User) string { + return strconv.FormatUint(uint64(u.ID), 10) +} + +// getUserByID GETs a user by id and decodes it. +func getUserByID(t *testing.T, api humatest.TestAPI, id string) apiv2.User { + t.Helper() + + resp := api.Get("/api/v2/users/" + id) + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var user apiv2.User + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &user)) + + return user +} + +// listUsers GETs the user list (with an optional raw query string) and returns +// the decoded slice plus the raw body, so tests can pin the envelope shape. +func listUsers(t *testing.T, api humatest.TestAPI, query string) ([]apiv2.User, string) { + t.Helper() + + resp := api.Get("/api/v2/tailnet/-/users" + query) + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var env struct { + Users []apiv2.User `json:"users"` + } + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &env)) + + return env.Users, resp.Body.String() +} + +func containsUserID(users []apiv2.User, id string) bool { + for _, u := range users { + if u.ID == id { + return true + } + } + + return false +} + +func TestAPIv2User_Get(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + user := app.state.CreateUserForTest("golden") + + got := getUserByID(t, api, userID(user)) + + // Shape for a user with no devices: identity mapped, unmodelled fields fixed. + want := apiv2.User{ + ID: userID(user), + DisplayName: "golden", + LoginName: "golden", + TailnetID: "1", + Created: user.CreatedAt, + Type: "member", + Role: "member", + Status: "active", + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("user mismatch (-want +got):\n%s", diff) + } +} + +func TestAPIv2User_Get_DeviceCount(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + user := app.state.CreateUserForTest("owner") + + const wantDevices = 3 + for i := range wantDevices { + node := app.state.CreateRegisteredNodeForTest(user, "dut-"+strconv.Itoa(i)) + app.state.PutNodeInStoreForTest(*node) + } + + got := getUserByID(t, api, userID(user)) + assert.Equal(t, wantDevices, got.DeviceCount, "deviceCount aggregates the user's nodes") +} + +func TestAPIv2User_Get_NotFound(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + tests := []struct { + name string + id string + }{ + {name: "unknown numeric id", id: "999999"}, + {name: "non-numeric id", id: "not-a-number"}, + // The tagged-devices pseudo-user is never a real DB row. + {name: "tagged-devices pseudo-user", id: strconv.Itoa(types.TaggedDevicesUserID)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := api.Get("/api/v2/users/" + tt.id) + assert.Equal(t, http.StatusNotFound, resp.Code) + assert.Contains(t, resp.Body.String(), `"message"`, "Tailscale error shape") + }) + } +} + +func TestAPIv2User_List(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + u1 := app.state.CreateUserForTest("alice") + u2 := app.state.CreateUserForTest("bob") + + users, body := listUsers(t, api, "") + assert.Contains(t, body, `"users"`, "list is enveloped under users") + assert.True(t, containsUserID(users, userID(u1))) + assert.True(t, containsUserID(users, userID(u2))) + assert.False(t, containsUserID(users, strconv.Itoa(types.TaggedDevicesUserID)), + "tagged-devices pseudo-user is not listed") + + for _, u := range users { + assert.NotEmpty(t, u.ID, "every user has an id") + assert.NotEmpty(t, u.LoginName, "every user has a login name") + assert.Equal(t, "member", u.Type) + assert.Equal(t, "active", u.Status) + } +} + +func TestAPIv2User_List_Filters(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + app.state.CreateUserForTest("alice") + app.state.CreateUserForTest("bob") + + tests := []struct { + name string + query string + wantEmpty bool + }{ + {name: "no filter", query: "", wantEmpty: false}, + {name: "type member", query: "?type=member", wantEmpty: false}, + {name: "role member", query: "?role=member", wantEmpty: false}, + {name: "member and member", query: "?type=member&role=member", wantEmpty: false}, + {name: "type shared", query: "?type=shared", wantEmpty: true}, + {name: "role admin", query: "?role=admin", wantEmpty: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + users, body := listUsers(t, api, tt.query) + assert.Contains(t, body, `"users"`) + assert.NotContains(t, body, "null", "empty list marshals as [] not null") + + if tt.wantEmpty { + assert.Empty(t, users) + } else { + assert.NotEmpty(t, users) + } + }) + } +} + +func TestAPIv2User_List_BadTailnet(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + resp := api.Get("/api/v2/tailnet/example.com/users") + assert.Equal(t, http.StatusNotFound, resp.Code) + assert.Contains(t, resp.Body.String(), `"message"`) +} From dd9f0a3f4fb2c8513946602fe61a8d1086a1de82 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sun, 21 Jun 2026 04:21:07 +0000 Subject: [PATCH 092/127] servertest: roundtrip v2 users and 4via6 through the clients Exercises the user data sources via the Go SDK, tscli, and OpenTofu, and backfills tailscale_4via6 (provider-local compute), each with the no-drift gate. --- hscontrol/servertest/apiv2_test.go | 151 +++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/hscontrol/servertest/apiv2_test.go b/hscontrol/servertest/apiv2_test.go index eadc6b3ef..4fb98d10c 100644 --- a/hscontrol/servertest/apiv2_test.go +++ b/hscontrol/servertest/apiv2_test.go @@ -2,6 +2,7 @@ package servertest_test import ( "encoding/json" + "fmt" "os" "os/exec" "path/filepath" @@ -40,6 +41,7 @@ func TestAPIv2(t *testing.T) { t.Run("GoClient", func(t *testing.T) { apiv2GoClient(t, srv, srv.URL, apiKey, owner) + apiv2UsersGoClient(t, srv, srv.URL, apiKey, owner) node := srv.CreateRegisteredNode(t, owner, "dut-go") apiv2DevicesGoClient(t, srv, srv.URL, apiKey, node.ID()) @@ -49,6 +51,7 @@ func TestAPIv2(t *testing.T) { t.Run("TSCLI", func(t *testing.T) { apiv2TSCLI(t, srv, srv.URL, apiKey) + apiv2UsersTSCLI(t, srv.URL, apiKey, owner) node := srv.CreateRegisteredNode(t, owner, "dut-tscli") apiv2DevicesTSCLI(t, srv, srv.URL, apiKey, node.ID()) @@ -58,6 +61,7 @@ func TestAPIv2(t *testing.T) { t.Run("Terraform", func(t *testing.T) { apiv2Terraform(t, srv, srv.URL, apiKey, owner) + apiv2UsersTerraform(t, srv, srv.URL, apiKey, owner) node := srv.CreateRegisteredNode(t, owner, "dut-tf") apiv2DevicesACLTerraform(t, srv, srv.URL, apiKey, node.Hostname(), node.ID()) @@ -150,6 +154,85 @@ func containsKeyID(keys []tsclient.Key, id string) bool { return false } +func containsUserID(users []tsclient.User, id string) bool { + for _, u := range users { + if u.ID == id { + return true + } + } + + return false +} + +// srvUserCount is the server-side ground truth for the number of users. +func srvUserCount(t *testing.T, srv *servertest.TestServer) int { + t.Helper() + + users, err := srv.State().ListAllUsers() + require.NoError(t, err) + + return len(users) +} + +// apiv2UsersGoClient exercises the Users data sources through the official SDK: +// get-by-id, list, the type/role filters (member matches all, anything else +// matches nothing), and a typed 404 — each cross-checked against server truth. +func apiv2UsersGoClient(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) { + t.Helper() + + ctx := t.Context() + ur := goClient(t, baseURL, apiKey).Users() + ownerID := strconv.FormatUint(uint64(owner.ID), 10) + + got, err := ur.Get(ctx, ownerID) + require.NoError(t, err) + assert.Equal(t, ownerID, got.ID) + assert.Equal(t, owner.Username(), got.LoginName) + assert.Equal(t, tsclient.UserTypeMember, got.Type) + assert.Equal(t, tsclient.UserStatusActive, got.Status) + assert.Equal(t, srv.State().ListNodesByUser(types.UserID(owner.ID)).Len(), got.DeviceCount, + "deviceCount matches the server's node count for the user") + + all, err := ur.List(ctx, nil, nil) + require.NoError(t, err) + assert.True(t, containsUserID(all, ownerID), "owner present in user list") + assert.Len(t, all, srvUserCount(t, srv)) + + // member matches every Headscale user; shared/admin match nothing. + members, err := ur.List(ctx, new(tsclient.UserTypeMember), nil) + require.NoError(t, err) + assert.Len(t, members, len(all)) + + shared, err := ur.List(ctx, new(tsclient.UserTypeShared), nil) + require.NoError(t, err) + assert.Empty(t, shared, "Headscale has no shared users") + + admins, err := ur.List(ctx, nil, new(tsclient.UserRoleAdmin)) + require.NoError(t, err) + assert.Empty(t, admins, "Headscale has no admin-role users") + + _, err = ur.Get(ctx, "999999") + require.Error(t, err) + assert.True(t, tsclient.IsNotFound(err), "unknown user id is a typed 404") +} + +// apiv2UsersTSCLI exercises the user verbs through tscli, asserting the owner is +// present in the list and retrievable by id. +func apiv2UsersTSCLI(t *testing.T, baseURL, apiKey string, owner *types.User) { + t.Helper() + + run, _ := tscliRunner(t, baseURL, apiKey) + ownerID := strconv.FormatUint(uint64(owner.ID), 10) + + listOut := run("list", "users", "-o", "json") + assert.Contains(t, listOut, ownerID) + assert.Contains(t, listOut, `"member"`) + + getOut := run("get", "user", "--user", ownerID, "-o", "json") + assert.Contains(t, getOut, ownerID) + assert.Contains(t, getOut, owner.Username()) +} + // srvPreAuthKey is the server-side ground truth for a key id; it fails the test // if the key is absent. func srvPreAuthKey(t *testing.T, srv *servertest.TestServer, id string) types.PreAuthKey { @@ -297,6 +380,74 @@ func apiv2Terraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey st require.NotNil(t, srvPreAuthKey(t, srv, keyID).Revoked, "key revoked after destroy") } +// usersTFConfig drives the tailscale_user (by login name) and tailscale_users +// data sources, plus tailscale_4via6 (provider-local compute, no server call) to +// prove that data source resolves against Headscale unchanged. %s is the owner's +// login name. Data sources create nothing, so the assertions are value +// correctness plus no drift on re-read. +const usersTFConfig = ` +terraform { + required_providers { + tailscale = { + source = "tailscale/tailscale" + version = "~> 0.21" + } + } +} + +provider "tailscale" {} + +data "tailscale_user" "owner" { + login_name = "%s" +} + +data "tailscale_users" "all" {} + +data "tailscale_4via6" "site" { + site = 7 + cidr = "10.1.1.0/24" +} + +output "user_id" { value = data.tailscale_user.owner.id } +output "user_login_name" { value = data.tailscale_user.owner.login_name } +output "user_type" { value = data.tailscale_user.owner.type } +output "user_device_count" { value = data.tailscale_user.owner.device_count } +output "users_count" { value = length(data.tailscale_users.all.users) } +output "via6" { value = data.tailscale_4via6.site.ipv6 } +` + +// apiv2UsersTerraform runs a tofu init/apply/(no-drift)/destroy over the +// tailscale_user + tailscale_users data sources (and the provider-local +// tailscale_4via6), cross-checking the data-source outputs against the server's +// stored users. +func apiv2UsersTerraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) { + t.Helper() + + tf := newTofu(t, baseURL, apiKey, fmt.Sprintf(usersTFConfig, owner.Username())) + + tf.run("init", "-no-color", "-input=false") + tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + + outputs := tf.outputs() + assert.Equal(t, strconv.FormatUint(uint64(owner.ID), 10), outputs.str(t, "user_id")) + assert.Equal(t, owner.Username(), outputs.str(t, "user_login_name")) + assert.Equal(t, "member", outputs.str(t, "user_type")) + assert.Equal(t, srv.State().ListNodesByUser(types.UserID(owner.ID)).Len(), + int(outputs.num(t, "user_device_count"))) + assert.Equal(t, srvUserCount(t, srv), int(outputs.num(t, "users_count"))) + + // tailscale_4via6 is computed by the provider with no server call; assert it + // resolved to a Tailscale 4via6 address. + assert.Contains(t, outputs.str(t, "via6"), "fd7a:115c:a1e0", "4via6 mapped address") + + // A converged data-source read must produce an empty plan — drift is a read bug. + tf.assertNoDrift() + + // destroy removes only TF state; the users persist (they are data sources). + tf.run("destroy", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + assert.GreaterOrEqual(t, srvUserCount(t, srv), 1, "users persist across data-source destroy") +} + // tofu binds a tofu binary, working dir, and env for a single workspace. cmd is // a closure capturing the looked-up binary so subprocess construction stays in // one place. From 83d3828ba12c43ac61dd7b8c745e0032e4c8d107 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 24 Jun 2026 08:16:09 +0000 Subject: [PATCH 093/127] api/v2: simplify endpoint comments --- hscontrol/api/v2/acl.go | 7 +++---- hscontrol/api/v2/settings.go | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/hscontrol/api/v2/acl.go b/hscontrol/api/v2/acl.go index 8129e7670..51bd25ce6 100644 --- a/hscontrol/api/v2/acl.go +++ b/hscontrol/api/v2/acl.go @@ -44,9 +44,8 @@ type setACLInput struct { Tailnet string `path:"tailnet"` IfMatch string `header:"If-Match"` Accept string `header:"Accept"` - // RawBody captures the HuJSON or JSON policy body verbatim; huma feeds the - // raw request bytes here regardless of Content-Type. The declared type only - // shapes the OpenAPI schema. + // RawBody captures the raw HuJSON or JSON policy bytes; huma feeds them here + // regardless of Content-Type. The declared type only shapes the OpenAPI schema. RawBody []byte `contentType:"application/json"` } @@ -183,7 +182,7 @@ func currentPolicy(b Backend) ([]byte, error) { return nil, huma.Error500InternalServerError("unsupported policy mode", nil) } -// streamPolicy writes the policy bytes verbatim with the chosen content type and +// streamPolicy writes the policy bytes as-is with the chosen content type and // a content-addressed ETag, bypassing huma's JSON marshaler so HuJSON survives. func streamPolicy(data []byte, contentType string) *huma.StreamResponse { return &huma.StreamResponse{Body: func(ctx huma.Context) { diff --git a/hscontrol/api/v2/settings.go b/hscontrol/api/v2/settings.go index cdd2bea03..58628370a 100644 --- a/hscontrol/api/v2/settings.go +++ b/hscontrol/api/v2/settings.go @@ -16,7 +16,7 @@ func init() { // TailnetSettings is the Tailscale tailnet-settings response. Headscale's config // is file-based and mostly not runtime-mutable, so only a few fields carry a -// real value; the rest report the honest "off"/default. +// real value; the rest report the default "off". type TailnetSettings struct { ACLsExternallyManagedOn bool `json:"aclsExternallyManagedOn"` ACLsExternalLink string `json:"aclsExternalLink"` From 036760df0293cc0d06af9b1cd9487953ed03cd2e Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 24 Jun 2026 08:16:17 +0000 Subject: [PATCH 094/127] hscontrol: collapse key and settings asserts into cmp.Diff --- hscontrol/apiv2_keys_test.go | 42 ++++++++++++++++++++------------ hscontrol/apiv2_settings_test.go | 20 +++++++++------ 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/hscontrol/apiv2_keys_test.go b/hscontrol/apiv2_keys_test.go index 679eb40fe..c71b18cfe 100644 --- a/hscontrol/apiv2_keys_test.go +++ b/hscontrol/apiv2_keys_test.go @@ -8,6 +8,8 @@ import ( "time" "github.com/danielgtaylor/huma/v2/humatest" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" "github.com/juanfont/headscale/hscontrol/types" "github.com/stretchr/testify/assert" @@ -131,17 +133,21 @@ func TestAPIv2Key_Create_Tagged(t *testing.T) { }) // (a) create response — Tailscale Key shape, exact seconds (int64 wire). - assert.Equal(t, "auth", created.KeyType) + // ID/Key/Created/Expires are server-assigned; assert their presence apart. + want := apiv2.Key{ + KeyType: "auth", + Description: "dev access", + ExpirySeconds: 86400, + Capabilities: taggedCaps("tag:test"), + Tags: []string{"tag:test"}, + } + if diff := cmp.Diff(want, created, cmpopts.IgnoreFields(apiv2.Key{}, "ID", "Key", "Created", "Expires")); diff != "" { + t.Errorf("created key mismatch (-want +got):\n%s", diff) + } + assert.NotEmpty(t, created.ID) assert.NotEmpty(t, created.Key, "secret returned on create") - assert.Equal(t, "dev access", created.Description) - assert.Equal(t, int64(86400), created.ExpirySeconds, "seconds, not nanoseconds") - assert.Empty(t, created.UserID, "tagged key presents no owner") - assert.Equal(t, []string{"tag:test"}, created.Capabilities.Devices.Create.Tags) - assert.True(t, created.Capabilities.Devices.Create.Reusable) - assert.True(t, created.Capabilities.Devices.Create.Preauthorized, "echoed on create") assert.NotNil(t, created.Expires) - assert.False(t, created.Invalid) // (c) server-side — the stored key. pak := srvKey(t, app, created.ID) @@ -234,14 +240,18 @@ func TestAPIv2Key_Get(t *testing.T) { }) got := getKey(t, api, created.ID) - assert.Equal(t, created.ID, got.ID) - assert.Empty(t, got.Key, "secret omitted on get") - assert.Equal(t, "dev access", got.Description) - assert.Equal(t, int64(86400), got.ExpirySeconds, "stable across get") - assert.True(t, got.Capabilities.Devices.Create.Reusable) - assert.True(t, got.Capabilities.Devices.Create.Preauthorized, "Headscale always preauthorizes") - assert.Equal(t, []string{"tag:test"}, got.Capabilities.Devices.Create.Tags) - assert.False(t, got.Invalid) + // Get omits the secret (empty Key) and is stable across the round-trip. + want := apiv2.Key{ + ID: created.ID, + KeyType: "auth", + Description: "dev access", + ExpirySeconds: 86400, + Capabilities: taggedCaps("tag:test"), + Tags: []string{"tag:test"}, + } + if diff := cmp.Diff(want, got, cmpopts.IgnoreFields(apiv2.Key{}, "Created", "Expires")); diff != "" { + t.Errorf("got key mismatch (-want +got):\n%s", diff) + } // Unknown id and bad tailnet both 404 with the Tailscale error body. assert.Equal(t, http.StatusNotFound, api.Get("/api/v2/tailnet/-/keys/999999").Code) diff --git a/hscontrol/apiv2_settings_test.go b/hscontrol/apiv2_settings_test.go index a46e83d78..62b88bd75 100644 --- a/hscontrol/apiv2_settings_test.go +++ b/hscontrol/apiv2_settings_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/danielgtaylor/huma/v2/humatest" + "github.com/google/go-cmp/cmp" apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" "github.com/juanfont/headscale/hscontrol/types" "github.com/stretchr/testify/assert" @@ -102,7 +103,7 @@ func TestAPIv2SettingsComputedFields(t *testing.T) { } } -// TestAPIv2SettingsConstantOffFields pins the honestly-hardcoded-off fields: +// TestAPIv2SettingsConstantOffFields pins the hardcoded-off fields: // even with every config knob set, they must not pick up signal. func TestAPIv2SettingsConstantOffFields(t *testing.T) { cfg := types.Config{ @@ -113,13 +114,16 @@ func TestAPIv2SettingsConstantOffFields(t *testing.T) { s := getSettings(t, settingsAPIWithConfig(t, &cfg)) - assert.False(t, s.DevicesApprovalOn) - assert.False(t, s.DevicesAutoUpdatesOn) - assert.False(t, s.UsersApprovalOn) - assert.False(t, s.NetworkFlowLoggingOn) - assert.False(t, s.RegionalRoutingOn) - assert.False(t, s.PostureIdentityCollectionOn) - assert.Empty(t, s.ACLsExternalLink) + // Only the four computed fields reflect cfg; everything else stays off. + want := apiv2.TailnetSettings{ + ACLsExternallyManagedOn: true, + DevicesKeyDurationDays: 90, + HTTPSEnabled: true, + UsersRoleAllowedToJoinExternalTailnets: "none", + } + if diff := cmp.Diff(want, s); diff != "" { + t.Errorf("settings mismatch (-want +got):\n%s", diff) + } } // TestAPIv2SettingsPatchUnsupported confirms writes are rejected and inert. From 66937040f231b0f67a2383f4e0289ebd76759677 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 23 Jun 2026 07:09:15 +0200 Subject: [PATCH 095/127] policy: allow the tailscale.com/cap/secrets capability It's setec's official cap; allowlist it so it can be granted via policy. --- hscontrol/policy/v2/types.go | 4 ++++ hscontrol/policy/v2/types_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index 4e2483235..50a89f8c1 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -2271,6 +2271,10 @@ var tailscaleCapAllowlist = map[tailcfg.PeerCapability]bool{ tailcfg.PeerCapabilityWebUI: true, // tailscale.com/cap/webui tailcfg.PeerCapabilityKubernetes: true, // tailscale.com/cap/kubernetes tailcfg.PeerCapabilityTsIDP: true, // tailscale.com/cap/tsidp + + // tailscale.com/cap/secrets is the capability used by setec + // (github.com/tailscale/setec); allow it so it can be granted via policy. + tailcfg.PeerCapability("tailscale.com/cap/secrets"): true, } // validateGrantSrcDstCombination validates [Grant]-specific source/destination diff --git a/hscontrol/policy/v2/types_test.go b/hscontrol/policy/v2/types_test.go index 1a046f126..19134e5b3 100644 --- a/hscontrol/policy/v2/types_test.go +++ b/hscontrol/policy/v2/types_test.go @@ -6244,3 +6244,27 @@ func TestUnmarshalPolicySSHTests(t *testing.T) { }) } } + +func TestValidateCapabilityName(t *testing.T) { + tests := []struct { + name string + cap string + wantErr error + }{ + {name: "custom domain allowed", cap: "example.com/cap/foo", wantErr: nil}, + {name: "allowlisted tailscale cap", cap: "tailscale.com/cap/drive", wantErr: nil}, + {name: "setec secrets cap allowed", cap: "tailscale.com/cap/secrets", wantErr: nil}, + {name: "non-allowlisted tailscale cap rejected", cap: "tailscale.com/cap/nope", wantErr: ErrCapNameTailscaleDomain}, + {name: "url scheme rejected", cap: "https://tailscale.com/cap/drive", wantErr: ErrCapNameInvalidForm}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateCapabilityName(tt.cap) + if tt.wantErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, tt.wantErr) + } + }) + } +} From 5aeeff98d0130f6e075b42e6050d0c02de8b9a94 Mon Sep 17 00:00:00 2001 From: Fredrik Ekre Date: Mon, 22 Jun 2026 13:12:02 +0200 Subject: [PATCH 096/127] oidc: fix NewProvider ignoring caller context timeout NewAuthProviderOIDC received a context with a 30-second timeout from its caller, but immediately discarded it by passing context.Background() to oidc.NewProvider. This caused both `headscale serve` and `headscale configtest` to hang indefinitely if the OIDC issuer was unreachable, rather than timing out and returning an error. Fix by passing the caller's ctx to oidc.NewProvider and remove the nolint:contextcheck annotation that was suppressing the linter warning about this bug. The annotation was introduced in ce580f82 as part of a bulk lint-suppression pass without addressing the underlying issue. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- hscontrol/oidc.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index fa2c38097..d432a052b 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -87,7 +87,7 @@ func NewAuthProviderOIDC( ) (*AuthProviderOIDC, error) { var err error // grab oidc config if it hasn't been already - oidcProvider, err := oidc.NewProvider(context.Background(), cfg.Issuer) //nolint:contextcheck + oidcProvider, err := oidc.NewProvider(ctx, cfg.Issuer) if err != nil { return nil, fmt.Errorf("creating OIDC provider from issuer config: %w", err) } From fe536dd79907b06dc70496d12b4418424d392a74 Mon Sep 17 00:00:00 2001 From: Florian Preinstorfer Date: Sun, 21 Jun 2026 06:34:08 +0200 Subject: [PATCH 097/127] Slightly restructure the example config comments --- config-example.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/config-example.yaml b/config-example.yaml index 28fc617d3..09f0356b8 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -230,12 +230,12 @@ database: # ssl: false ### TLS configuration -# -## Let's encrypt / ACME -# -# headscale supports automatically requesting and setting up +# See: https://headscale.net/stable/ref/tls/ + +## Let's Encrypt / ACME +# Headscale supports automatically requesting and setting up # TLS for a domain with Let's Encrypt. -# + # URL to ACME directory acme_url: https://acme-v02.api.letsencrypt.org/directory @@ -245,15 +245,13 @@ acme_email: "" # Domain name to request a TLS certificate for: tls_letsencrypt_hostname: "" -# Path to store certificates and metadata needed by -# letsencrypt -# For production: +# Path to store certificates and metadata needed by letsencrypt tls_letsencrypt_cache_dir: /var/lib/headscale/cache # Type of ACME challenge to use, currently supported types: # HTTP-01 or TLS-ALPN-01 -# See: https://headscale.net/stable/ref/tls/ tls_letsencrypt_challenge_type: HTTP-01 + # When HTTP-01 challenge is chosen, letsencrypt must set up a # verification endpoint, and it will be listening on: # :http = port 80 @@ -279,6 +277,7 @@ policy: # The mode can be "file" or "database" that defines # where the policies are stored and read from. mode: file + # If the mode is set to "file", the path to a HuJSON file containing policies. path: "" @@ -460,6 +459,7 @@ taildrop: # choice. auto_update: enabled: false + # Advanced performance tuning parameters. # The defaults are carefully chosen and should rarely need adjustment. # Only modify these if you have identified a specific performance issue. From 3846c3f1487c6bd75e1dbe85285150909ac6bb24 Mon Sep 17 00:00:00 2001 From: Florian Preinstorfer Date: Sun, 21 Jun 2026 15:45:57 +0200 Subject: [PATCH 098/127] Remove X-Restart-Triggers from systemd service file Its not used by systemd. --- packaging/systemd/headscale.service | 1 - 1 file changed, 1 deletion(-) diff --git a/packaging/systemd/headscale.service b/packaging/systemd/headscale.service index 7d20444ff..8a4ba47e9 100644 --- a/packaging/systemd/headscale.service +++ b/packaging/systemd/headscale.service @@ -1,7 +1,6 @@ [Unit] After=network.target Description=headscale coordination server for Tailscale -X-Restart-Triggers=/etc/headscale/config.yaml [Service] Type=simple From 6e6ea3758d5560c4a10ff1bceb54a34677c6d399 Mon Sep 17 00:00:00 2001 From: Florian Preinstorfer Date: Sun, 21 Jun 2026 17:30:53 +0200 Subject: [PATCH 099/127] Remove CAP_CHOWN from systemd service --- packaging/systemd/headscale.service | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packaging/systemd/headscale.service b/packaging/systemd/headscale.service index 8a4ba47e9..7f02c9150 100644 --- a/packaging/systemd/headscale.service +++ b/packaging/systemd/headscale.service @@ -14,8 +14,8 @@ RestartSec=5 WorkingDirectory=/var/lib/headscale ReadWritePaths=/var/lib/headscale -AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_CHOWN -CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_CHOWN +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE LockPersonality=true NoNewPrivileges=true PrivateDevices=true @@ -41,7 +41,6 @@ RuntimeDirectoryMode=0750 StateDirectory=headscale StateDirectoryMode=0750 SystemCallArchitectures=native -SystemCallFilter=@chown SystemCallFilter=@system-service SystemCallFilter=~@privileged UMask=0077 From 6e0814a84577c810bdc118c85eabe5d6d64cdffd Mon Sep 17 00:00:00 2001 From: Florian Preinstorfer Date: Mon, 22 Jun 2026 11:39:07 +0200 Subject: [PATCH 100/127] Add DevicePolicy and MemoryDenyWriteExecute I've used those for a long time with my setups. --- packaging/systemd/headscale.service | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packaging/systemd/headscale.service b/packaging/systemd/headscale.service index 7f02c9150..6881b290c 100644 --- a/packaging/systemd/headscale.service +++ b/packaging/systemd/headscale.service @@ -16,7 +16,9 @@ ReadWritePaths=/var/lib/headscale AmbientCapabilities=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE +DevicePolicy=closed LockPersonality=true +MemoryDenyWriteExecute=true NoNewPrivileges=true PrivateDevices=true PrivateMounts=true From f6865fb40c6e6bf150aa81d1e35890fcd34a456b Mon Sep 17 00:00:00 2001 From: Florian Preinstorfer Date: Mon, 22 Jun 2026 11:46:17 +0200 Subject: [PATCH 101/127] Filter @resources from the list of allowed syscalls --- packaging/systemd/headscale.service | 1 + 1 file changed, 1 insertion(+) diff --git a/packaging/systemd/headscale.service b/packaging/systemd/headscale.service index 6881b290c..9d3b69712 100644 --- a/packaging/systemd/headscale.service +++ b/packaging/systemd/headscale.service @@ -45,6 +45,7 @@ StateDirectoryMode=0750 SystemCallArchitectures=native SystemCallFilter=@system-service SystemCallFilter=~@privileged +SystemCallFilter=~@resources UMask=0077 [Install] From 7b1776a87e50689651499987c1ed49322ef20130 Mon Sep 17 00:00:00 2001 From: Florian Preinstorfer Date: Thu, 25 Jun 2026 10:45:07 +0200 Subject: [PATCH 102/127] Add changelog entry for systemd service refresh --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82f8251c0..26469eaf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ HTTP API directly. ### Changes - Expiring or deleting a non-existent pre-auth key now returns an error instead of silently succeeding [#3324](https://github.com/juanfont/headscale/pull/3324) +- Improve systemd service file hardening [#3341](https://github.com/juanfont/headscale/pull/3341) ## 0.29.1 (2026-06-18) From ee95cf58d9272ba73ae2314f52602069b7906808 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sun, 21 Jun 2026 17:35:14 +0000 Subject: [PATCH 103/127] hscontrol: add the OAuth client and access-token model Add the OAuth client type, its database storage, the scope grant package, policy tag-ownership exposure, and the state operations backing the v2 OAuth client-credentials flow. --- hscontrol/db/db.go | 75 +++++ hscontrol/db/oauth.go | 385 +++++++++++++++++++++++++ hscontrol/db/oauth_test.go | 204 +++++++++++++ hscontrol/db/preauth_keys.go | 38 ++- hscontrol/db/schema.sql | 30 ++ hscontrol/policy/pm.go | 6 + hscontrol/policy/v2/policy.go | 60 ++++ hscontrol/policy/v2/policy_test.go | 72 +++++ hscontrol/scope/scope.go | 119 ++++++++ hscontrol/scope/scope_property_test.go | 90 ++++++ hscontrol/scope/scope_test.go | 234 +++++++++++++++ hscontrol/state/state.go | 57 ++++ hscontrol/types/oauth.go | 153 ++++++++++ 13 files changed, 1509 insertions(+), 14 deletions(-) create mode 100644 hscontrol/db/oauth.go create mode 100644 hscontrol/db/oauth_test.go create mode 100644 hscontrol/scope/scope.go create mode 100644 hscontrol/scope/scope_property_test.go create mode 100644 hscontrol/scope/scope_test.go create mode 100644 hscontrol/types/oauth.go diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index af2a6d349..c9b94ef6c 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -831,6 +831,75 @@ WHERE user_id IS NULL }, Rollback: func(db *gorm.DB) error { return nil }, }, + { + // Add the OAuth client + access token tables backing the v2 API's + // OAuth client-credentials flow. They mirror the api_keys / + // pre_auth_keys security model: a public id/prefix plus an Argon2id + // hash of the secret. + // + // SQLite uses explicit DDL that matches schema.sql byte-for-byte + // (the squibble digest is the SQLite source of truth). Postgres, + // which has no digest and rejects SQLite-isms like AUTOINCREMENT, + // uses dialect-aware AutoMigrate, mirroring InitSchema's fresh-DB + // table creation so an existing Postgres deployment can upgrade. + ID: "202606211200-oauth-clients-and-tokens", + Migrate: func(tx *gorm.DB) error { + if tx.Migrator().HasTable(&types.OAuthClient{}) && + tx.Migrator().HasTable(&types.OAuthAccessToken{}) { + return nil + } + + if tx.Name() != "sqlite" { + return tx.AutoMigrate(&types.OAuthClient{}, &types.OAuthAccessToken{}) + } + + if !tx.Migrator().HasTable(&types.OAuthClient{}) { + err := tx.Exec(`CREATE TABLE oauth_clients( + id integer PRIMARY KEY AUTOINCREMENT, + client_id text, + secret_hash blob, + scopes text, + tags text, + description text, + user_id integer, + created_at datetime, + revoked datetime +)`).Error + if err != nil { + return fmt.Errorf("creating oauth_clients table: %w", err) + } + + err = tx.Exec(`CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id)`).Error + if err != nil { + return fmt.Errorf("creating oauth_clients index: %w", err) + } + } + + if !tx.Migrator().HasTable(&types.OAuthAccessToken{}) { + err := tx.Exec(`CREATE TABLE oauth_access_tokens( + id integer PRIMARY KEY AUTOINCREMENT, + prefix text, + hash blob, + client_id text, + scopes text, + tags text, + expiration datetime, + created_at datetime +)`).Error + if err != nil { + return fmt.Errorf("creating oauth_access_tokens table: %w", err) + } + + err = tx.Exec(`CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix)`).Error + if err != nil { + return fmt.Errorf("creating oauth_access_tokens index: %w", err) + } + } + + return nil + }, + Rollback: func(db *gorm.DB) error { return nil }, + }, }, ) @@ -842,6 +911,8 @@ WHERE user_id IS NULL &types.APIKey{}, &types.Node{}, &types.Policy{}, + &types.OAuthClient{}, + &types.OAuthAccessToken{}, ) if err != nil { return err @@ -857,6 +928,8 @@ WHERE user_id IS NULL `DROP INDEX IF EXISTS "idx_name_provider_identifier"`, `DROP INDEX IF EXISTS "idx_name_no_provider_identifier"`, `DROP INDEX IF EXISTS "idx_pre_auth_keys_prefix"`, + `DROP INDEX IF EXISTS "idx_oauth_clients_client_id"`, + `DROP INDEX IF EXISTS "idx_oauth_access_tokens_prefix"`, } for _, dropSQL := range dropIndexes { @@ -875,6 +948,8 @@ WHERE user_id IS NULL `CREATE UNIQUE INDEX idx_name_provider_identifier ON users(name, provider_identifier)`, `CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users(name) WHERE provider_identifier IS NULL`, `CREATE UNIQUE INDEX idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != ''`, + `CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id)`, + `CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix)`, } for _, indexSQL := range indexes { diff --git a/hscontrol/db/oauth.go b/hscontrol/db/oauth.go new file mode 100644 index 000000000..6ce757bc7 --- /dev/null +++ b/hscontrol/db/oauth.go @@ -0,0 +1,385 @@ +package db + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "runtime" + "slices" + "strings" + "time" + + "github.com/juanfont/headscale/hscontrol/types" + "golang.org/x/crypto/argon2" + "gorm.io/gorm" + "tailscale.com/util/rands" + "tailscale.com/util/set" +) + +const ( + // OAuth client secret: hskey-client--. The clientID + // is the public, indexed lookup key (the analogue of an API key's prefix) and + // is embedded in the secret so the token endpoint can derive it. The prefix + // itself lives in the types package ([types.OAuthClientPrefix]). + oauthClientIDLength = 12 + oauthClientSecretLength = 64 + + // OAuth access token: hskey-oauthtok--. The distinct + // prefix (vs hskey-api- admin keys, [types.AccessTokenPrefix]) lets the auth + // middleware dispatch a scoped token from an all-access admin key alone. + accessTokenPrefixLength = 12 + accessTokenSecretLength = 64 +) + +var ( + ErrOAuthClientNotFound = fmt.Errorf("oauth client not found: %w", gorm.ErrRecordNotFound) + ErrOAuthClientFailedToParse = errors.New("failed to parse oauth client secret") + ErrOAuthClientRevoked = errors.New("oauth client revoked") + + ErrAccessTokenNotFound = fmt.Errorf("oauth access token not found: %w", gorm.ErrRecordNotFound) + ErrAccessTokenFailedToParse = errors.New("failed to parse oauth access token") + ErrAccessTokenExpired = errors.New("oauth access token expired") + ErrAccessTokenClientRevoked = errors.New("oauth access token issuing client revoked or deleted") + + errSecretHashMalformed = errors.New("malformed secret hash") + errSecretMismatch = errors.New("secret does not match hash") +) + +// Argon2id parameters, OWASP's minimum recommendation (19 MiB, 2 iterations, 1 +// lane). They are encoded into every stored hash, so raising them later still +// verifies credentials stored under the old cost. +const ( + argon2Time = 2 + argon2Memory = 19 * 1024 + argon2Threads = 1 + argon2KeyLen = 32 + argon2SaltLen = 16 +) + +// argon2Limiter bounds concurrent Argon2id computations. Each costs ~19 MiB and +// the unauthenticated OAuth token endpoint runs one per attempt, so an unbounded +// flood could exhaust memory. ponytail: a global semaphore sized to GOMAXPROCS; +// revisit only if credential hashing ever becomes a throughput bottleneck. +var argon2Limiter = make(chan struct{}, max(2, runtime.GOMAXPROCS(0))) + +// hashSecret hashes a credential secret with Argon2id, encoded in PHC string +// form so the parameters travel with the hash. Argon2id is the current OWASP +// recommendation, replacing bcrypt for new credential storage. +func hashSecret(secret string) ([]byte, error) { + salt := make([]byte, argon2SaltLen) + + _, err := rand.Read(salt) + if err != nil { + return nil, fmt.Errorf("generating salt: %w", err) + } + + hash := argon2.IDKey([]byte(secret), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen) + + encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, argon2Memory, argon2Time, argon2Threads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(hash), + ) + + return []byte(encoded), nil +} + +// verifySecret reports whether secret matches a hashSecret-encoded hash. It +// reads the cost parameters from the stored hash and compares in constant time +// so a mismatch leaks no timing signal. +func verifySecret(encoded []byte, secret string) error { + parts := strings.Split(string(encoded), "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return errSecretHashMalformed + } + + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version { //nolint:noinlineerr + return errSecretHashMalformed + } + + var ( + memory, time uint32 + threads uint8 + ) + + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { //nolint:noinlineerr + return errSecretHashMalformed + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return errSecretHashMalformed + } + + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return errSecretHashMalformed + } + + argon2Limiter <- struct{}{} + //nolint:gosec // want is a 32-byte hash read back from storage, no overflow + got := argon2.IDKey([]byte(secret), salt, time, memory, threads, uint32(len(want))) + + <-argon2Limiter + + if subtle.ConstantTimeCompare(got, want) != 1 { + return errSecretMismatch + } + + return nil +} + +// CreateOAuthClient creates a new [types.OAuthClient] and returns the plaintext +// secret (shown ONCE) alongside the stored client. creatorUserID is the user who +// created it (informational), or nil. +func (hsdb *HSDatabase) CreateOAuthClient( + scopes, tags []string, + description string, + creatorUserID *uint, +) (string, *types.OAuthClient, error) { + tags, err := validateACLTags(tags) + if err != nil { + return "", nil, err + } + + scopes = set.SetOf(scopes).Slice() + slices.Sort(scopes) + + clientID := rands.HexString(oauthClientIDLength) + secret := rands.HexString(oauthClientSecretLength) + secretStr := types.OAuthClientPrefix + clientID + "-" + secret + + hash, err := hashSecret(secret) + if err != nil { + return "", nil, err + } + + now := time.Now().UTC() + client := types.OAuthClient{ + ClientID: clientID, + SecretHash: hash, + Scopes: scopes, + Tags: tags, + Description: description, + UserID: creatorUserID, + CreatedAt: &now, + } + + err = hsdb.Write(func(tx *gorm.DB) error { + return tx.Save(&client).Error + }) + if err != nil { + return "", nil, fmt.Errorf("saving oauth client: %w", err) + } + + return secretStr, &client, nil +} + +// AuthenticateOAuthClient validates a presented client secret and returns the +// matching, unrevoked [types.OAuthClient]. The client id is derived from the +// secret (its middle segment), so any separately-supplied client_id is +// redundant, matching Tailscale, where get-authkey passes a dummy id and the +// server derives the real one from the secret. +func (hsdb *HSDatabase) AuthenticateOAuthClient(secretStr string) (*types.OAuthClient, error) { + if secretStr == "" { + return nil, ErrOAuthClientFailedToParse + } + + // Tailscale allows the secret to carry optional ?key=value attributes when + // used directly as an auth key; strip them before parsing. + secretStr, _, _ = strings.Cut(secretStr, "?") + + _, rest, found := strings.Cut(secretStr, types.OAuthClientPrefix) + if !found { + return nil, ErrOAuthClientFailedToParse + } + + clientID, secret, err := parsePrefixedKey( + rest, + oauthClientIDLength, + oauthClientSecretLength, + ErrOAuthClientFailedToParse, + ) + if err != nil { + return nil, err + } + + var client types.OAuthClient + if err := hsdb.DB.First(&client, "client_id = ?", clientID).Error; err != nil { //nolint:noinlineerr + return nil, ErrOAuthClientNotFound + } + + if err := verifySecret(client.SecretHash, secret); err != nil { //nolint:noinlineerr + return nil, fmt.Errorf("invalid oauth client secret: %w", err) + } + + if client.Revoked != nil { + return nil, ErrOAuthClientRevoked + } + + return &client, nil +} + +// GetOAuthClientByClientID returns a [types.OAuthClient] by its public client id. +func (hsdb *HSDatabase) GetOAuthClientByClientID(clientID string) (*types.OAuthClient, error) { + var client types.OAuthClient + if result := hsdb.DB.First(&client, "client_id = ?", clientID); result.Error != nil { + return nil, result.Error + } + + return &client, nil +} + +// ListOAuthClients returns every [types.OAuthClient]. +func (hsdb *HSDatabase) ListOAuthClients() ([]types.OAuthClient, error) { + clients := []types.OAuthClient{} + + err := hsdb.DB.Find(&clients).Error + if err != nil { + return nil, err + } + + return clients, nil +} + +// RevokeOAuthClient deletes a client and all access tokens it issued. An unknown +// client id returns [ErrOAuthClientNotFound], so a repeated DELETE is a clean +// 404. Unlike pre-auth keys (which soft-revoke for node-registration history), an +// OAuth client has no such history and is removed outright, matching Tailscale. +func (hsdb *HSDatabase) RevokeOAuthClient(clientID string) error { + return hsdb.Write(func(tx *gorm.DB) error { + err := tx.Where("client_id = ?", clientID). + Delete(&types.OAuthAccessToken{}).Error + if err != nil { + return fmt.Errorf("deleting oauth access tokens: %w", err) + } + + res := tx.Where("client_id = ?", clientID).Delete(&types.OAuthClient{}) + if res.Error != nil { + return res.Error + } + + if res.RowsAffected == 0 { + return ErrOAuthClientNotFound + } + + return nil + }) +} + +// MintAccessToken stores a new [types.OAuthAccessToken] for clientID with the +// given (already narrowed) scopes/tags and expiration, returning the plaintext +// token (shown ONCE). +func (hsdb *HSDatabase) MintAccessToken( + clientID string, + scopes, tags []string, + expiration *time.Time, +) (string, *types.OAuthAccessToken, error) { + prefix := rands.HexString(accessTokenPrefixLength) + secret := rands.HexString(accessTokenSecretLength) + tokenStr := types.AccessTokenPrefix + prefix + "-" + secret + + hash, err := hashSecret(secret) + if err != nil { + return "", nil, err + } + + now := time.Now().UTC() + token := types.OAuthAccessToken{ + Prefix: prefix, + Hash: hash, + ClientID: clientID, + Scopes: scopes, + Tags: tags, + Expiration: expiration, + CreatedAt: &now, + } + + // Mint inside a transaction that re-checks the client still exists and is + // not revoked, so a mint cannot complete against a client being deleted. + err = hsdb.Write(func(tx *gorm.DB) error { + var client types.OAuthClient + + err := tx.First(&client, "client_id = ?", clientID).Error + if err != nil { + return ErrOAuthClientNotFound + } + + if client.Revoked != nil { + return ErrOAuthClientRevoked + } + + return tx.Save(&token).Error + }) + if err != nil { + return "", nil, fmt.Errorf("saving oauth access token: %w", err) + } + + return tokenStr, &token, nil +} + +// AuthenticateAccessToken validates a presented bearer token and returns the +// matching, unexpired [types.OAuthAccessToken] (carrying its granted scopes and +// tags). A non-nil error means the token is missing, malformed, or expired. +func (hsdb *HSDatabase) AuthenticateAccessToken(tokenStr string) (*types.OAuthAccessToken, error) { + if tokenStr == "" { + return nil, ErrAccessTokenFailedToParse + } + + _, rest, found := strings.Cut(tokenStr, types.AccessTokenPrefix) + if !found { + return nil, ErrAccessTokenFailedToParse + } + + prefix, secret, err := parsePrefixedKey( + rest, + accessTokenPrefixLength, + accessTokenSecretLength, + ErrAccessTokenFailedToParse, + ) + if err != nil { + return nil, err + } + + var token types.OAuthAccessToken + if err := hsdb.DB.First(&token, "prefix = ?", prefix).Error; err != nil { //nolint:noinlineerr + return nil, ErrAccessTokenNotFound + } + + if err := verifySecret(token.Hash, secret); err != nil { //nolint:noinlineerr + return nil, fmt.Errorf("invalid oauth access token: %w", err) + } + + if token.Expiration != nil && token.Expiration.Before(time.Now()) { + return nil, ErrAccessTokenExpired + } + + // Bind validity to the issuing client: a token whose client has been + // revoked or deleted is rejected. This closes a mint/revoke race (where a + // token could be inserted after the client's tokens were purged) and any + // orphan left by manual deletion or a future soft-revoke path. + var client types.OAuthClient + if err := hsdb.DB.First(&client, "client_id = ?", token.ClientID).Error; err != nil { //nolint:noinlineerr + return nil, ErrAccessTokenClientRevoked + } + + if client.Revoked != nil { + return nil, ErrAccessTokenClientRevoked + } + + return &token, nil +} + +// DeleteExpiredAccessTokens hard-deletes every access token that expired before +// cutoff, returning how many were removed. Auth-time checks already reject +// expired tokens; the hourly reaper (see app.go) calls this only to keep the +// table from growing unbounded. +func (hsdb *HSDatabase) DeleteExpiredAccessTokens(cutoff time.Time) (int64, error) { + res := hsdb.DB.Where("expiration IS NOT NULL AND expiration < ?", cutoff). + Delete(&types.OAuthAccessToken{}) + + return res.RowsAffected, res.Error +} diff --git a/hscontrol/db/oauth_test.go b/hscontrol/db/oauth_test.go new file mode 100644 index 000000000..1bf8c72d4 --- /dev/null +++ b/hscontrol/db/oauth_test.go @@ -0,0 +1,204 @@ +package db + +import ( + "strings" + "sync" + "testing" + "time" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestVerifySecretConcurrent runs more concurrent verifications than the Argon2 +// concurrency semaphore admits, asserting the limiter releases correctly (no +// deadlock) and stays correct under contention. Run with -race. +func TestVerifySecretConcurrent(t *testing.T) { + hash, err := hashSecret("s3cr3t") + require.NoError(t, err) + + const n = 64 + + var wg sync.WaitGroup + + errs := make([]error, n) + + for i := range n { + wg.Add(1) + + go func(i int) { + defer wg.Done() + + if i%2 == 0 { + errs[i] = verifySecret(hash, "s3cr3t") + } else { + errs[i] = verifySecret(hash, "wrong") + } + }(i) + } + + wg.Wait() + + for i, e := range errs { + if i%2 == 0 { + assert.NoError(t, e, "correct secret must verify") + } else { + assert.Error(t, e, "wrong secret must fail") + } + } +} + +func TestOAuthClientCreateAndAuthenticate(t *testing.T) { + db, err := newSQLiteTestDB() + require.NoError(t, err) + + secret, client, err := db.CreateOAuthClient( + []string{"auth_keys", "devices:core"}, + []string{"tag:ci"}, + "my client", + nil, + ) + require.NoError(t, err) + require.NotNil(t, client) + + // Secret carries the public client id as its middle segment, so it can be + // derived from the secret alone (the Tailscale get-authkey trick). + assert.True(t, strings.HasPrefix(secret, "hskey-client-"+client.ClientID+"-")) + // Scopes/tags are deduplicated and sorted for stable storage. + assert.Equal(t, []string{"auth_keys", "devices:core"}, client.Scopes) + assert.Equal(t, []string{"tag:ci"}, client.Tags) + // Only the Argon2id hash is stored, never the plaintext. + assert.NotEmpty(t, client.SecretHash) + assert.True(t, strings.HasPrefix(string(client.SecretHash), "$argon2id$")) + + // The secret authenticates, deriving the client id from the secret itself. + got, err := db.AuthenticateOAuthClient(secret) + require.NoError(t, err) + assert.Equal(t, client.ClientID, got.ClientID) + + // A truncated/garbage secret does not. + _, err = db.AuthenticateOAuthClient("hskey-client-deadbeef-nope") + require.Error(t, err) + + // Wrong secret for a real client id is rejected by the constant-time compare. + _, err = db.AuthenticateOAuthClient("hskey-client-" + client.ClientID + "-" + strings.Repeat("0", 64)) + require.Error(t, err) +} + +func TestHashSecretRoundTrip(t *testing.T) { + const secret = "a-high-entropy-credential-secret" + + encoded, err := hashSecret(secret) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(string(encoded), "$argon2id$v=")) + + // The same secret hashes to a different value each time (random salt) yet + // still verifies. + encoded2, err := hashSecret(secret) + require.NoError(t, err) + assert.NotEqual(t, encoded, encoded2) + + require.NoError(t, verifySecret(encoded, secret)) + require.ErrorIs(t, verifySecret(encoded, "wrong-secret"), errSecretMismatch) + require.ErrorIs(t, verifySecret([]byte("not-a-phc-string"), secret), errSecretHashMalformed) +} + +func TestOAuthClientRevoke(t *testing.T) { + db, err := newSQLiteTestDB() + require.NoError(t, err) + + secret, client, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil) + require.NoError(t, err) + + // A token minted by the client survives only until the client is revoked. + _, _, err = db.MintAccessToken(client.ClientID, client.Scopes, client.Tags, nil) + require.NoError(t, err) + + require.NoError(t, db.RevokeOAuthClient(client.ClientID)) + + // The client no longer authenticates and a repeated revoke is a clean 404. + _, err = db.AuthenticateOAuthClient(secret) + require.Error(t, err) + require.ErrorIs(t, db.RevokeOAuthClient(client.ClientID), ErrOAuthClientNotFound) +} + +func TestOAuthAccessTokenMintAuthenticateExpire(t *testing.T) { + db, err := newSQLiteTestDB() + require.NoError(t, err) + + _, client, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil) + require.NoError(t, err) + + future := time.Now().Add(time.Hour) + tokenStr, token, err := db.MintAccessToken( + client.ClientID, + []string{"auth_keys"}, + []string{"tag:ci"}, + &future, + ) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(tokenStr, "hskey-oauthtok-")) + + got, err := db.AuthenticateAccessToken(tokenStr) + require.NoError(t, err) + assert.Equal(t, client.ClientID, got.ClientID) + assert.Equal(t, []string{"auth_keys"}, got.Scopes) + assert.Equal(t, []string{"tag:ci"}, got.Tags) + + // An expired token is rejected even though the row still exists. + past := time.Now().Add(-time.Hour) + expiredStr, _, err := db.MintAccessToken(client.ClientID, nil, nil, &past) + require.NoError(t, err) + _, err = db.AuthenticateAccessToken(expiredStr) + require.ErrorIs(t, err, ErrAccessTokenExpired) + + // The reaper deletes the expired row; the live token is untouched. + n, err := db.DeleteExpiredAccessTokens(time.Now()) + require.NoError(t, err) + assert.Equal(t, int64(1), n) + + _ = token + + _, err = db.AuthenticateAccessToken(tokenStr) + require.NoError(t, err) +} + +// TestAccessTokenRejectedWhenClientGone asserts a token whose issuing client no +// longer exists (orphaned by a delete/revoke race) is rejected, even though the +// token row itself is valid and unexpired. +func TestAccessTokenRejectedWhenClientGone(t *testing.T) { + db, err := newSQLiteTestDB() + require.NoError(t, err) + + _, client, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil) + require.NoError(t, err) + + future := time.Now().Add(time.Hour) + tokenStr, _, err := db.MintAccessToken(client.ClientID, []string{"auth_keys"}, []string{"tag:ci"}, &future) + require.NoError(t, err) + + _, err = db.AuthenticateAccessToken(tokenStr) + require.NoError(t, err) + + // Delete only the client row, leaving the token orphaned (the state a + // mint/revoke race or manual deletion would produce). + require.NoError(t, db.DB.Where("client_id = ?", client.ClientID).Delete(&types.OAuthClient{}).Error) + + _, err = db.AuthenticateAccessToken(tokenStr) + require.ErrorIs(t, err, ErrAccessTokenClientRevoked) + + // A soft-revoked client (row present, Revoked set) is likewise rejected. + _, client2, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil) + require.NoError(t, err) + + tokenStr2, _, err := db.MintAccessToken(client2.ClientID, []string{"auth_keys"}, []string{"tag:ci"}, &future) + require.NoError(t, err) + + now := time.Now() + require.NoError(t, db.DB.Model(&types.OAuthClient{}). + Where("client_id = ?", client2.ClientID).Update("revoked", now).Error) + + _, err = db.AuthenticateAccessToken(tokenStr2) + require.ErrorIs(t, err, ErrAccessTokenClientRevoked) +} diff --git a/hscontrol/db/preauth_keys.go b/hscontrol/db/preauth_keys.go index 1da35ac36..e839ff507 100644 --- a/hscontrol/db/preauth_keys.go +++ b/hscontrol/db/preauth_keys.go @@ -25,6 +25,26 @@ var ( ErrPreAuthKeyACLTagInvalid = errors.New("auth-key tag is invalid") ) +// validateACLTags deduplicates, sorts, and checks that every tag carries the +// "tag:" prefix. Shared by the pre-auth-key and OAuth credential paths so both +// enforce the same tag shape. +func validateACLTags(tags []string) ([]string, error) { + tags = set.SetOf(tags).Slice() + slices.Sort(tags) + + for _, tag := range tags { + if !strings.HasPrefix(tag, "tag:") { + return nil, fmt.Errorf( + "%w: '%s' did not begin with 'tag:'", + ErrPreAuthKeyACLTagInvalid, + tag, + ) + } + } + + return tags, nil +} + func (hsdb *HSDatabase) CreatePreAuthKey( uid *types.UserID, reusable bool, @@ -76,20 +96,9 @@ func CreatePreAuthKey( userID = &user.ID } - // Remove duplicates and sort for consistency - aclTags = set.SetOf(aclTags).Slice() - slices.Sort(aclTags) - - // TODO(kradalby): factor out and create a reusable tag validation, - // check if there is one in Tailscale's lib. - for _, tag := range aclTags { - if !strings.HasPrefix(tag, "tag:") { - return nil, fmt.Errorf( - "%w: '%s' did not begin with 'tag:'", - ErrPreAuthKeyACLTagInvalid, - tag, - ) - } + aclTags, err := validateACLTags(aclTags) + if err != nil { + return nil, err } now := time.Now().UTC() @@ -228,6 +237,7 @@ func findAuthKey(tx *gorm.DB, keyStr string) (*types.PreAuthKey, error) { // separator-based to handle dashes in base64 URL-safe characters. func parsePrefixedKey( prefixAndSecret string, + //nolint:unparam // kept explicit though every credential kind uses a 12-char prefix and 64-char secret today prefixLen, secretLen int, parseErr error, ) (string, string, error) { diff --git a/hscontrol/db/schema.sql b/hscontrol/db/schema.sql index 6343ad81c..59b601601 100644 --- a/hscontrol/db/schema.sql +++ b/hscontrol/db/schema.sql @@ -70,6 +70,36 @@ CREATE TABLE api_keys( ); CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(prefix); +-- OAuth 2.0 client-credentials clients for the v2 API. client_id is public and +-- embedded in the secret (hskey-client--); only the bcrypt +-- hash of the secret is stored. Mirrors the api_keys security model. +CREATE TABLE oauth_clients( + id integer PRIMARY KEY AUTOINCREMENT, + client_id text, + secret_hash blob, + scopes text, + tags text, + description text, + user_id integer, + created_at datetime, + revoked datetime +); +CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id); + +-- Short-lived bearer access tokens minted by an oauth_client. Stored as a bcrypt +-- hash of the secret, looked up by prefix. +CREATE TABLE oauth_access_tokens( + id integer PRIMARY KEY AUTOINCREMENT, + prefix text, + hash blob, + client_id text, + scopes text, + tags text, + expiration datetime, + created_at datetime +); +CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix); + CREATE TABLE nodes( id integer PRIMARY KEY AUTOINCREMENT, machine_key text, diff --git a/hscontrol/policy/pm.go b/hscontrol/policy/pm.go index 4c9dc3970..ffde6361a 100644 --- a/hscontrol/policy/pm.go +++ b/hscontrol/policy/pm.go @@ -33,6 +33,12 @@ type PolicyManager interface { // TagExists reports whether the given tag is defined in the policy. TagExists(tag string) bool + // TagOwnedByTags reports whether a credential holding ownerTags may apply + // tag: true if tag is one of ownerTags, or tag's tag-to-tag ownership chain + // transitively includes one of ownerTags. Authorises the tags an OAuth + // access token may set on the auth keys it mints. + TagOwnedByTags(tag string, ownerTags []string) bool + // NodeCanApproveRoute reports whether the given node can approve the given route. NodeCanApproveRoute(node types.NodeView, route netip.Prefix) bool diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go index 49750713c..7da5dcb22 100644 --- a/hscontrol/policy/v2/policy.go +++ b/hscontrol/policy/v2/policy.go @@ -968,6 +968,66 @@ func (pm *PolicyManager) NodeCanHaveTag(node types.NodeView, tag string) bool { return false } +// TagOwnedByTags reports whether a credential holding ownerTags is authorised to +// apply tag. It is true when tag is one of ownerTags, or when tag's tagOwners +// chain (tag-to-tag ownership) transitively includes one of ownerTags. This is +// the tag-level check used when an OAuth access token mints an auth key: the +// requested tags must each be owned by the token's tags, so an operator token +// tagged tag:k8s-operator may mint tag:k8s keys when the policy declares +// "tag:k8s": ["tag:k8s-operator"]. It is purely tag-relational and does not +// consult node IPs. +func (pm *PolicyManager) TagOwnedByTags(tag string, ownerTags []string) bool { + if pm == nil { + return false + } + + owns := make(map[string]bool, len(ownerTags)) + for _, t := range ownerTags { + owns[t] = true + } + + // A credential may always apply a tag it directly holds; this needs no policy. + if owns[tag] { + return true + } + + pm.mu.Lock() + defer pm.mu.Unlock() + + // Owned-by delegation requires the policy's tagOwners. + if pm.pol == nil { + return false + } + + // Walk tag-to-tag ownership transitively, guarding against cycles. + visited := make(map[Tag]bool) + + var walk func(t Tag) bool + + walk = func(t Tag) bool { + if visited[t] { + return false + } + + visited[t] = true + + for _, owner := range pm.pol.TagOwners[t] { + ot, ok := owner.(*Tag) + if !ok { + continue + } + + if owns[string(*ot)] || walk(*ot) { + return true + } + } + + return false + } + + return walk(Tag(tag)) +} + // userMatchesOwner checks if a user matches a tag owner entry. // This is used as a fallback when the node's IP is not in the [PolicyManager.tagOwnerMap]. func (pm *PolicyManager) userMatchesOwner(user types.UserView, owner Owner) bool { diff --git a/hscontrol/policy/v2/policy_test.go b/hscontrol/policy/v2/policy_test.go index 9237f33fe..bc8b0f256 100644 --- a/hscontrol/policy/v2/policy_test.go +++ b/hscontrol/policy/v2/policy_test.go @@ -2470,3 +2470,75 @@ func TestPeerRelayGrantMakesRelayVisible(t *testing.T) { }) } } + +func TestTagOwnedByTags(t *testing.T) { + // tag:leaf is owned by tag:mid, which is owned by tag:root: a tag-to-tag + // delegation chain, the shape an operator token uses to mint narrower keys. + const policy = `{ + "tagOwners": { + "tag:root": [], + "tag:mid": ["tag:root"], + "tag:leaf": ["tag:mid"], + "tag:lone": [] + }, + "acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}] + }` + + pm, err := NewPolicyManager([]byte(policy), nil, types.Nodes{}.ViewSlice()) + require.NoError(t, err) + + tests := []struct { + name string + tag string + ownerTags []string + want bool + }{ + { + name: "directly held tag needs no policy", + tag: "tag:lone", + ownerTags: []string{"tag:lone"}, + want: true, + }, + { + name: "one-hop owned-by", + tag: "tag:mid", + ownerTags: []string{"tag:root"}, + want: true, + }, + { + name: "transitive chain root owns leaf", + tag: "tag:leaf", + ownerTags: []string{"tag:root"}, + want: true, + }, + { + name: "owning one link does not grant a sibling", + tag: "tag:lone", + ownerTags: []string{"tag:root"}, + want: false, + }, + { + name: "unowned tag denied", + tag: "tag:leaf", + ownerTags: []string{"tag:unrelated"}, + want: false, + }, + { + name: "empty owners deny a delegated tag", + tag: "tag:leaf", + ownerTags: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, pm.TagOwnedByTags(tt.tag, tt.ownerTags)) + }) + } + + t.Run("nil policy manager denies", func(t *testing.T) { + var nilPM *PolicyManager + require.False(t, nilPM.TagOwnedByTags("tag:leaf", []string{"tag:root"})) + }) +} diff --git a/hscontrol/scope/scope.go b/hscontrol/scope/scope.go new file mode 100644 index 000000000..6a06917a9 --- /dev/null +++ b/hscontrol/scope/scope.go @@ -0,0 +1,119 @@ +// Package scope models the OAuth capability scopes the Headscale v2 API enforces +// and the rule for whether a granted set of scopes satisfies a required one. +// +// The vocabulary is taken from Tailscale's OpenAPI spec (the same scope names +// the Terraform provider and Kubernetes operator request), so a client written +// against Tailscale's scopes works unchanged against Headscale. The grant +// predicate is kept here, separate from the HTTP/huma layer in +// hscontrol/api/v2, so it can be tested exhaustively on its own. +package scope + +import "strings" + +// Scope is an OAuth capability an operation requires and a token grants. The names +// mirror Tailscale's API scopes; a "...:read" scope is the read-only subset of its +// write scope. +type Scope string + +const ( + // All and AllRead are Tailscale's forward-compatible super-scopes: "all" + // grants every other scope, "all:read" grants every :read subset. + All Scope = "all" + AllRead Scope = "all:read" + + AuthKeys Scope = "auth_keys" + AuthKeysRead Scope = "auth_keys:read" + + // OAuthKeys gates managing OAuth clients (keyType:"client" on the keys + // resource). + OAuthKeys Scope = "oauth_keys" + OAuthKeysRead Scope = "oauth_keys:read" + + DevicesCore Scope = "devices:core" + DevicesCoreRead Scope = "devices:core:read" + + DevicesRoutes Scope = "devices:routes" + DevicesRoutesRead Scope = "devices:routes:read" + + PolicyFile Scope = "policy_file" + PolicyFileRead Scope = "policy_file:read" + + FeatureSettings Scope = "feature_settings" + FeatureSettingsRead Scope = "feature_settings:read" +) + +const readSuffix = ":read" + +// Known returns every scope in the vocabulary, in a stable order. Useful for +// exhaustive iteration in tests and documentation. +func Known() []Scope { + return []Scope{ + All, AllRead, + AuthKeys, AuthKeysRead, + OAuthKeys, OAuthKeysRead, + DevicesCore, DevicesCoreRead, + DevicesRoutes, DevicesRoutesRead, + PolicyFile, PolicyFileRead, + FeatureSettings, FeatureSettingsRead, + } +} + +// IsRead reports whether s is a read-only scope (its name ends with ":read"). +func (s Scope) IsRead() bool { + return strings.HasSuffix(string(s), readSuffix) +} + +// IsWrite reports whether s is a non-empty write scope. +func (s Scope) IsWrite() bool { + return s != "" && !s.IsRead() +} + +// Parse converts scope strings (as stored on a token or client) into Scope values. +// Unknown strings are kept verbatim; they simply never satisfy any required scope. +func Parse(ss []string) []Scope { + out := make([]Scope, len(ss)) + for i, s := range ss { + out[i] = Scope(s) + } + + return out +} + +// Grants reports whether the granted scopes satisfy the required want scope. +func Grants(granted []Scope, want Scope) bool { + for _, g := range granted { + if satisfies(g, want) { + return true + } + } + + return false +} + +// satisfies reports whether a single held scope satisfies want: exact match; a +// write scope grants its own :read subset; "all" grants everything; "all:read" +// grants any :read scope. +func satisfies(have, want Scope) bool { + if have == want || have == All { + return true + } + + if have == AllRead { + return want.IsRead() + } + + // A write scope grants its own read subset, e.g. auth_keys ⊇ auth_keys:read. + return string(want) == string(have)+readSuffix +} + +// RequiresTags reports whether any scope obliges a credential to carry tags: +// devices:core and auth_keys mint tagged, tailnet-owned credentials. +func RequiresTags(scopes []Scope) bool { + for _, s := range scopes { + if s == DevicesCore || s == AuthKeys { + return true + } + } + + return false +} diff --git a/hscontrol/scope/scope_property_test.go b/hscontrol/scope/scope_property_test.go new file mode 100644 index 000000000..1132ba513 --- /dev/null +++ b/hscontrol/scope/scope_property_test.go @@ -0,0 +1,90 @@ +package scope + +import ( + "slices" + "testing" + + "pgregory.net/rapid" +) + +// scopeGen draws a scope: mostly from the real vocabulary, sometimes adversarial +// junk (including read-like junk such as "foo:read") so the rules are exercised +// against unknown input too. +func scopeGen() *rapid.Generator[Scope] { + known := Known() + + return rapid.Custom(func(t *rapid.T) Scope { + if rapid.Float64().Draw(t, "junkP") < 0.2 { + return Scope(rapid.StringMatching(`[a-z_]{1,12}(:read)?`).Draw(t, "junk")) + } + + return rapid.SampledFrom(known).Draw(t, "vocab") + }) +} + +// TestGrantsMatchesOracle fuzzes Grants against the independent oracle over random +// granted-sets and want-scopes (vocabulary + junk). +func TestGrantsMatchesOracle(t *testing.T) { + rapid.Check(t, func(rt *rapid.T) { + granted := rapid.SliceOfN(scopeGen(), 0, 6).Draw(rt, "granted") + want := scopeGen().Draw(rt, "want") + + if got, exp := Grants(granted, want), oracleGrants(granted, want); got != exp { + rt.Fatalf("Grants(%v, %q) = %v, oracle = %v", granted, want, got, exp) + } + }) +} + +// TestGrantsInvariants asserts the algebraic properties of the grant relation hold +// for arbitrary inputs. +func TestGrantsInvariants(t *testing.T) { + rapid.Check(t, func(rt *rapid.T) { + granted := rapid.SliceOfN(scopeGen(), 0, 6).Draw(rt, "granted") + want := scopeGen().Draw(rt, "want") + + // Reflexivity: a scope always grants itself. + if !Grants([]Scope{want}, want) { + rt.Fatalf("reflexivity: %q does not grant itself", want) + } + + // The empty set grants nothing. + if Grants(nil, want) { + rt.Fatalf("empty grant satisfied %q", want) + } + + // OR-semantics: a set grants iff some member does. + anyMember := slices.ContainsFunc(granted, func(g Scope) bool { + return Grants([]Scope{g}, want) + }) + if Grants(granted, want) != anyMember { + rt.Fatalf("OR-semantics broken for %v / %q", granted, want) + } + + // Monotonicity: adding a scope never withdraws a grant. + before := Grants(granted, want) + extra := scopeGen().Draw(rt, "extra") + after := Grants(append(slices.Clone(granted), extra), want) + + if before && !after { + rt.Fatalf("monotonicity broken: adding %q withdrew the grant of %q", extra, want) + } + }) +} + +// TestSuperScopeProperties fuzzes the super-scope rules. +func TestSuperScopeProperties(t *testing.T) { + rapid.Check(t, func(rt *rapid.T) { + want := scopeGen().Draw(rt, "want") + + // all grants everything. + if !Grants([]Scope{All}, want) { + rt.Fatalf("all did not grant %q", want) + } + + // all:read grants exactly the read scopes. + if Grants([]Scope{AllRead}, want) != want.IsRead() { + rt.Fatalf("all:read grant of %q = %v, want IsRead = %v", + want, Grants([]Scope{AllRead}, want), want.IsRead()) + } + }) +} diff --git a/hscontrol/scope/scope_test.go b/hscontrol/scope/scope_test.go new file mode 100644 index 000000000..0b59aa42a --- /dev/null +++ b/hscontrol/scope/scope_test.go @@ -0,0 +1,234 @@ +package scope + +import ( + "fmt" + "strings" + "testing" +) + +// classify decomposes a scope into (resource, super, read) WITHOUT reusing any of +// the production logic, so the oracle below is an independent second +// implementation of the grant rule: a divergence between it and Grants is a real +// bug in one of them, not a tautology. +type classified struct { + resource string // "" for super-scopes; otherwise the write-scope base (e.g. "auth_keys") + super bool // all / all:read + read bool // the :read variant +} + +func classify(s Scope) classified { + str := string(s) + read := strings.HasSuffix(str, ":read") + base := strings.TrimSuffix(str, ":read") + + if base == "all" { + return classified{super: true, read: read} + } + + return classified{resource: base, read: read} +} + +// oracle re-derives "does have satisfy want" from the classification, independent +// of satisfies/Grants. +func oracle(have, want Scope) bool { + h, w := classify(have), classify(want) + + if h.super { + // "all" grants everything; "all:read" grants only reads. + return !h.read || w.read + } + + if h.resource != w.resource { + return false + } + + // Same resource: a write scope grants both read and write; a read scope grants + // only read. + return !h.read || w.read +} + +func oracleGrants(granted []Scope, want Scope) bool { + for _, g := range granted { + if oracle(g, want) { + return true + } + } + + return false +} + +// TestGrantsHandPicked pins specific (granted, want) outcomes with literal +// expected values, independent of any oracle: the anchor for the rules. +func TestGrantsHandPicked(t *testing.T) { + tests := []struct { + granted []Scope + want Scope + ok bool + }{ + {granted: []Scope{AuthKeys}, want: AuthKeys, ok: true}, + {granted: []Scope{AuthKeys}, want: AuthKeysRead, ok: true}, + {granted: []Scope{AuthKeysRead}, want: AuthKeys, ok: false}, + {granted: []Scope{AuthKeysRead}, want: AuthKeysRead, ok: true}, + {granted: []Scope{DevicesCore}, want: AuthKeys, ok: false}, + {granted: []Scope{DevicesCoreRead}, want: AuthKeysRead, ok: false}, + {granted: []Scope{All}, want: AuthKeys, ok: true}, + {granted: []Scope{All}, want: FeatureSettingsRead, ok: true}, + {granted: []Scope{AllRead}, want: PolicyFileRead, ok: true}, + {granted: []Scope{AllRead}, want: PolicyFile, ok: false}, + {granted: []Scope{AllRead}, want: All, ok: false}, + {granted: []Scope{DevicesCore, OAuthKeys}, want: OAuthKeys, ok: true}, + {granted: nil, want: AuthKeysRead, ok: false}, + {granted: []Scope{"garbage"}, want: AuthKeys, ok: false}, + {granted: []Scope{"garbage"}, want: "garbage", ok: true}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v_%s", tt.granted, tt.want), func(t *testing.T) { + if got := Grants(tt.granted, tt.want); got != tt.ok { + t.Errorf("Grants(%v, %q) = %v, want %v", tt.granted, tt.want, got, tt.ok) + } + }) + } +} + +// TestGrantsExhaustive checks every single-grant pair in the vocabulary against +// the independent oracle, plus representative multi-grant cases. +func TestGrantsExhaustive(t *testing.T) { + known := Known() + + for _, g := range known { + for _, w := range known { + got := Grants([]Scope{g}, w) + exp := oracle(g, w) + + if got != exp { + t.Errorf("Grants([%q], %q) = %v, oracle = %v", g, w, got, exp) + } + } + } + + multi := [][]Scope{ + {All, AuthKeysRead}, + {AllRead, AuthKeys}, + {AuthKeys, OAuthKeysRead}, + {DevicesCore, DevicesRoutes, PolicyFile}, + {AuthKeys, AuthKeys}, // duplicates + } + + for _, granted := range multi { + for _, w := range known { + got := Grants(granted, w) + exp := oracleGrants(granted, w) + + if got != exp { + t.Errorf("Grants(%v, %q) = %v, oracle = %v", granted, w, got, exp) + } + } + } +} + +// TestWriteGrantsItsRead and friends assert the structural rules over the whole +// vocabulary, deterministically. +func TestWriteGrantsItsRead(t *testing.T) { + for _, s := range Known() { + if !s.IsWrite() { + continue + } + + read := Scope(string(s) + ":read") + if !Grants([]Scope{s}, read) { + t.Errorf("write scope %q does not grant its read subset %q", s, read) + } + } +} + +func TestReadNeverGrantsWrite(t *testing.T) { + for _, s := range Known() { + if !s.IsRead() { + continue + } + + write := Scope(strings.TrimSuffix(string(s), ":read")) + if Grants([]Scope{s}, write) { + t.Errorf("read scope %q must not grant write scope %q", s, write) + } + } +} + +func TestAllGrantsEverything(t *testing.T) { + for _, w := range Known() { + if !Grants([]Scope{All}, w) { + t.Errorf("all should grant %q", w) + } + } +} + +func TestAllReadGrantsReadsOnly(t *testing.T) { + for _, w := range Known() { + got := Grants([]Scope{AllRead}, w) + if got != w.IsRead() { + t.Errorf("all:read grants %q = %v, want %v (IsRead)", w, got, w.IsRead()) + } + } +} + +// TestResourceIsolation: a non-super scope never grants a scope of a different +// resource. +func TestResourceIsolation(t *testing.T) { + for _, a := range Known() { + if a == All || a == AllRead { + continue + } + + for _, b := range Known() { + if classify(a).resource == classify(b).resource { + continue + } + + if Grants([]Scope{a}, b) { + t.Errorf("scope %q (resource %q) must not grant %q (resource %q)", + a, classify(a).resource, b, classify(b).resource) + } + } + } +} + +func TestRequiresTags(t *testing.T) { + tests := []struct { + scopes []Scope + requires bool + }{ + {scopes: []Scope{DevicesCore}, requires: true}, + {scopes: []Scope{AuthKeys}, requires: true}, + {scopes: []Scope{OAuthKeys}, requires: false}, + {scopes: []Scope{PolicyFile, AuthKeys}, requires: true}, + {scopes: []Scope{DevicesCoreRead}, requires: false}, + {scopes: nil, requires: false}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v", tt.scopes), func(t *testing.T) { + if got := RequiresTags(tt.scopes); got != tt.requires { + t.Errorf("RequiresTags(%v) = %v, want %v", tt.scopes, got, tt.requires) + } + }) + } +} + +func TestKnownIsComplete(t *testing.T) { + known := Known() + + seen := make(map[Scope]bool, len(known)) + for _, s := range known { + if seen[s] { + t.Errorf("Known() contains duplicate %q", s) + } + + seen[s] = true + } + + // 7 resources × 2 (write+read) + 2 super-scopes = 16. + if len(known) != 16 { + t.Errorf("Known() has %d scopes, want 16", len(known)) + } +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index c4d70efd9..7432e2bbb 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -1511,6 +1511,63 @@ func (s *State) DeletePreAuthKey(id uint64) error { return s.db.DeletePreAuthKey(id) } +// CreateOAuthClient creates a new OAuth client-credentials client, returning the +// plaintext secret (shown once) and the stored client. +func (s *State) CreateOAuthClient(scopes, tags []string, description string, creatorUserID *uint) (string, *types.OAuthClient, error) { + return s.db.CreateOAuthClient(scopes, tags, description, creatorUserID) +} + +// AuthenticateOAuthClient validates a client secret and returns the client. +func (s *State) AuthenticateOAuthClient(secret string) (*types.OAuthClient, error) { + return s.db.AuthenticateOAuthClient(secret) +} + +// GetOAuthClientByClientID returns an OAuth client by its public client id. +func (s *State) GetOAuthClientByClientID(clientID string) (*types.OAuthClient, error) { + return s.db.GetOAuthClientByClientID(clientID) +} + +// ListOAuthClients returns every OAuth client. +func (s *State) ListOAuthClients() ([]types.OAuthClient, error) { + return s.db.ListOAuthClients() +} + +// RevokeOAuthClient deletes a client and the access tokens it issued. +func (s *State) RevokeOAuthClient(clientID string) error { + return s.db.RevokeOAuthClient(clientID) +} + +// MintAccessToken stores a new scoped access token for an OAuth client. +func (s *State) MintAccessToken(clientID string, scopes, tags []string, expiration *time.Time) (string, *types.OAuthAccessToken, error) { + return s.db.MintAccessToken(clientID, scopes, tags, expiration) +} + +// AuthenticateAccessToken validates a bearer access token and returns it with +// its granted scopes and tags. +func (s *State) AuthenticateAccessToken(token string) (*types.OAuthAccessToken, error) { + return s.db.AuthenticateAccessToken(token) +} + +// TagOwnedByTags reports whether a credential holding ownerTags may apply tag, +// per the policy's tag-to-tag ownership. Used to authorise the tags an OAuth +// access token sets on the auth keys it mints. +func (s *State) TagOwnedByTags(tag string, ownerTags []string) bool { + return s.polMan.TagOwnedByTags(tag, ownerTags) +} + +// TagExists reports whether tag is defined in the policy's tagOwners. Used to +// reject OAuth clients and auth keys carrying tags that no policy authorises, +// matching SetNodeTags. +func (s *State) TagExists(tag string) bool { + return s.polMan.TagExists(tag) +} + +// DeleteExpiredAccessTokens hard-deletes OAuth access tokens that expired before +// cutoff, returning how many were removed. +func (s *State) DeleteExpiredAccessTokens(cutoff time.Time) (int64, error) { + return s.db.DeleteExpiredAccessTokens(cutoff) +} + // GetAuthCacheEntry retrieves a pending auth request from the cache. func (s *State) GetAuthCacheEntry(id types.AuthID) (*types.AuthRequest, bool) { return s.authCache.Get(id) diff --git a/hscontrol/types/oauth.go b/hscontrol/types/oauth.go new file mode 100644 index 000000000..66c87e83b --- /dev/null +++ b/hscontrol/types/oauth.go @@ -0,0 +1,153 @@ +package types + +import ( + "time" + + "github.com/rs/zerolog" +) + +const ( + // OAuthClientPrefix prefixes an OAuth client secret: + // hskey-client--. + OAuthClientPrefix = "hskey-client-" //nolint:gosec // prefix, not a credential + + // AccessTokenPrefix prefixes an OAuth access token: + // hskey-oauthtok--. The v2 auth middleware dispatches a + // scope-limited token from an all-access admin key on this prefix alone, so + // it is one canonical constant shared by the db and api layers. + AccessTokenPrefix = "hskey-oauthtok-" //nolint:gosec // prefix, not a credential +) + +// OAuthClient is a long-lived OAuth 2.0 client-credentials principal. It mints +// short-lived [OAuthAccessToken]s limited to its Scopes and Tags. The secret is +// stored only as an Argon2id hash. ClientID is public and embedded in the secret +// string (hskey-client--) so the token endpoint can derive it +// from the secret alone, matching Tailscale, where the client id is a substring +// of the client secret. +// +// An OAuth client is always tag/tailnet-scoped, never user-owned: the access +// tokens it mints, and the auth keys those tokens create, produce tagged nodes. +// UserID only records who created the client (informational), mirroring +// [APIKey]. +type OAuthClient struct { + ID uint64 `gorm:"primary_key"` + ClientID string `gorm:"uniqueIndex"` + SecretHash []byte + + // Scopes the client may grant. Tags the client may assign to access tokens + // (and, transitively, to the auth keys and nodes those tokens create). + Scopes []string `gorm:"serializer:json"` + Tags []string `gorm:"serializer:json"` + + Description string + + // UserID records who created the client. Kept as a plain column with no + // foreign key so an upgraded database matches a freshly-migrated one. + UserID *uint + + CreatedAt *time.Time + Revoked *time.Time +} + +// TableName pins the table name. GORM's naming strategy would otherwise render +// OAuthClient as "o_auth_clients" (it breaks the OAuth initialism), diverging +// from the hand-written migration DDL and schema.sql. +func (*OAuthClient) TableName() string { return "oauth_clients" } + +// OAuthAccessToken is a short-lived bearer token minted by an [OAuthClient] via +// the client-credentials grant. It carries the scope/tag set granted at mint +// time (a subset of the issuing client's), is stored as an Argon2id hash of its +// secret, and authenticates v2 API requests as Authorization: Bearer. +type OAuthAccessToken struct { + ID uint64 `gorm:"primary_key"` + Prefix string `gorm:"uniqueIndex"` + Hash []byte + + // ClientID links back to the issuing [OAuthClient]. + ClientID string + + Scopes []string `gorm:"serializer:json"` + Tags []string `gorm:"serializer:json"` + + Expiration *time.Time + CreatedAt *time.Time +} + +// TableName pins the table name (see [OAuthClient.TableName]). +func (*OAuthAccessToken) TableName() string { return "oauth_access_tokens" } + +// maskedClientID returns the client id in masked form for safe logging. +// SECURITY: never log the secret or its hash. +func (c *OAuthClient) maskedClientID() string { + if c.ClientID != "" { + return OAuthClientPrefix + c.ClientID + "-***" + } + + return "" +} + +// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging. +// SECURITY: intentionally does NOT log the secret or hash. +func (c *OAuthClient) MarshalZerologObject(e *zerolog.Event) { + if c == nil { + return + } + + e.Uint64("oauth_client_id", c.ID) + + if masked := c.maskedClientID(); masked != "" { + e.Str("oauth_client", masked) + } + + if len(c.Scopes) > 0 { + e.Strs("oauth_client_scopes", c.Scopes) + } + + if len(c.Tags) > 0 { + e.Strs("oauth_client_tags", c.Tags) + } + + if c.Revoked != nil { + e.Time("oauth_client_revoked", *c.Revoked) + } +} + +// maskedPrefix returns the token prefix in masked form for safe logging. +// SECURITY: never log the secret or its hash. +func (t *OAuthAccessToken) maskedPrefix() string { + if t.Prefix != "" { + return AccessTokenPrefix + t.Prefix + "-***" + } + + return "" +} + +// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging. +// SECURITY: intentionally does NOT log the secret or hash. +func (t *OAuthAccessToken) MarshalZerologObject(e *zerolog.Event) { + if t == nil { + return + } + + e.Uint64("oauth_token_id", t.ID) + + if masked := t.maskedPrefix(); masked != "" { + e.Str("oauth_token", masked) + } + + if t.ClientID != "" { + e.Str("oauth_token_client", t.ClientID) + } + + if len(t.Scopes) > 0 { + e.Strs("oauth_token_scopes", t.Scopes) + } + + if len(t.Tags) > 0 { + e.Strs("oauth_token_tags", t.Tags) + } + + if t.Expiration != nil { + e.Time("oauth_token_expiration", *t.Expiration) + } +} From 84715e976cf03f8ca9f308a61d6a62998825f3ca Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sun, 21 Jun 2026 17:35:32 +0000 Subject: [PATCH 104/127] api/v2: add OAuth client-credentials auth and scope enforcement --- hscontrol/api/v2/README.md | 59 +++++- hscontrol/api/v2/acl.go | 5 +- hscontrol/api/v2/api.go | 149 +++++++++---- hscontrol/api/v2/devices.go | 32 ++- hscontrol/api/v2/errors.go | 10 +- hscontrol/api/v2/keys.go | 370 ++++++++++++++++++++++++++------- hscontrol/api/v2/oauth.go | 178 ++++++++++++++++ hscontrol/api/v2/oauth_test.go | 31 +++ hscontrol/api/v2/settings.go | 5 +- hscontrol/api/v2/users.go | 5 +- hscontrol/apiv2_keys_test.go | 14 +- hscontrol/scope/scope.go | 6 +- 12 files changed, 708 insertions(+), 156 deletions(-) create mode 100644 hscontrol/api/v2/oauth.go create mode 100644 hscontrol/api/v2/oauth_test.go diff --git a/hscontrol/api/v2/README.md b/hscontrol/api/v2/README.md index 0a6437105..90fad3638 100644 --- a/hscontrol/api/v2/README.md +++ b/hscontrol/api/v2/README.md @@ -1,7 +1,7 @@ -# API v2 — Headscale's v2 API +# API v2: Headscale's v2 API This is Headscale's v2 HTTP API, served at `/api/v2`. Some of its endpoints are -**ported from Tailscale's API** — reusing Tailscale's wire shapes — so the +**ported from Tailscale's API**, reusing Tailscale's wire shapes, so the Tailscale ecosystem that cannot talk to Headscale today works: the [Terraform/OpenTofu provider], [tscli], and the official [Go client] (`tailscale.com/client/tailscale/v2`). @@ -23,12 +23,20 @@ headscale's own conventions. The headscale-native admin API stays at `/api/v1` - Errors use **Tailscale's** body (`{"message","data","status"}`), installed as a per-API transform (`tailscaleErrorTransformer` in `errors.go`). A future headscale-native v2 operation would keep Huma's RFC 9457 problem+json. -- Auth accepts the API key as **HTTP Basic** (key as username — what the SDK - sends) or **Bearer**. See `authMiddleware`. -- Each operation declares the Tailscale scope it would require (`auth_keys`, - `devices:core`, `devices:routes`, `policy_file`, `feature_settings`, each with - a `:read` subset). Nothing is enforced yet — every key is all-access — pending - OAuth tokens; see the `TODO(scopes)` in `api.go`. +- Auth accepts a credential as **HTTP Basic** (key as username, what the SDK + sends) or **Bearer**: an admin API key (`hskey-api-…`), or an OAuth access + token (`hskey-oauthtok-…`). See `authMiddleware`. +- Each operation declares the Tailscale scope it requires (`auth_keys`, + `oauth_keys`, `devices:core`, `devices:routes`, `policy_file`, + `feature_settings`, each with a `:read` subset, plus `all`/`all:read`). + `requireScope` records it both for the middleware and in the generated + OpenAPI, as an `x-required-scope` extension and a sentence in the operation + description, so the scope shows up in the docs and spec. Enforcement: an + **admin API key is all-access** (scope checks skipped); an **OAuth access + token is scope-limited**, the middleware checks the operation's declared scope + against the token's grant (`scope.Grants`, where a write scope subsumes its + `:read` and `all`/`all:read` are super-scopes). The two are told apart by + credential prefix. - Resolve one entity by id with a typed getter (`GetNodeByID`, `GetUserByID`, `GetAPIKeyByID`, `GetPreAuthKeyByID`); add one to state/db if it is missing rather than scanning a `List`. Build responses from the view accessors @@ -38,6 +46,35 @@ headscale's own conventions. The headscale-native admin API stays at `/api/v1` `ExpirySeconds *time.Duration` marshals as nanoseconds, which the spec and every client read as seconds. +## OAuth clients & scopes + +Most of the Tailscale ecosystem (the Terraform provider, `tscli`, the Go client) +accepts **either** an API key **or OAuth 2.0 client-credentials**; the Kubernetes +operator is OAuth-only. Supporting OAuth lets all of them drive Headscale. + +- **OAuth clients** are not a separate resource; they are `keyType:"client"` on + the keys endpoint, exactly as Tailscale does it. Create + (`POST /api/v2/tailnet/-/keys` with `{"keyType":"client","scopes":[…],"tags":[…]}`) + returns a `Key` whose `id` is the client id and whose `key` is the secret, + **shown once**; get/list never re-expose it. The secret is + `hskey-client--`, embedding the client id so the token + endpoint derives it from the secret (Tailscale's `get-authkey` trick). See + `keys.go` (`createOAuthClient`) and `db/oauth.go`. +- **Token endpoint** `POST /api/v2/oauth/token` (`oauth.go`) is a plain handler, + not a Huma operation: it takes `application/x-www-form-urlencoded` and emits + RFC 6749 OAuth2 error bodies (`{"error","error_description"}`). Credentials + arrive in the body or HTTP Basic; optional space-delimited `scope`/`tags` + narrow the token to a subset of the client's grant. It returns a 1-hour + `Bearer` access token (`hskey-oauthtok-…`). +- **Scope enforcement** is the one seam in `authMiddleware`. **Tag enforcement**: + an auth key minted by a token may only carry tags the token holds, or tags + owned-by them via the policy `tagOwners` (`State.TagOwnedByTags` → + `policy/v2`), so e.g. an operator token tagged `tag:k8s-operator` may mint + `tag:k8s` keys. +- Credentials/tokens are stored like API keys: a public id/prefix plus an + **Argon2id** hash of the secret (no JWT, no signing keys). `OAuthClient` and + `OAuthAccessToken` live in `types/oauth.go` and `db/oauth.go`. + ## Adding an endpoint Worked example: the keys resource (`keys.go`) = Tailscale auth keys = Headscale @@ -55,7 +92,7 @@ pre-auth keys. 3. **Map to Headscale.** Write the field ↔ field ↔ `state` call mapping. Record gaps and the decision for each (e.g. Tailscale `preauthorized` has no - Headscale equivalent — accepted, ignored, echoed back). _Acceptance: every + Headscale equivalent: accepted, ignored, echoed back). _Acceptance: every request field is consumed or deliberately ignored; every response field has a source._ @@ -73,13 +110,13 @@ compat`; declare its `Errors`; enforce the tailnet and scope. Map state 6. **Roundtrip the real clients.** Add a `t.Run` subtest to `TestAPIv2` (`hscontrol/servertest/apiv2_test.go`) for each of the Go client, tscli, and - OpenTofu — full create→read→list→delete against one shared server on a real + OpenTofu, full create→read→list→delete against one shared server on a real loopback port (`servertest.WithRealListener`). tscli and tofu come from the nix dev shell; a missing binary fails the test. _Acceptance: `nix develop -c go test ./hscontrol/servertest/ -run TestAPIv2` is green._ 7. **Update the CLI** only if the v2 operation fully replaces a v1 one. Tailscale - has no separate key-expire verb — its `DELETE` _is_ the revoke — so v2 maps + has no separate key-expire verb (its `DELETE` _is_ the revoke), so v2 maps `DELETE` to a soft revoke: the key stays retrievable with `invalid: true` until the collector reaps it (`preauth_keys.revoked_retention`), the equivalent of v1 `preauthkeys expire`. `headscale preauthkeys` still stays on diff --git a/hscontrol/api/v2/acl.go b/hscontrol/api/v2/acl.go index 51bd25ce6..ff6917933 100644 --- a/hscontrol/api/v2/acl.go +++ b/hscontrol/api/v2/acl.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/scope" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" ) @@ -60,7 +61,7 @@ func registerACL(api huma.API, b Backend) { Tags: aclTags, Security: security, Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusInternalServerError}, - }, ScopePolicyFileRead), func(ctx context.Context, in *getACLInput) (*huma.StreamResponse, error) { + }, scope.PolicyFileRead), func(ctx context.Context, in *getACLInput) (*huma.StreamResponse, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err @@ -89,7 +90,7 @@ func registerACL(api huma.API, b Backend) { http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusPreconditionFailed, http.StatusInternalServerError, }, - }, ScopePolicyFile), func(ctx context.Context, in *setACLInput) (*huma.StreamResponse, error) { + }, scope.PolicyFile), func(ctx context.Context, in *setACLInput) (*huma.StreamResponse, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err diff --git a/hscontrol/api/v2/api.go b/hscontrol/api/v2/api.go index a2a8b887e..e42c51b83 100644 --- a/hscontrol/api/v2/api.go +++ b/hscontrol/api/v2/api.go @@ -1,8 +1,8 @@ // Package apiv2 is Headscale's v2 HTTP API, served at /api/v2. // // Where the v1 API (hscontrol/api/v1) is the headscale-native admin surface, v2 -// additionally ports selected endpoints from Tailscale's API — reusing -// Tailscale's wire shapes (paths, request/response JSON, error body) — so the +// additionally ports selected endpoints from Tailscale's API, reusing +// Tailscale's wire shapes (paths, request/response JSON, error body), so the // existing Tailscale ecosystem (the Terraform/OpenTofu provider, tscli, and // tailscale.com/client/tailscale/v2) can drive Headscale unchanged. Ported // operations carry the "Tailscale compat" tag; a headscale-native v2 operation @@ -24,6 +24,7 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humachi" "github.com/go-chi/chi/v5" + "github.com/juanfont/headscale/hscontrol/scope" "github.com/juanfont/headscale/hscontrol/state" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types/change" @@ -40,7 +41,7 @@ type Backend struct { } // security is the requirement applied to authenticated operations: an API key -// presented as HTTP Basic (the key as username — what the Tailscale SDK sends) +// presented as HTTP Basic (the key as username, what the Tailscale SDK sends) // or as a Bearer token. var security = []map[string][]string{{"basicAuth": {}}, {"bearerAuth": {}}} @@ -79,7 +80,7 @@ func Config() huma.Config { // Accept application/hujson request bodies (the Tailscale SDK sends the // policy file that way). The bytes are captured raw by the ACL handler, so // reusing the JSON format is only to satisfy huma's content-type check. - // Clone first — config.Formats aliases huma's shared DefaultFormats map. + // Clone first: config.Formats aliases huma's shared DefaultFormats map. formats := maps.Clone(config.Formats) formats["application/hujson"] = formats["application/json"] config.Formats = formats @@ -98,6 +99,10 @@ func NewAPI(router chi.Router, backend Backend) huma.API { Register(api, backend) + // The OAuth token endpoint is a plain route, not a Huma operation (see + // oauth.go); register it on the same router. + registerOAuthToken(router, backend) + return api } @@ -117,11 +122,21 @@ func Spec() ([]byte, error) { return api.OpenAPI().YAML() } +// Spec30 emits the document downgraded to OpenAPI 3.0.3, needed because +// oapi-codegen cannot yet read 3.1; the typed client is generated from this. +func Spec30() ([]byte, error) { + api := NewAPI(chi.NewMux(), Backend{}) + + return api.OpenAPI().DowngradeYAML() +} + type contextKey int const ( localTrustKey contextKey = iota ownerUserKey + principalScopesKey + principalTagsKey ) // WithLocalTrust marks a request as arriving over a locally-trusted transport @@ -136,8 +151,8 @@ func WithLocalTrust(next http.Handler) http.Handler { } // authMiddleware authenticates the API key (HTTP Basic with the key as the -// username — what the Tailscale SDK sends — or Bearer), records the key's -// owning user for handlers, and enforces the operation's required scope. +// username, what the Tailscale SDK sends, or Bearer), records the key's owning +// user for handlers, and enforces the operation's required scope. func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Context)) { return func(ctx huma.Context, next func(huma.Context)) { if ctx.Context().Value(localTrustKey) != nil { @@ -159,6 +174,35 @@ func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Contex return } + // An OAuth access token is scope-limited; an admin API key is all-access. + // They are told apart by prefix so a scoped token can never be mistaken + // for an all-access key. + if strings.HasPrefix(token, types.AccessTokenPrefix) { + at, err := b.State.AuthenticateAccessToken(token) + if err != nil { + _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized") + + return + } + + if want, ok := requiredScope(ctx.Operation()); ok && !scope.Grants(scope.Parse(at.Scopes), want) { + _ = huma.WriteErr(api, ctx, http.StatusForbidden, + "token is missing the required scope "+string(want)) + + return + } + + // The keys handler multiplexes on keyType, so its required scope and + // permitted tags depend on the body; carry the token's scopes and tags + // for it to finish the check the static middleware cannot. + ctx = huma.WithValue(ctx, principalScopesKey, at.Scopes) + ctx = huma.WithValue(ctx, principalTagsKey, at.Tags) + + next(ctx) + + return + } + key, err := b.State.AuthenticateAPIKey(token) if err != nil { _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized") @@ -166,13 +210,9 @@ func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Contex return } - // TODO(scopes): every valid key is all-access today. Operations still - // declare their required scope (requireScope) so that once OAuth tokens - // arrive, the granted set can be derived from the token and checked - // against the operation's scope here. Until then, accept everything. - - // Record the key's owning user (may be unset) so handlers can create - // user-owned keys on its behalf. + // An admin API key is all-access: its operations are not scope-checked. + // Record its owning user (may be unset) so handlers can create user-owned + // keys on its behalf. if key.UserID != nil { ctx = huma.WithValue(ctx, ownerUserKey, types.UserID(*key.UserID)) } @@ -210,6 +250,24 @@ func ownerUser(ctx context.Context) (types.UserID, bool) { return uid, ok } +// principalScopes returns the scopes granted to the request's OAuth access +// token, and whether the request authenticated with one. ok is false for an +// admin API key, which is all-access and not scope-checked. +func principalScopes(ctx context.Context) ([]string, bool) { + scopes, ok := ctx.Value(principalScopesKey).([]string) + + return scopes, ok +} + +// principalTags returns the tags granted to the request's OAuth access token, +// and whether the request authenticated with one. An admin API key is not an +// OAuth token, so ok is false and its key creation is unrestricted by tags. +func principalTags(ctx context.Context) ([]string, bool) { + tags, ok := ctx.Value(principalTagsKey).([]string) + + return tags, ok +} + // requireDefaultTailnet rejects any tailnet other than "-". Headscale is // single-tailnet; the Tailscale SDK sends "-" (its default tailnet). A non-"-" // value is "no such tailnet", a 404, which lets the SDK's IsNotFound behave. @@ -221,46 +279,47 @@ func requireDefaultTailnet(tailnet string) error { return nil } -// Scope is an OAuth capability an operation requires and a token grants. The -// names mirror Tailscale's API scopes (see the OAuth scope descriptions in the -// Tailscale OpenAPI spec); a ...Read scope is the read-only subset of its -// write scope. Nothing is enforced yet — every key is all-access — but every -// operation declares the scope it would require, so OAuth tokens can later be -// checked against it without reworking the operations. -type Scope string +// The scope vocabulary and the grant predicate live in the hscontrol/scope +// package; this file only wires a required scope onto each huma operation and +// reads it back in the middleware. -const ( - ScopeAuthKeys Scope = "auth_keys" - ScopeAuthKeysRead Scope = "auth_keys:read" - - ScopeDevicesCore Scope = "devices:core" - ScopeDevicesCoreRead Scope = "devices:core:read" - - ScopeDevicesRoutes Scope = "devices:routes" - ScopeDevicesRoutesRead Scope = "devices:routes:read" - - ScopePolicyFile Scope = "policy_file" - ScopePolicyFileRead Scope = "policy_file:read" - - ScopeFeatureSettings Scope = "feature_settings" - ScopeFeatureSettingsRead Scope = "feature_settings:read" - - ScopeUsers Scope = "users" - ScopeUsersRead Scope = "users:read" -) - -// scopeMetaKey keys the per-operation required Scope in huma.Operation.Metadata. +// scopeMetaKey keys the per-operation required scope in huma.Operation.Metadata. const scopeMetaKey = "headscale.scope" -// requireScope records op's required scope in its Metadata, where the auth -// middleware can read it back. It keeps the requirement next to the operation -// definition. -func requireScope(op huma.Operation, s Scope) huma.Operation { +// requireScope records op's required scope, both in its Metadata (where the auth +// middleware reads it back) and in the generated OpenAPI document: an +// x-required-scope extension for machine consumers and a Description line so the +// rendered docs state what each operation needs. +func requireScope(op huma.Operation, s scope.Scope) huma.Operation { if op.Metadata == nil { op.Metadata = map[string]any{} } op.Metadata[scopeMetaKey] = s + if op.Extensions == nil { + op.Extensions = map[string]any{} + } + + op.Extensions["x-required-scope"] = string(s) + + note := "Requires the `" + string(s) + "` OAuth scope (an admin API key is all-access)." + if op.Description == "" { + op.Description = note + } else { + op.Description += "\n\n" + note + } + return op } + +// requiredScope returns the scope an operation declared via requireScope, if any. +func requiredScope(op *huma.Operation) (scope.Scope, bool) { + if op == nil || op.Metadata == nil { + return "", false + } + + s, ok := op.Metadata[scopeMetaKey].(scope.Scope) + + return s, ok +} diff --git a/hscontrol/api/v2/devices.go b/hscontrol/api/v2/devices.go index 23eb7cacd..5bdb553f8 100644 --- a/hscontrol/api/v2/devices.go +++ b/hscontrol/api/v2/devices.go @@ -8,6 +8,7 @@ import ( "time" "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/scope" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" "tailscale.com/net/tsaddr" @@ -125,7 +126,7 @@ func registerDevices(api huma.API, b Backend) { Tags: deviceTags, Security: security, Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeDevicesCoreRead), func(ctx context.Context, in *deviceByIDInput) (*deviceOutput, error) { + }, scope.DevicesCoreRead), func(ctx context.Context, in *deviceByIDInput) (*deviceOutput, error) { node, err := lookupNode(b, in.DeviceID) if err != nil { return nil, err @@ -142,7 +143,7 @@ func registerDevices(api huma.API, b Backend) { Tags: deviceTags, Security: security, Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeDevicesCoreRead), func(ctx context.Context, in *listDevicesInput) (*listDevicesOutput, error) { + }, scope.DevicesCoreRead), func(ctx context.Context, in *listDevicesInput) (*listDevicesOutput, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err @@ -170,7 +171,7 @@ func registerDevices(api huma.API, b Backend) { Security: security, DefaultStatus: http.StatusOK, Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeDevicesCore), func(ctx context.Context, in *deviceByIDInput) (*emptyOutput, error) { + }, scope.DevicesCore), func(ctx context.Context, in *deviceByIDInput) (*emptyOutput, error) { node, err := lookupNode(b, in.DeviceID) if err != nil { return nil, err @@ -195,7 +196,7 @@ func registerDevices(api huma.API, b Backend) { Security: security, DefaultStatus: http.StatusOK, Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeDevicesCore), func(ctx context.Context, in *setAuthorizedInput) (*emptyOutput, error) { + }, scope.DevicesCore), func(ctx context.Context, in *setAuthorizedInput) (*emptyOutput, error) { _, err := lookupNode(b, in.DeviceID) if err != nil { return nil, err @@ -222,7 +223,7 @@ func registerDevices(api huma.API, b Backend) { Security: security, DefaultStatus: http.StatusOK, Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeDevicesCore), func(ctx context.Context, in *setNameInput) (*emptyOutput, error) { + }, scope.DevicesCore), func(ctx context.Context, in *setNameInput) (*emptyOutput, error) { node, err := lookupNode(b, in.DeviceID) if err != nil { return nil, err @@ -247,7 +248,7 @@ func registerDevices(api huma.API, b Backend) { Security: security, DefaultStatus: http.StatusOK, Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeDevicesCore), func(ctx context.Context, in *setTagsInput) (*emptyOutput, error) { + }, scope.DevicesCore), func(ctx context.Context, in *setTagsInput) (*emptyOutput, error) { node, err := lookupNode(b, in.DeviceID) if err != nil { return nil, err @@ -261,6 +262,19 @@ func registerDevices(api huma.API, b Backend) { return &emptyOutput{}, nil } + // An OAuth token may only assign tags within its grant (held directly or + // owned by a held tag per policy); an admin API key is unrestricted. The + // devices:core scope alone must not let a token stamp an arbitrary policy + // tag (e.g. tag:prod) onto any node. SetNodeTags still enforces that each + // tag exists in policy. + if tokenTags, isOAuth := principalTags(ctx); isOAuth { + for _, tag := range in.Body.Tags { + if !b.State.TagOwnedByTags(tag, tokenTags) { + return nil, huma.Error403Forbidden("token may not assign tag " + tag) + } + } + } + _, nodeChange, err := b.State.SetNodeTags(node.ID(), in.Body.Tags) if err != nil { return nil, mapError("setting device tags", err) @@ -280,7 +294,7 @@ func registerDevices(api huma.API, b Backend) { Security: security, DefaultStatus: http.StatusOK, Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeDevicesCore), func(ctx context.Context, in *setKeyInput) (*emptyOutput, error) { + }, scope.DevicesCore), func(ctx context.Context, in *setKeyInput) (*emptyOutput, error) { node, err := lookupNode(b, in.DeviceID) if err != nil { return nil, err @@ -313,7 +327,7 @@ func registerDevices(api huma.API, b Backend) { Security: security, DefaultStatus: http.StatusOK, Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeDevicesRoutes), func(ctx context.Context, in *setSubnetRoutesInput) (*deviceRoutesOutput, error) { + }, scope.DevicesRoutes), func(ctx context.Context, in *setSubnetRoutesInput) (*deviceRoutesOutput, error) { node, err := lookupNode(b, in.DeviceID) if err != nil { return nil, err @@ -342,7 +356,7 @@ func registerDevices(api huma.API, b Backend) { Tags: deviceTags, Security: security, Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeDevicesRoutesRead), func(ctx context.Context, in *deviceByIDInput) (*deviceRoutesOutput, error) { + }, scope.DevicesRoutesRead), func(ctx context.Context, in *deviceByIDInput) (*deviceRoutesOutput, error) { node, err := lookupNode(b, in.DeviceID) if err != nil { return nil, err diff --git a/hscontrol/api/v2/errors.go b/hscontrol/api/v2/errors.go index 7bd25c4f5..cf5110e2f 100644 --- a/hscontrol/api/v2/errors.go +++ b/hscontrol/api/v2/errors.go @@ -9,12 +9,12 @@ import ( "gorm.io/gorm" ) -// apiError is the Tailscale API error body. The official Tailscale Go client — -// and therefore the Terraform provider and tscli built on it — decode 4xx/5xx +// apiError is the Tailscale API error body. The official Tailscale Go client +// (and therefore the Terraform provider and tscli built on it) decodes 4xx/5xx // responses into this shape. Huma's default RFC 9457 problem+json would reach -// them with an empty message, so every v2 error is rewritten into this shape by -// tailscaleErrorTransformer. The HTTP status is read from the response code, -// not from the body's status field. +// them with an empty message, so tailscaleErrorTransformer rewrites every v2 +// error into this shape. The HTTP status is read from the response code, not +// from the body's status field. type apiError struct { Message string `json:"message"` Data []apiErrorData `json:"data,omitempty"` diff --git a/hscontrol/api/v2/keys.go b/hscontrol/api/v2/keys.go index 9ce5f26fb..552109136 100644 --- a/hscontrol/api/v2/keys.go +++ b/hscontrol/api/v2/keys.go @@ -3,10 +3,13 @@ package apiv2 import ( "context" "net/http" + "strconv" "time" "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/scope" "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" ) func init() { @@ -17,9 +20,13 @@ func init() { // 90 days, matching Tailscale and the Terraform provider default. const defaultExpiry = 90 * 24 * time.Hour -// keyTypeAuth is the only key kind Headscale issues; Tailscale's OAuth-client -// and federated-identity kinds are out of scope. -const keyTypeAuth = "auth" +const ( + // keyTypeAuth is a machine auth key (Headscale pre-auth key); the default. + keyTypeAuth = "auth" + // keyTypeClient is an OAuth client (client-credentials). Multiplexed onto the + // keys resource exactly as Tailscale does. + keyTypeClient = "client" +) // KeyCapabilities maps a resource to the actions a key permits. Headscale // populates only devices.create (auth keys); the named types (vs Tailscale's @@ -42,16 +49,27 @@ type KeyDeviceCreateCapabilities struct { Tags []string `json:"tags"` } -// CreateKeyRequest is the POST body. expirySeconds is plain seconds (unlike the -// response, see Key). +// CreateKeyRequest is the POST body. It is multiplexed by keyType: "auth" +// (default) creates a machine auth key from Capabilities; "client" creates an +// OAuth client from the top-level Scopes and Tags. expirySeconds is plain +// seconds (unlike the response, see Key). type CreateKeyRequest struct { - Capabilities KeyCapabilities `json:"capabilities"` - ExpirySeconds int64 `doc:"Lifetime in seconds; defaults to 90 days." json:"expirySeconds,omitempty"` - Description string `json:"description,omitempty" maxLength:"50"` + KeyType string `doc:"Key kind: \"auth\" (default) or \"client\" (OAuth client)." json:"keyType,omitempty"` + // Capabilities is optional: an auth key carries device-create capabilities, + // but an OAuth client (keyType:"client") has none and the Tailscale clients + // omit the field entirely, so it must not be required. + Capabilities *KeyCapabilities `json:"capabilities,omitempty"` + ExpirySeconds int64 `doc:"Lifetime in seconds; defaults to 90 days. Auth keys only." json:"expirySeconds,omitempty"` + Description string `json:"description,omitempty" maxLength:"50"` + // Scopes and Tags are top-level and apply only to keyType "client" (an OAuth + // client). Auth-key tags live under Capabilities.Devices.Create.Tags. + Scopes []string `doc:"OAuth scopes granted to the client. keyType=client only." json:"scopes,omitempty"` + Tags []string `doc:"Tags the client may assign. keyType=client only." json:"tags,omitempty"` } -// Key is the Tailscale auth-key response. expirySeconds is emitted in seconds -// to match the Tailscale spec; the secret key is present only at creation. +// Key is the Tailscale key response, shared by auth keys and OAuth clients. +// expirySeconds is emitted in seconds to match the Tailscale spec; the secret +// key is present only at creation. type Key struct { ID string `json:"id"` KeyType string `json:"keyType"` @@ -63,6 +81,7 @@ type Key struct { Revoked *time.Time `json:"revoked,omitempty"` Invalid bool `json:"invalid"` Capabilities KeyCapabilities `json:"capabilities"` + Scopes []string `json:"scopes,omitempty"` Tags []string `json:"tags,omitempty"` UserID string `json:"userId,omitempty"` } @@ -98,14 +117,19 @@ type ( } ) +// The keys resource is multiplexed by keyType (auth key vs OAuth client), so the +// scope an operation requires depends on the request rather than being fixed. +// requireScope (which the middleware enforces statically) is therefore omitted +// here; each handler authorizes via requireKeyScope once the kind is known. func registerKeys(api huma.API, b Backend) { keysTags := []string{"Keys", "Tailscale compat"} - huma.Register(api, requireScope(huma.Operation{ + huma.Register(api, huma.Operation{ OperationID: "createKey", Method: http.MethodPost, Path: "/api/v2/tailnet/{tailnet}/keys", - Summary: "Create an auth key", + Summary: "Create an auth key or OAuth client", + Description: "Requires the `auth_keys` scope for an auth key, or `oauth_keys` for an OAuth client (an admin API key is all-access).", Tags: keysTags, Security: security, Errors: []int{ @@ -114,63 +138,25 @@ func registerKeys(api huma.API, b Backend) { http.StatusForbidden, http.StatusNotFound, }, - }, ScopeAuthKeys), func(ctx context.Context, in *createKeyInput) (*keyOutput, error) { + }, func(ctx context.Context, in *createKeyInput) (*keyOutput, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err } - create := in.Body.Capabilities.Devices.Create - - // Ownership: tags -> tagged key; no tags -> owned by the API key's user; - // no tags and an ownerless (legacy/admin) key -> 400. - var userID *types.UserID - - if len(create.Tags) == 0 { - uid, ok := ownerUser(ctx) - if !ok { - return nil, huma.Error400BadRequest( - "an auth key without tags must be created with a user-owned API key", - ) - } - - userID = &uid + if in.Body.KeyType == keyTypeClient { + return createOAuthClient(ctx, b, in.Body) } - expiration := time.Now().Add(expiryDuration(in.Body.ExpirySeconds)) - - pak, err := b.State.CreatePreAuthKey( - userID, - create.Reusable, - create.Ephemeral, - &expiration, - create.Tags, - ) - if err != nil { - return nil, mapError("creating auth key", err) - } - - if in.Body.Description != "" { - err := b.State.SetPreAuthKeyDescription( - pak.ID, - in.Body.Description, - ) - if err != nil { - return nil, huma.Error500InternalServerError( - "setting auth key description", - err, - ) - } - } - - return &keyOutput{Body: keyFromNew(pak, in.Body)}, nil + return createAuthKey(ctx, b, in.Body) }) - huma.Register(api, requireScope(huma.Operation{ + huma.Register(api, huma.Operation{ OperationID: "listKeys", Method: http.MethodGet, Path: "/api/v2/tailnet/{tailnet}/keys", - Summary: "List auth keys", + Summary: "List auth keys and OAuth clients", + Description: "A token sees the kinds it can read: `auth_keys:read` for auth keys, `oauth_keys:read` for OAuth clients (an admin API key sees all).", Tags: keysTags, Security: security, Errors: []int{ @@ -178,32 +164,49 @@ func registerKeys(api huma.API, b Backend) { http.StatusForbidden, http.StatusNotFound, }, - }, ScopeAuthKeysRead), func(ctx context.Context, in *listKeysInput) (*listKeysOutput, error) { + }, func(ctx context.Context, in *listKeysInput) (*listKeysOutput, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err } - keys, err := b.State.ListPreAuthKeys() - if err != nil { - return nil, huma.Error500InternalServerError("listing auth keys", err) - } + scopes, isOAuth := principalScopes(ctx) out := &listKeysOutput{} - out.Body.Keys = make([]Key, 0, len(keys)) + out.Body.Keys = []Key{} - for i := range keys { - out.Body.Keys = append(out.Body.Keys, keyFromStored(&keys[i])) + // A token sees the key kinds it has read scope for; an admin key sees all. + if !isOAuth || scope.Grants(scope.Parse(scopes), scope.AuthKeysRead) { + keys, err := b.State.ListPreAuthKeys() + if err != nil { + return nil, huma.Error500InternalServerError("listing auth keys", err) + } + + for i := range keys { + out.Body.Keys = append(out.Body.Keys, keyFromStored(&keys[i])) + } + } + + if !isOAuth || scope.Grants(scope.Parse(scopes), scope.OAuthKeysRead) { + clients, err := b.State.ListOAuthClients() + if err != nil { + return nil, huma.Error500InternalServerError("listing oauth clients", err) + } + + for i := range clients { + out.Body.Keys = append(out.Body.Keys, oauthClientToKey(&clients[i], "")) + } } return out, nil }) - huma.Register(api, requireScope(huma.Operation{ + huma.Register(api, huma.Operation{ OperationID: "getKey", Method: http.MethodGet, Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}", - Summary: "Get an auth key", + Summary: "Get an auth key or OAuth client", + Description: "Requires `auth_keys:read` for an auth key, or `oauth_keys:read` for an OAuth client (an admin API key is all-access).", Tags: keysTags, Security: security, Errors: []int{ @@ -211,12 +214,29 @@ func registerKeys(api huma.API, b Backend) { http.StatusForbidden, http.StatusNotFound, }, - }, ScopeAuthKeysRead), func(ctx context.Context, in *keyByIDInput) (*keyOutput, error) { + }, func(ctx context.Context, in *keyByIDInput) (*keyOutput, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err } + // An OAuth client id is a hex string distinct from a numeric auth-key id, + // so a client lookup that hits is authoritative; otherwise fall through to + // the auth-key path. The lookup is gated on the caller actually holding + // oauth_keys:read so a token without it cannot tell a real client id (403) + // from an unknown key (404) — i.e. no client-existence oracle. + if requireKeyScope(ctx, scope.OAuthKeysRead) == nil { + client, err := b.State.GetOAuthClientByClientID(in.KeyID) + if err == nil { + return &keyOutput{Body: oauthClientToKey(client, "")}, nil + } + } + + err = requireKeyScope(ctx, scope.AuthKeysRead) + if err != nil { + return nil, err + } + key, err := findKeyByID(b, in.KeyID) if err != nil { return nil, err @@ -225,11 +245,12 @@ func registerKeys(api huma.API, b Backend) { return &keyOutput{Body: keyFromStored(key)}, nil }) - huma.Register(api, requireScope(huma.Operation{ + huma.Register(api, huma.Operation{ OperationID: "deleteKey", Method: http.MethodDelete, Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}", - Summary: "Delete an auth key", + Summary: "Delete an auth key or OAuth client", + Description: "Requires the `auth_keys` scope for an auth key, or `oauth_keys` for an OAuth client (an admin API key is all-access).", Tags: keysTags, Security: security, DefaultStatus: http.StatusOK, @@ -238,12 +259,31 @@ func registerKeys(api huma.API, b Backend) { http.StatusForbidden, http.StatusNotFound, }, - }, ScopeAuthKeys), func(ctx context.Context, in *keyByIDInput) (*deleteKeyOutput, error) { + }, func(ctx context.Context, in *keyByIDInput) (*deleteKeyOutput, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err } + // Gated on oauth_keys (write) for the same no-existence-oracle reason as + // getKey: a token without it must not learn that an id is an OAuth client. + if requireKeyScope(ctx, scope.OAuthKeys) == nil { + _, err = b.State.GetOAuthClientByClientID(in.KeyID) + if err == nil { + err = b.State.RevokeOAuthClient(in.KeyID) + if err != nil { + return nil, mapError("deleting oauth client", err) + } + + return &deleteKeyOutput{}, nil + } + } + + err = requireKeyScope(ctx, scope.AuthKeys) + if err != nil { + return nil, err + } + id, err := parseID(in.KeyID, "auth key") if err != nil { return nil, err @@ -261,6 +301,163 @@ func registerKeys(api huma.API, b Backend) { }) } +// requireKeyScope authorizes a keys operation for an OAuth access token. An admin +// API key carries no OAuth scopes and is all-access, so it always passes. +func requireKeyScope(ctx context.Context, need scope.Scope) error { + scopes, isOAuth := principalScopes(ctx) + if !isOAuth { + return nil + } + + if !scope.Grants(scope.Parse(scopes), need) { + return huma.Error403Forbidden("token is missing the required scope " + string(need)) + } + + return nil +} + +// createAuthKey creates a machine auth key (pre-auth key). Ownership: tags -> a +// tagged key; no tags -> owned by the API key's user. An OAuth access token must +// mint tagged keys, and each tag must be within the token's grant. +func createAuthKey(ctx context.Context, b Backend, body CreateKeyRequest) (*keyOutput, error) { + err := requireKeyScope(ctx, scope.AuthKeys) + if err != nil { + return nil, err + } + + var create KeyDeviceCreateCapabilities + if body.Capabilities != nil { + create = body.Capabilities.Devices.Create + } + + tokenTags, isOAuth := principalTags(ctx) + + var userID *types.UserID + + switch { + case len(create.Tags) > 0: + // A key minted by an OAuth token may only carry tags within the token's + // grant (held directly, or owned by a held tag) and defined in policy, + // matching SetNodeTags. An admin key keeps the historical behaviour of + // validating only tag syntax (db.validateACLTags). + if isOAuth { + for _, tag := range create.Tags { + if !b.State.TagExists(tag) { + return nil, huma.Error400BadRequest("tag " + tag + " is not defined in policy") + } + + if !b.State.TagOwnedByTags(tag, tokenTags) { + return nil, huma.Error403Forbidden( + "token may not assign tag " + tag, + ) + } + } + } + + case isOAuth: + // OAuth-minted keys are tailnet/tag-owned; an untagged (user-owned) key + // cannot be created from a token. + return nil, huma.Error403Forbidden("an OAuth client must create tagged auth keys") + + default: + uid, ok := ownerUser(ctx) + if !ok { + return nil, huma.Error400BadRequest( + "an auth key without tags must be created with a user-owned API key", + ) + } + + userID = &uid + } + + expiration := time.Now().Add(expiryDuration(body.ExpirySeconds)) + + pak, err := b.State.CreatePreAuthKey( + userID, + create.Reusable, + create.Ephemeral, + &expiration, + create.Tags, + ) + if err != nil { + return nil, mapError("creating auth key", err) + } + + if body.Description != "" { + err := b.State.SetPreAuthKeyDescription(pak.ID, body.Description) + if err != nil { + return nil, huma.Error500InternalServerError("setting auth key description", err) + } + } + + return &keyOutput{Body: keyFromNew(pak, body)}, nil +} + +// createOAuthClient creates an OAuth client (keyType:"client"). The client secret +// is returned once, here. +func createOAuthClient(ctx context.Context, b Backend, body CreateKeyRequest) (*keyOutput, error) { + err := requireKeyScope(ctx, scope.OAuthKeys) + if err != nil { + return nil, err + } + + if len(body.Scopes) == 0 { + return nil, huma.Error400BadRequest("an OAuth client must declare at least one scope") + } + + // Tailscale: tags are mandatory when the scopes include devices:core or + // auth_keys, because such a client mints tagged, tailnet-owned credentials. + if scope.RequiresTags(scope.Parse(body.Scopes)) && len(body.Tags) == 0 { + return nil, huma.Error400BadRequest( + "tags are required when scopes include devices:core or auth_keys", + ) + } + + // A client created by an OAuth token may not be granted authority the token + // lacks: its scopes must each be within the token's grant, and its tags within + // the token's tags and defined in policy (matching SetNodeTags). Otherwise an + // oauth_keys token could mint an all-access client and escalate. An admin API + // key (not an OAuth token) is unrestricted and keeps the historical tag + // behaviour (syntax-only validation). + if tokenScopes, isOAuth := principalScopes(ctx); isOAuth { + for _, s := range body.Scopes { + if !scope.Grants(scope.Parse(tokenScopes), scope.Scope(s)) { + return nil, huma.Error403Forbidden( + "client may not be granted scope " + s + " beyond the creating token", + ) + } + } + + tokenTags, _ := principalTags(ctx) + + for _, tag := range body.Tags { + if !b.State.TagExists(tag) { + return nil, huma.Error400BadRequest("tag " + tag + " is not defined in policy") + } + + if !b.State.TagOwnedByTags(tag, tokenTags) { + return nil, huma.Error403Forbidden( + "client may not be granted tag " + tag + " beyond the creating token", + ) + } + } + } + + var creator *uint + + if uid, ok := ownerUser(ctx); ok { + u := uint(uid) + creator = &u + } + + secret, client, err := b.State.CreateOAuthClient(body.Scopes, body.Tags, body.Description, creator) + if err != nil { + return nil, mapError("creating oauth client", err) + } + + return &keyOutput{Body: oauthClientToKey(client, secret)}, nil +} + // findKeyByID looks up a stored pre-auth key by its (stringified) id with a // direct by-id query; an unknown id surfaces as gorm.ErrRecordNotFound, which // mapError turns into a 404. @@ -283,7 +480,10 @@ func findKeyByID(b Backend, rawID string) (*types.PreAuthKey, error) { // match keyFromStored: Headscale always authorizes pre-auth-key nodes, so the // create and read paths must agree or the Terraform provider sees a diff. func keyFromNew(pak *types.PreAuthKeyNew, req CreateKeyRequest) Key { - create := req.Capabilities.Devices.Create + var create KeyDeviceCreateCapabilities + if req.Capabilities != nil { + create = req.Capabilities.Devices.Create + } key := Key{ ID: pak.StringID(), @@ -344,6 +544,32 @@ func keyFromStored(pak *types.PreAuthKey) Key { return key } +// oauthClientToKey builds the keys response for an OAuth client. secret is the +// plaintext client secret, set only on the create response and empty on get/list +// (the secret is never re-exposed). +func oauthClientToKey(client *types.OAuthClient, secret string) Key { + key := Key{ + ID: client.ClientID, + KeyType: keyTypeClient, + Key: secret, + Description: client.Description, + Created: timeOrZero(client.CreatedAt), + Scopes: emptyIfNil(client.Scopes), + Tags: emptyIfNil(client.Tags), + } + + if client.Revoked != nil { + key.Revoked = client.Revoked + key.Invalid = true + } + + if client.UserID != nil { + key.UserID = strconv.FormatUint(uint64(*client.UserID), util.Base10) + } + + return key +} + func capabilities( reusable, ephemeral, preauthorized bool, tags []string, diff --git a/hscontrol/api/v2/oauth.go b/hscontrol/api/v2/oauth.go new file mode 100644 index 000000000..330c94f96 --- /dev/null +++ b/hscontrol/api/v2/oauth.go @@ -0,0 +1,178 @@ +package apiv2 + +import ( + "encoding/json" + "net/http" + "slices" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/juanfont/headscale/hscontrol/scope" + "github.com/juanfont/headscale/hscontrol/types" +) + +// accessTokenTTL is the fixed lifetime of a minted access token, matching +// Tailscale's non-configurable one hour. +const accessTokenTTL = time.Hour + +// registerOAuthToken mounts POST /api/v2/oauth/token on the router. It is a plain +// handler, not a Huma operation: it consumes application/x-www-form-urlencoded +// and emits RFC 6749 OAuth2 error bodies ({"error","error_description"}), neither +// of which fits Huma's JSON-in / Tailscale-error-out machinery. +// ponytail: one bespoke OAuth endpoint isn't worth bending Huma around. +func registerOAuthToken(router chi.Router, b Backend) { + router.Post("/api/v2/oauth/token", oauthTokenHandler(b)) +} + +// tokenResponse is the OAuth 2.0 client-credentials success body. +type tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope,omitempty"` +} + +// oauthTokenHandler implements the client-credentials grant (RFC 6749 §4.4): +// authenticate the client, optionally narrow the granted scopes/tags, and mint a +// short-lived bearer token. +func oauthTokenHandler(b Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "could not parse request body") + + return + } + + // grant_type defaults to client_credentials: Tailscale's documented curl + // omits it, and the x/oauth2 client always sends it. + if gt := r.PostForm.Get("grant_type"); gt != "" && gt != "client_credentials" { + writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type", + "only the client_credentials grant is supported") + + return + } + + // Credentials may arrive in the body or as HTTP Basic (the x/oauth2 + // auto-detect probes Basic first). The secret embeds the client id, so a + // separate client_id is not required. + secret := r.PostForm.Get("client_secret") + if secret == "" { + if _, pass, ok := r.BasicAuth(); ok { + secret = pass + } + } + + if secret == "" { + writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "missing client credentials") + + return + } + + client, err := b.State.AuthenticateOAuthClient(secret) + if err != nil { + writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "invalid client credentials") + + return + } + + // Optional space-delimited scope/tags narrow the token to a subset of the + // client's grant. + scopes, badScope, ok := narrowScopes(client.Scopes, strings.Fields(r.PostForm.Get("scope"))) + if !ok { + writeOAuthError(w, http.StatusBadRequest, "invalid_scope", + "scope "+badScope+" is not granted to this client") + + return + } + + tags, badTag, ok := narrowTags(client, strings.Fields(r.PostForm.Get("tags"))) + if !ok { + writeOAuthError(w, http.StatusBadRequest, "invalid_target", + "tag "+badTag+" is not granted to this client") + + return + } + + expiry := time.Now().Add(accessTokenTTL) + + tokenStr, _, err := b.State.MintAccessToken(client.ClientID, scopes, tags, &expiry) + if err != nil { + writeOAuthError(w, http.StatusInternalServerError, "server_error", "could not mint access token") + + return + } + + writeJSON(w, http.StatusOK, tokenResponse{ + AccessToken: tokenStr, + TokenType: "Bearer", + ExpiresIn: int(accessTokenTTL.Seconds()), + Scope: strings.Join(scopes, " "), + }) + } +} + +// narrowScopes returns the requested scopes if each is granted by the client (an +// empty request means "the client's full grant"), otherwise the offending scope +// and false. A client holding a broad scope (e.g. "all") may mint a token limited +// to a narrower one. +func narrowScopes(granted, requested []string) ([]string, string, bool) { + if len(requested) == 0 { + return granted, "", true + } + + for _, req := range requested { + if !scope.Grants(scope.Parse(granted), scope.Scope(req)) { + return nil, req, false + } + } + + return requested, "", true +} + +// narrowTags returns the requested tags if each is within the client's grant (an +// empty request means "the client's full tag set"), otherwise the offending tag +// and false. A client with the "all" scope may assign any tag, matching Tailscale. +func narrowTags(client *types.OAuthClient, requested []string) ([]string, string, bool) { + if len(requested) == 0 { + return client.Tags, "", true + } + + allScope := slices.Contains(client.Scopes, string(scope.All)) + + for _, req := range requested { + // Reject malformed tags at the trust boundary. A client's own tags are + // validated at creation, so this only matters for an "all"-scope client, + // whose tags would otherwise skip the membership check below and flow + // unvalidated into auth-key creation. + if !strings.HasPrefix(req, "tag:") { + return nil, req, false + } + + if !allScope && !slices.Contains(client.Tags, req) { + return nil, req, false + } + } + + return requested, "", true +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + // Token responses carry bearer credentials; RFC 6749 §5.1 forbids caching + // them. writeJSON serves only the token endpoint, so set it unconditionally. + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) //nolint:errchkjson // best-effort response write of a known-safe value +} + +// writeOAuthError emits an RFC 6749 §5.2 error body, which the x/oauth2 client +// parses into its RetrieveError. +func writeOAuthError(w http.ResponseWriter, status int, code, desc string) { + writeJSON(w, status, map[string]string{ + "error": code, + "error_description": desc, + }) +} diff --git a/hscontrol/api/v2/oauth_test.go b/hscontrol/api/v2/oauth_test.go new file mode 100644 index 000000000..1abbacf87 --- /dev/null +++ b/hscontrol/api/v2/oauth_test.go @@ -0,0 +1,31 @@ +package apiv2 + +import ( + "testing" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" +) + +// TestNarrowTagsRejectsMalformed asserts the token endpoint validates tag +// format at the trust boundary. An "all"-scope client may assign any tag, but a +// malformed tag (missing the "tag:" prefix) must still be rejected rather than +// flowing unvalidated into auth-key creation. +func TestNarrowTagsRejectsMalformed(t *testing.T) { + all := &types.OAuthClient{Scopes: []string{"all"}} + + _, bad, ok := narrowTags(all, []string{"not-a-tag"}) + assert.False(t, ok, "malformed tag must be rejected even with all scope") + assert.Equal(t, "not-a-tag", bad) + + got, _, ok := narrowTags(all, []string{"tag:anything"}) + assert.True(t, ok, "all scope may assign any well-formed tag") + assert.Equal(t, []string{"tag:anything"}, got) + + // A scoped client may only assign its own well-formed tags. + scoped := &types.OAuthClient{Scopes: []string{"auth_keys"}, Tags: []string{"tag:ci"}} + + _, bad, ok = narrowTags(scoped, []string{"tag:other"}) + assert.False(t, ok) + assert.Equal(t, "tag:other", bad) +} diff --git a/hscontrol/api/v2/settings.go b/hscontrol/api/v2/settings.go index 58628370a..262ec554c 100644 --- a/hscontrol/api/v2/settings.go +++ b/hscontrol/api/v2/settings.go @@ -7,6 +7,7 @@ import ( "time" "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/scope" "github.com/juanfont/headscale/hscontrol/types" ) @@ -59,7 +60,7 @@ func registerSettings(api huma.API, b Backend) { Tags: settingsTags, Security: security, Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeFeatureSettingsRead), func(ctx context.Context, in *getSettingsInput) (*settingsOutput, error) { + }, scope.FeatureSettingsRead), func(ctx context.Context, in *getSettingsInput) (*settingsOutput, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err @@ -86,7 +87,7 @@ func registerSettings(api huma.API, b Backend) { // The body is accepted but ignored; skip validation. SkipValidateBody: true, Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented}, - }, ScopeFeatureSettings), func(ctx context.Context, in *patchSettingsInput) (*settingsOutput, error) { + }, scope.FeatureSettings), func(ctx context.Context, in *patchSettingsInput) (*settingsOutput, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err diff --git a/hscontrol/api/v2/users.go b/hscontrol/api/v2/users.go index c3d94526d..2890f432e 100644 --- a/hscontrol/api/v2/users.go +++ b/hscontrol/api/v2/users.go @@ -7,6 +7,7 @@ import ( "time" "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/scope" "github.com/juanfont/headscale/hscontrol/types" ) @@ -73,7 +74,7 @@ func registerUsers(api huma.API, b Backend) { Tags: usersTags, Security: security, Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeUsersRead), func(ctx context.Context, in *userByIDInput) (*userOutput, error) { + }, scope.UsersRead), func(ctx context.Context, in *userByIDInput) (*userOutput, error) { view, err := lookupUser(b, in.UserID) if err != nil { return nil, err @@ -90,7 +91,7 @@ func registerUsers(api huma.API, b Backend) { Tags: usersTags, Security: security, Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeUsersRead), func(ctx context.Context, in *listUsersInput) (*listUsersOutput, error) { + }, scope.UsersRead), func(ctx context.Context, in *listUsersInput) (*listUsersOutput, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err diff --git a/hscontrol/apiv2_keys_test.go b/hscontrol/apiv2_keys_test.go index c71b18cfe..5a296b2df 100644 --- a/hscontrol/apiv2_keys_test.go +++ b/hscontrol/apiv2_keys_test.go @@ -17,8 +17,8 @@ import ( ) // taggedCaps builds capabilities for a tagged auth key. -func taggedCaps(tags ...string) apiv2.KeyCapabilities { - return apiv2.KeyCapabilities{ +func taggedCaps(tags ...string) *apiv2.KeyCapabilities { + return &apiv2.KeyCapabilities{ Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{ Reusable: true, Preauthorized: true, @@ -138,7 +138,7 @@ func TestAPIv2Key_Create_Tagged(t *testing.T) { KeyType: "auth", Description: "dev access", ExpirySeconds: 86400, - Capabilities: taggedCaps("tag:test"), + Capabilities: *taggedCaps("tag:test"), Tags: []string{"tag:test"}, } if diff := cmp.Diff(want, created, cmpopts.IgnoreFields(apiv2.Key{}, "ID", "Key", "Created", "Expires")); diff != "" { @@ -172,7 +172,7 @@ func TestAPIv2Key_Create_Permutations(t *testing.T) { }{ { name: "single-use", - req: apiv2.CreateKeyRequest{Capabilities: apiv2.KeyCapabilities{ + req: apiv2.CreateKeyRequest{Capabilities: &apiv2.KeyCapabilities{ Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{Tags: []string{"tag:test"}}}, }}, wantSeconds: 7776000, @@ -185,7 +185,7 @@ func TestAPIv2Key_Create_Permutations(t *testing.T) { }, { name: "ephemeral", - req: apiv2.CreateKeyRequest{Capabilities: apiv2.KeyCapabilities{ + req: apiv2.CreateKeyRequest{Capabilities: &apiv2.KeyCapabilities{ Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{ Ephemeral: true, Tags: []string{"tag:test"}, @@ -246,7 +246,7 @@ func TestAPIv2Key_Get(t *testing.T) { KeyType: "auth", Description: "dev access", ExpirySeconds: 86400, - Capabilities: taggedCaps("tag:test"), + Capabilities: *taggedCaps("tag:test"), Tags: []string{"tag:test"}, } if diff := cmp.Diff(want, got, cmpopts.IgnoreFields(apiv2.Key{}, "Created", "Expires")); diff != "" { @@ -313,7 +313,7 @@ func TestAPIv2Key_Create_NoTags_NoOwner_400(t *testing.T) { before := keyCount(t, app) - resp := api.Post("/api/v2/tailnet/-/keys", apiv2.CreateKeyRequest{Capabilities: apiv2.KeyCapabilities{}}) + resp := api.Post("/api/v2/tailnet/-/keys", apiv2.CreateKeyRequest{Capabilities: &apiv2.KeyCapabilities{}}) assert.Equal(t, http.StatusBadRequest, resp.Code) assert.Contains(t, resp.Body.String(), `"message"`) diff --git a/hscontrol/scope/scope.go b/hscontrol/scope/scope.go index 6a06917a9..1a1a0a6d0 100644 --- a/hscontrol/scope/scope.go +++ b/hscontrol/scope/scope.go @@ -40,6 +40,9 @@ const ( FeatureSettings Scope = "feature_settings" FeatureSettingsRead Scope = "feature_settings:read" + + Users Scope = "users" + UsersRead Scope = "users:read" ) const readSuffix = ":read" @@ -55,6 +58,7 @@ func Known() []Scope { DevicesRoutes, DevicesRoutesRead, PolicyFile, PolicyFileRead, FeatureSettings, FeatureSettingsRead, + Users, UsersRead, } } @@ -69,7 +73,7 @@ func (s Scope) IsWrite() bool { } // Parse converts scope strings (as stored on a token or client) into Scope values. -// Unknown strings are kept verbatim; they simply never satisfy any required scope. +// Unknown strings are kept as-is; they simply never satisfy any required scope. func Parse(ss []string) []Scope { out := make([]Scope, len(ss)) for i, s := range ss { From b05f8875c342dff36f9b2e10d8245696ce4f2a72 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sun, 21 Jun 2026 17:35:36 +0000 Subject: [PATCH 105/127] hscontrol, cli: serve the v2 API on the local socket and add the oauth-clients command Serve the v2 API over the local unix socket for the CLI and add the oauth-clients command that drives it. --- Makefile | 15 +- cmd/gen-openapi/main.go | 47 +- cmd/headscale/cli/oauth_client.go | 271 ++ gen/client/v2/client.gen.go | 4013 +++++++++++++++++++++++++++++ hscontrol/app.go | 33 +- 5 files changed, 4355 insertions(+), 24 deletions(-) create mode 100644 cmd/headscale/cli/oauth_client.go create mode 100644 gen/client/v2/client.gen.go diff --git a/Makefile b/Makefile index 8bfd2037b..da2726714 100644 --- a/Makefile +++ b/Makefile @@ -91,17 +91,20 @@ openapi: @echo "Emitting OpenAPI spec from code..." go run ./cmd/gen-openapi -# Generate the strongly-typed Go HTTP client. The served spec is OpenAPI 3.1, -# but oapi-codegen v2 does not yet read 3.1, so the client is generated from a -# transient 3.0.3 downgrade of the same document. Pinned so the committed client -# is reproducible. +# Generate the strongly-typed Go HTTP clients (v1 and v2). The served specs are +# OpenAPI 3.1, but oapi-codegen v2 does not yet read 3.1, so each client is +# generated from a transient 3.0.3 downgrade of its document. Pinned so the +# committed clients are reproducible. .PHONY: client client: - @echo "Generating API client..." + @echo "Generating API clients..." @tmp=$$(mktemp -t headscale-openapi-3.0.XXXXXX.yaml); \ go run ./cmd/gen-openapi -downgrade "$$tmp" && \ go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.7.1 \ - -generate types,client -package clientv1 -o gen/client/v1/client.gen.go "$$tmp"; \ + -generate types,client -package clientv1 -o gen/client/v1/client.gen.go "$$tmp" && \ + go run ./cmd/gen-openapi -api v2 -downgrade "$$tmp" && \ + go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.7.1 \ + -generate types,client -package clientv2 -o gen/client/v2/client.gen.go "$$tmp"; \ status=$$?; rm -f "$$tmp"; exit $$status # Clean targets diff --git a/cmd/gen-openapi/main.go b/cmd/gen-openapi/main.go index 3e77a2f10..32568f14f 100644 --- a/cmd/gen-openapi/main.go +++ b/cmd/gen-openapi/main.go @@ -1,33 +1,58 @@ -// Command gen-openapi emits the Headscale v1 OpenAPI document from the -// authoritative Huma definitions in hscontrol/api/v1. The server also serves the -// spec live at /openapi.yaml; this tool emits it on demand, and with -downgrade -// the 3.0.3 form used to generate the client. The output is not committed. +// Command gen-openapi emits a Headscale OpenAPI document from the authoritative +// Huma definitions in hscontrol/api/v1 and hscontrol/api/v2. The server also +// serves each spec live (at /openapi.yaml and /api/v2/openapi); this tool emits +// them on demand, and with -downgrade the 3.0.3 form used to generate the typed +// client. The output is not committed. // // Usage: // -// go run ./cmd/gen-openapi # write the 3.1 spec to openapi/v1/headscale.yaml -// go run ./cmd/gen-openapi -downgrade # write the 3.0.3 downgrade (for client gen) +// go run ./cmd/gen-openapi # write the v1 3.1 spec to its default path +// go run ./cmd/gen-openapi -api v2 # write the v2 3.1 spec to its default path +// go run ./cmd/gen-openapi -downgrade # write the v1 3.0.3 downgrade (for client gen) +// go run ./cmd/gen-openapi -api v2 -downgrade # the v2 3.0.3 downgrade package main import ( + "flag" "log" "os" "path/filepath" apiv1 "github.com/juanfont/headscale/hscontrol/api/v1" + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" ) -// outPath is relative to the repository root. -const outPath = "openapi/v1/headscale.yaml" +// spec bundles a version's full (3.1) and downgraded (3.0.3) generators with the +// committed output path. outPath is relative to the repository root. +type spec struct { + full func() ([]byte, error) + down func() ([]byte, error) + outPath string +} + +// specs maps the -api value to its generators. +var specs = map[string]spec{ + "v1": {apiv1.Spec, apiv1.Spec30, "openapi/v1/headscale.yaml"}, + "v2": {apiv2.Spec, apiv2.Spec30, "openapi/v2/headscale.yaml"}, +} func main() { - if len(os.Args) == 3 && os.Args[1] == "-downgrade" { - writeSpec(os.Args[2], apiv1.Spec30) + api := flag.String("api", "v1", "which API spec to emit: v1 or v2") + downgrade := flag.String("downgrade", "", "write the OpenAPI 3.0.3 downgrade to this path instead of the committed 3.1 spec") + flag.Parse() + + s, ok := specs[*api] + if !ok { + log.Fatalf("unknown -api %q (want v1 or v2)", *api) + } + + if *downgrade != "" { + writeSpec(*downgrade, s.down) return } - writeSpec(outPath, apiv1.Spec) + writeSpec(s.outPath, s.full) } func writeSpec(path string, gen func() ([]byte, error)) { diff --git a/cmd/headscale/cli/oauth_client.go b/cmd/headscale/cli/oauth_client.go new file mode 100644 index 000000000..aeb566469 --- /dev/null +++ b/cmd/headscale/cli/oauth_client.go @@ -0,0 +1,271 @@ +package cli + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net" + "net/http" + "strings" + + clientv2 "github.com/juanfont/headscale/gen/client/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/spf13/cobra" +) + +// oauthTailnet is the single Headscale tailnet the v2 API addresses as "-". +const oauthTailnet = "-" + +func init() { + rootCmd.AddCommand(oauthClientsCmd) + + oauthClientsCmd.AddCommand(listOAuthClientsCmd) + + createOAuthClientCmd.Flags(). + StringArrayP("scope", "s", nil, "Scope the client's tokens are granted (repeatable): auth_keys, oauth_keys, devices:core, devices:routes, policy_file, feature_settings (each with a :read variant), or all/all:read") + createOAuthClientCmd.Flags(). + StringArrayP("tag", "t", nil, "Tag the client's tokens may assign to devices (repeatable), e.g. tag:k8s-operator") + createOAuthClientCmd.Flags().StringP("description", "d", "", "Human-readable description") + oauthClientsCmd.AddCommand(createOAuthClientCmd) + + deleteOAuthClientCmd.Flags().StringP("id", "i", "", "OAuth client id") + oauthClientsCmd.AddCommand(deleteOAuthClientCmd) +} + +var oauthClientsCmd = &cobra.Command{ + Use: "oauth-clients", + Short: "Manage OAuth clients", + Aliases: []string{"oauth-client", "oauthclients", "oauthclient", "oauth"}, +} + +var createOAuthClientCmd = &cobra.Command{ + Use: "create", + Short: "Create an OAuth client", + Long: `Create a general-purpose OAuth client. It authenticates with the OAuth 2.0 +client-credentials grant and mints short-lived, scope-limited access tokens. +The wire format is compatible with Tailscale tooling (the Terraform provider, +the Kubernetes operator, tscli, ...), so those can drive Headscale unchanged. + +The client secret is shown ONCE on creation and cannot be retrieved again; if +you lose it, delete the client and create a new one. + +Scopes gate what the client's tokens may do; tags are the device tags those +tokens may assign and are required when the scopes include devices:core or +auth_keys.`, + Aliases: []string{"c", cmdNew}, + RunE: func(cmd *cobra.Command, _ []string) error { + scopes, _ := cmd.Flags().GetStringArray("scope") + tags, _ := cmd.Flags().GetStringArray("tag") + description, _ := cmd.Flags().GetString("description") + + if len(scopes) == 0 { + return fmt.Errorf("at least one --scope is required: %w", errMissingParameter) + } + + ctx, client, cancel, err := newV2Client() + if err != nil { + return err + } + defer cancel() + + keyType := "client" + + resp, err := client.CreateKeyWithResponse(ctx, oauthTailnet, clientv2.CreateKeyRequest{ + KeyType: &keyType, + Scopes: &scopes, + Tags: &tags, + Description: &description, + }) + if err != nil { + return fmt.Errorf("creating oauth client: %w", err) + } + + err = v2Error(resp.HTTPResponse.StatusCode, resp.Body) + if err != nil { + return err + } + + key := resp.JSON200 + + return printOutput(cmd, key, + fmt.Sprintf("OAuth client %s created.\nSecret (shown once, store it now): %s", key.Id, ptrStr(key.Key))) + }, +} + +var listOAuthClientsCmd = &cobra.Command{ + Use: cmdList, + Short: "List OAuth clients", + Aliases: []string{"ls", cmdShow}, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx, client, cancel, err := newV2Client() + if err != nil { + return err + } + defer cancel() + + resp, err := client.ListKeysWithResponse(ctx, oauthTailnet, nil) + if err != nil { + return fmt.Errorf("listing oauth clients: %w", err) + } + + err = v2Error(resp.HTTPResponse.StatusCode, resp.Body) + if err != nil { + return err + } + + // The keys endpoint is multiplexed; keep only OAuth clients. + clients := make([]clientv2.Key, 0, len(resp.JSON200.Keys)) + + for _, k := range resp.JSON200.Keys { + if k.KeyType == "client" { + clients = append(clients, k) + } + } + + return printListOutput(cmd, clients, func() error { + rows := make([][]string, 0, len(clients)) + for _, c := range clients { + rows = append(rows, []string{ + c.Id, + strings.Join(ptrStrs(c.Scopes), ","), + strings.Join(ptrStrs(c.Tags), ","), + ptrStr(c.Description), + c.Created.Format(HeadscaleDateTimeFormat), + }) + } + + return renderTable([]string{"ID", "Scopes", "Tags", "Description", colCreated}, rows) + }) + }, +} + +var deleteOAuthClientCmd = &cobra.Command{ + Use: cmdDelete, + Short: "Delete an OAuth client", + Aliases: []string{"remove", aliasDel}, + RunE: func(cmd *cobra.Command, _ []string) error { + id, _ := cmd.Flags().GetString("id") + if id == "" { + return fmt.Errorf("--id is required: %w", errMissingParameter) + } + + ctx, client, cancel, err := newV2Client() + if err != nil { + return err + } + defer cancel() + + resp, err := client.DeleteKeyWithResponse(ctx, oauthTailnet, id) + if err != nil { + return fmt.Errorf("deleting oauth client: %w", err) + } + + err = v2Error(resp.HTTPResponse.StatusCode, resp.Body) + if err != nil { + return err + } + + return printOutput(cmd, map[string]string{"id": id}, "OAuth client "+id+" deleted") + }, +} + +// newV2Client builds a generated v2 API client, selecting the transport the same +// way the v1 client does: over the local unix socket it is unauthenticated +// (local trust); a remote address injects the configured API key as a bearer +// token. +func newV2Client() (context.Context, *clientv2.ClientWithResponses, context.CancelFunc, error) { + cfg, err := types.LoadCLIConfig() + if err != nil { + return nil, nil, nil, fmt.Errorf("loading configuration: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), cfg.CLI.Timeout) + + if cfg.CLI.Address == "" { + socketPath := cfg.UnixSocket + + httpClient := &http.Client{Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return dialHeadscaleSocket(ctx, socketPath) + }, + }} + + client, err := clientv2.NewClientWithResponses("http://local", clientv2.WithHTTPClient(httpClient)) + if err != nil { + cancel() + + return nil, nil, nil, err + } + + return ctx, client, cancel, nil + } + + if cfg.CLI.APIKey == "" { + cancel() + + return nil, nil, nil, errAPIKeyNotSet + } + + transport := &http.Transport{} + if cfg.CLI.Insecure { + //nolint:gosec // intentionally honouring the insecure flag + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + + apiKey := cfg.CLI.APIKey + + client, err := clientv2.NewClientWithResponses( + clientBaseURL(cfg.CLI.Address), + clientv2.WithHTTPClient(&http.Client{Transport: transport}), + clientv2.WithRequestEditorFn(func(_ context.Context, req *http.Request) error { + req.Header.Set("Authorization", "Bearer "+apiKey) + + return nil + }), + ) + if err != nil { + cancel() + + return nil, nil, nil, err + } + + return ctx, client, cancel, nil +} + +// v2Error turns a non-2xx v2 response into an error. The v2 API emits the +// Tailscale error body ({"message":...}) rather than RFC 7807, so it reads the +// "message" field instead of the generated problem+json types. +func v2Error(status int, body []byte) error { + if status >= http.StatusOK && status < http.StatusMultipleChoices { + return nil + } + + var e struct { + Message string `json:"message"` + } + + if json.Unmarshal(body, &e) == nil && e.Message != "" { + //nolint:err113 // surfacing the server's message + return fmt.Errorf("api error (%d): %s", status, e.Message) + } + + //nolint:err113 // surfacing the server's body + return fmt.Errorf("api error (%d): %s", status, strings.TrimSpace(string(body))) +} + +func ptrStr(s *string) string { + if s == nil { + return "" + } + + return *s +} + +func ptrStrs(s *[]string) []string { + if s == nil { + return nil + } + + return *s +} diff --git a/gen/client/v2/client.gen.go b/gen/client/v2/client.gen.go new file mode 100644 index 000000000..c31b47ab1 --- /dev/null +++ b/gen/client/v2/client.gen.go @@ -0,0 +1,4013 @@ +// Package clientv2 provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.1 DO NOT EDIT. +package clientv2 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/oapi-codegen/runtime" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +const ( + BasicAuthScopes basicAuthContextKey = "basicAuth.Scopes" + BearerAuthScopes bearerAuthContextKey = "bearerAuth.Scopes" +) + +// CreateKeyRequest defines model for CreateKeyRequest. +type CreateKeyRequest struct { + Capabilities *KeyCapabilities `json:"capabilities,omitempty"` + Description *string `json:"description,omitempty"` + + // ExpirySeconds Lifetime in seconds; defaults to 90 days. Auth keys only. + ExpirySeconds *int64 `json:"expirySeconds,omitempty"` + + // KeyType Key kind: "auth" (default) or "client" (OAuth client). + KeyType *string `json:"keyType,omitempty"` + + // Scopes OAuth scopes granted to the client. keyType=client only. + Scopes *[]string `json:"scopes,omitempty"` + + // Tags Tags the client may assign. keyType=client only. + Tags *[]string `json:"tags,omitempty"` +} + +// DeleteKeyOutputBody defines model for DeleteKeyOutputBody. +type DeleteKeyOutputBody = map[string]interface{} + +// Device defines model for Device. +type Device struct { + Addresses []string `json:"addresses"` + AdvertisedRoutes *[]string `json:"advertisedRoutes,omitempty"` + Authorized bool `json:"authorized"` + ClientVersion string `json:"clientVersion"` + Created time.Time `json:"created"` + EnabledRoutes *[]string `json:"enabledRoutes,omitempty"` + Expires *time.Time `json:"expires,omitempty"` + Hostname string `json:"hostname"` + Id string `json:"id"` + IsEphemeral bool `json:"isEphemeral"` + KeyExpiryDisabled bool `json:"keyExpiryDisabled"` + LastSeen *time.Time `json:"lastSeen,omitempty"` + MachineKey string `json:"machineKey"` + Name string `json:"name"` + NodeId string `json:"nodeId"` + NodeKey string `json:"nodeKey"` + Os string `json:"os"` + Tags []string `json:"tags"` + UpdateAvailable bool `json:"updateAvailable"` + User string `json:"user"` +} + +// DeviceRoutes defines model for DeviceRoutes. +type DeviceRoutes struct { + AdvertisedRoutes []string `json:"advertisedRoutes"` + EnabledRoutes []string `json:"enabledRoutes"` +} + +// EmptyOutputBody defines model for EmptyOutputBody. +type EmptyOutputBody = map[string]interface{} + +// ErrorDetail defines model for ErrorDetail. +type ErrorDetail struct { + // Location Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id' + Location *string `json:"location,omitempty"` + + // Message Error message text + Message *string `json:"message,omitempty"` + + // Value The value at the given location + Value interface{} `json:"value,omitempty"` +} + +// ErrorModel defines model for ErrorModel. +type ErrorModel struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail *string `json:"detail,omitempty"` + + // Errors Optional list of individual error details + Errors *[]ErrorDetail `json:"errors,omitempty"` + + // Instance A URI reference that identifies the specific occurrence of the problem. + Instance *string `json:"instance,omitempty"` + + // Status HTTP status code + Status *int64 `json:"status,omitempty"` + + // Title A short, human-readable summary of the problem type. This value should not change between occurrences of the error. + Title *string `json:"title,omitempty"` + + // Type A URI reference to human-readable documentation for the error. + Type *string `json:"type,omitempty"` +} + +// Key defines model for Key. +type Key struct { + Capabilities KeyCapabilities `json:"capabilities"` + Created time.Time `json:"created"` + Description *string `json:"description,omitempty"` + Expires *time.Time `json:"expires,omitempty"` + ExpirySeconds *int64 `json:"expirySeconds,omitempty"` + Id string `json:"id"` + Invalid bool `json:"invalid"` + Key *string `json:"key,omitempty"` + KeyType string `json:"keyType"` + Revoked *time.Time `json:"revoked,omitempty"` + Scopes *[]string `json:"scopes,omitempty"` + Tags *[]string `json:"tags,omitempty"` + UserId *string `json:"userId,omitempty"` +} + +// KeyCapabilities defines model for KeyCapabilities. +type KeyCapabilities struct { + Devices KeyDeviceCapabilities `json:"devices"` +} + +// KeyDeviceCapabilities defines model for KeyDeviceCapabilities. +type KeyDeviceCapabilities struct { + Create KeyDeviceCreateCapabilities `json:"create"` +} + +// KeyDeviceCreateCapabilities defines model for KeyDeviceCreateCapabilities. +type KeyDeviceCreateCapabilities struct { + Ephemeral bool `json:"ephemeral"` + Preauthorized bool `json:"preauthorized"` + Reusable bool `json:"reusable"` + Tags *[]string `json:"tags"` +} + +// ListDevicesOutputBody defines model for ListDevicesOutputBody. +type ListDevicesOutputBody struct { + Devices []Device `json:"devices"` +} + +// ListKeysOutputBody defines model for ListKeysOutputBody. +type ListKeysOutputBody struct { + Keys []Key `json:"keys"` +} + +// ListUsersOutputBody defines model for ListUsersOutputBody. +type ListUsersOutputBody struct { + Users []User `json:"users"` +} + +// SetAuthorizedRequest defines model for SetAuthorizedRequest. +type SetAuthorizedRequest struct { + Authorized bool `json:"authorized"` +} + +// SetKeyRequest defines model for SetKeyRequest. +type SetKeyRequest struct { + KeyExpiryDisabled bool `json:"keyExpiryDisabled"` +} + +// SetNameRequest defines model for SetNameRequest. +type SetNameRequest struct { + Name string `json:"name"` +} + +// SetSubnetRoutesRequest defines model for SetSubnetRoutesRequest. +type SetSubnetRoutesRequest struct { + Routes []string `json:"routes"` +} + +// SetTagsRequest defines model for SetTagsRequest. +type SetTagsRequest struct { + Tags *[]string `json:"tags"` +} + +// TailnetSettings defines model for TailnetSettings. +type TailnetSettings struct { + AclsExternalLink string `json:"aclsExternalLink"` + AclsExternallyManagedOn bool `json:"aclsExternallyManagedOn"` + DevicesApprovalOn bool `json:"devicesApprovalOn"` + DevicesAutoUpdatesOn bool `json:"devicesAutoUpdatesOn"` + DevicesKeyDurationDays int64 `json:"devicesKeyDurationDays"` + HttpsEnabled bool `json:"httpsEnabled"` + NetworkFlowLoggingOn bool `json:"networkFlowLoggingOn"` + PostureIdentityCollectionOn bool `json:"postureIdentityCollectionOn"` + RegionalRoutingOn bool `json:"regionalRoutingOn"` + UsersApprovalOn bool `json:"usersApprovalOn"` + UsersRoleAllowedToJoinExternalTailnets string `json:"usersRoleAllowedToJoinExternalTailnets"` +} + +// User defines model for User. +type User struct { + Created time.Time `json:"created"` + CurrentlyConnected bool `json:"currentlyConnected"` + DeviceCount int64 `json:"deviceCount"` + DisplayName string `json:"displayName"` + Id string `json:"id"` + LastSeen time.Time `json:"lastSeen"` + LoginName string `json:"loginName"` + ProfilePicUrl string `json:"profilePicUrl"` + Role string `json:"role"` + Status string `json:"status"` + TailnetId string `json:"tailnetId"` + Type string `json:"type"` +} + +// basicAuthContextKey is the context key for basicAuth security scheme +type basicAuthContextKey string + +// bearerAuthContextKey is the context key for bearerAuth security scheme +type bearerAuthContextKey string + +// DeleteDeviceParams defines parameters for DeleteDevice. +type DeleteDeviceParams struct { + // Fields Set to "all" for route fields. + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetDeviceParams defines parameters for GetDevice. +type GetDeviceParams struct { + // Fields Set to "all" for route fields. + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetDeviceRoutesParams defines parameters for GetDeviceRoutes. +type GetDeviceRoutesParams struct { + // Fields Set to "all" for route fields. + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// GetACLParams defines parameters for GetACL. +type GetACLParams struct { + // Details Accepted for compatibility; ignored. + Details *bool `form:"details,omitempty" json:"details,omitempty"` + Accept *string `json:"Accept,omitempty"` +} + +// SetACLJSONBody defines parameters for SetACL. +type SetACLJSONBody = openapi_types.File + +// SetACLParams defines parameters for SetACL. +type SetACLParams struct { + IfMatch *string `json:"If-Match,omitempty"` + Accept *string `json:"Accept,omitempty"` +} + +// ListDevicesParams defines parameters for ListDevices. +type ListDevicesParams struct { + Fields *string `form:"fields,omitempty" json:"fields,omitempty"` +} + +// ListKeysParams defines parameters for ListKeys. +type ListKeysParams struct { + // All Accepted for compatibility; Headscale returns all keys. + All *bool `form:"all,omitempty" json:"all,omitempty"` +} + +// UpdateTailnetSettingsJSONBody defines parameters for UpdateTailnetSettings. +type UpdateTailnetSettingsJSONBody = interface{} + +// ListUsersParams defines parameters for ListUsers. +type ListUsersParams struct { + // Type Filter by user type; Headscale users are all "member". + Type *string `form:"type,omitempty" json:"type,omitempty"` + + // Role Filter by user role; Headscale users are all "member". + Role *string `form:"role,omitempty" json:"role,omitempty"` +} + +// AuthorizeDeviceJSONRequestBody defines body for AuthorizeDevice for application/json ContentType. +type AuthorizeDeviceJSONRequestBody = SetAuthorizedRequest + +// SetDeviceKeyJSONRequestBody defines body for SetDeviceKey for application/json ContentType. +type SetDeviceKeyJSONRequestBody = SetKeyRequest + +// SetDeviceNameJSONRequestBody defines body for SetDeviceName for application/json ContentType. +type SetDeviceNameJSONRequestBody = SetNameRequest + +// SetDeviceRoutesJSONRequestBody defines body for SetDeviceRoutes for application/json ContentType. +type SetDeviceRoutesJSONRequestBody = SetSubnetRoutesRequest + +// SetDeviceTagsJSONRequestBody defines body for SetDeviceTags for application/json ContentType. +type SetDeviceTagsJSONRequestBody = SetTagsRequest + +// SetACLJSONRequestBody defines body for SetACL for application/json ContentType. +type SetACLJSONRequestBody = SetACLJSONBody + +// CreateKeyJSONRequestBody defines body for CreateKey for application/json ContentType. +type CreateKeyJSONRequestBody = CreateKeyRequest + +// UpdateTailnetSettingsJSONRequestBody defines body for UpdateTailnetSettings for application/json ContentType. +type UpdateTailnetSettingsJSONRequestBody = UpdateTailnetSettingsJSONBody + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // DeleteDevice request + DeleteDevice(ctx context.Context, id string, params *DeleteDeviceParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetDevice request + GetDevice(ctx context.Context, id string, params *GetDeviceParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // AuthorizeDeviceWithBody request with any body + AuthorizeDeviceWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + AuthorizeDevice(ctx context.Context, id string, body AuthorizeDeviceJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // SetDeviceKeyWithBody request with any body + SetDeviceKeyWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetDeviceKey(ctx context.Context, id string, body SetDeviceKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // SetDeviceNameWithBody request with any body + SetDeviceNameWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetDeviceName(ctx context.Context, id string, body SetDeviceNameJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetDeviceRoutes request + GetDeviceRoutes(ctx context.Context, id string, params *GetDeviceRoutesParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // SetDeviceRoutesWithBody request with any body + SetDeviceRoutesWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetDeviceRoutes(ctx context.Context, id string, body SetDeviceRoutesJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // SetDeviceTagsWithBody request with any body + SetDeviceTagsWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetDeviceTags(ctx context.Context, id string, body SetDeviceTagsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetACL request + GetACL(ctx context.Context, tailnet string, params *GetACLParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // SetACLWithBody request with any body + SetACLWithBody(ctx context.Context, tailnet string, params *SetACLParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + SetACL(ctx context.Context, tailnet string, params *SetACLParams, body SetACLJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListDevices request + ListDevices(ctx context.Context, tailnet string, params *ListDevicesParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListKeys request + ListKeys(ctx context.Context, tailnet string, params *ListKeysParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // CreateKeyWithBody request with any body + CreateKeyWithBody(ctx context.Context, tailnet string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CreateKey(ctx context.Context, tailnet string, body CreateKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeleteKey request + DeleteKey(ctx context.Context, tailnet string, keyId string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetKey request + GetKey(ctx context.Context, tailnet string, keyId string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetTailnetSettings request + GetTailnetSettings(ctx context.Context, tailnet string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // UpdateTailnetSettingsWithBody request with any body + UpdateTailnetSettingsWithBody(ctx context.Context, tailnet string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + UpdateTailnetSettings(ctx context.Context, tailnet string, body UpdateTailnetSettingsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ListUsers request + ListUsers(ctx context.Context, tailnet string, params *ListUsersParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetUser request + GetUser(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) DeleteDevice(ctx context.Context, id string, params *DeleteDeviceParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteDeviceRequest(c.Server, id, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetDevice(ctx context.Context, id string, params *GetDeviceParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetDeviceRequest(c.Server, id, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) AuthorizeDeviceWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAuthorizeDeviceRequestWithBody(c.Server, id, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) AuthorizeDevice(ctx context.Context, id string, body AuthorizeDeviceJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewAuthorizeDeviceRequest(c.Server, id, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetDeviceKeyWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetDeviceKeyRequestWithBody(c.Server, id, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetDeviceKey(ctx context.Context, id string, body SetDeviceKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetDeviceKeyRequest(c.Server, id, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetDeviceNameWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetDeviceNameRequestWithBody(c.Server, id, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetDeviceName(ctx context.Context, id string, body SetDeviceNameJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetDeviceNameRequest(c.Server, id, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetDeviceRoutes(ctx context.Context, id string, params *GetDeviceRoutesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetDeviceRoutesRequest(c.Server, id, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetDeviceRoutesWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetDeviceRoutesRequestWithBody(c.Server, id, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetDeviceRoutes(ctx context.Context, id string, body SetDeviceRoutesJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetDeviceRoutesRequest(c.Server, id, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetDeviceTagsWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetDeviceTagsRequestWithBody(c.Server, id, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetDeviceTags(ctx context.Context, id string, body SetDeviceTagsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetDeviceTagsRequest(c.Server, id, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetACL(ctx context.Context, tailnet string, params *GetACLParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetACLRequest(c.Server, tailnet, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetACLWithBody(ctx context.Context, tailnet string, params *SetACLParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetACLRequestWithBody(c.Server, tailnet, params, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) SetACL(ctx context.Context, tailnet string, params *SetACLParams, body SetACLJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewSetACLRequest(c.Server, tailnet, params, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ListDevices(ctx context.Context, tailnet string, params *ListDevicesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListDevicesRequest(c.Server, tailnet, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ListKeys(ctx context.Context, tailnet string, params *ListKeysParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListKeysRequest(c.Server, tailnet, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateKeyWithBody(ctx context.Context, tailnet string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateKeyRequestWithBody(c.Server, tailnet, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateKey(ctx context.Context, tailnet string, body CreateKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateKeyRequest(c.Server, tailnet, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeleteKey(ctx context.Context, tailnet string, keyId string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteKeyRequest(c.Server, tailnet, keyId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetKey(ctx context.Context, tailnet string, keyId string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetKeyRequest(c.Server, tailnet, keyId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetTailnetSettings(ctx context.Context, tailnet string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetTailnetSettingsRequest(c.Server, tailnet) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) UpdateTailnetSettingsWithBody(ctx context.Context, tailnet string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewUpdateTailnetSettingsRequestWithBody(c.Server, tailnet, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) UpdateTailnetSettings(ctx context.Context, tailnet string, body UpdateTailnetSettingsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewUpdateTailnetSettingsRequest(c.Server, tailnet, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ListUsers(ctx context.Context, tailnet string, params *ListUsersParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListUsersRequest(c.Server, tailnet, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetUser(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetUserRequest(c.Server, id) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewDeleteDeviceRequest generates requests for DeleteDevice +func NewDeleteDeviceRequest(server string, id string, params *DeleteDeviceParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/device/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Fields != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "fields", *params.Fields, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetDeviceRequest generates requests for GetDevice +func NewGetDeviceRequest(server string, id string, params *GetDeviceParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/device/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Fields != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "fields", *params.Fields, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewAuthorizeDeviceRequest calls the generic AuthorizeDevice builder with application/json body +func NewAuthorizeDeviceRequest(server string, id string, body AuthorizeDeviceJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewAuthorizeDeviceRequestWithBody(server, id, "application/json", bodyReader) +} + +// NewAuthorizeDeviceRequestWithBody generates requests for AuthorizeDevice with any type of body +func NewAuthorizeDeviceRequestWithBody(server string, id string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/device/%s/authorized", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewSetDeviceKeyRequest calls the generic SetDeviceKey builder with application/json body +func NewSetDeviceKeyRequest(server string, id string, body SetDeviceKeyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetDeviceKeyRequestWithBody(server, id, "application/json", bodyReader) +} + +// NewSetDeviceKeyRequestWithBody generates requests for SetDeviceKey with any type of body +func NewSetDeviceKeyRequestWithBody(server string, id string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/device/%s/key", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewSetDeviceNameRequest calls the generic SetDeviceName builder with application/json body +func NewSetDeviceNameRequest(server string, id string, body SetDeviceNameJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetDeviceNameRequestWithBody(server, id, "application/json", bodyReader) +} + +// NewSetDeviceNameRequestWithBody generates requests for SetDeviceName with any type of body +func NewSetDeviceNameRequestWithBody(server string, id string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/device/%s/name", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewGetDeviceRoutesRequest generates requests for GetDeviceRoutes +func NewGetDeviceRoutesRequest(server string, id string, params *GetDeviceRoutesParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/device/%s/routes", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Fields != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "fields", *params.Fields, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewSetDeviceRoutesRequest calls the generic SetDeviceRoutes builder with application/json body +func NewSetDeviceRoutesRequest(server string, id string, body SetDeviceRoutesJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetDeviceRoutesRequestWithBody(server, id, "application/json", bodyReader) +} + +// NewSetDeviceRoutesRequestWithBody generates requests for SetDeviceRoutes with any type of body +func NewSetDeviceRoutesRequestWithBody(server string, id string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/device/%s/routes", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewSetDeviceTagsRequest calls the generic SetDeviceTags builder with application/json body +func NewSetDeviceTagsRequest(server string, id string, body SetDeviceTagsJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetDeviceTagsRequestWithBody(server, id, "application/json", bodyReader) +} + +// NewSetDeviceTagsRequestWithBody generates requests for SetDeviceTags with any type of body +func NewSetDeviceTagsRequestWithBody(server string, id string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/device/%s/tags", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewGetACLRequest generates requests for GetACL +func NewGetACLRequest(server string, tailnet string, params *GetACLParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/acl", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Details != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "details", *params.Details, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + if params != nil { + + if params.Accept != nil { + var headerParam0 string + + headerParam0, err = runtime.StyleParamWithOptions("simple", false, "Accept", *params.Accept, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", headerParam0) + } + + } + + return req, nil +} + +// NewSetACLRequest calls the generic SetACL builder with application/json body +func NewSetACLRequest(server string, tailnet string, params *SetACLParams, body SetACLJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewSetACLRequestWithBody(server, tailnet, params, "application/json", bodyReader) +} + +// NewSetACLRequestWithBody generates requests for SetACL with any type of body +func NewSetACLRequestWithBody(server string, tailnet string, params *SetACLParams, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/acl", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + if params != nil { + + if params.IfMatch != nil { + var headerParam0 string + + headerParam0, err = runtime.StyleParamWithOptions("simple", false, "If-Match", *params.IfMatch, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + req.Header.Set("If-Match", headerParam0) + } + + if params.Accept != nil { + var headerParam1 string + + headerParam1, err = runtime.StyleParamWithOptions("simple", false, "Accept", *params.Accept, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", headerParam1) + } + + } + + return req, nil +} + +// NewListDevicesRequest generates requests for ListDevices +func NewListDevicesRequest(server string, tailnet string, params *ListDevicesParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/devices", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Fields != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "fields", *params.Fields, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewListKeysRequest generates requests for ListKeys +func NewListKeysRequest(server string, tailnet string, params *ListKeysParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/keys", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.All != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "all", *params.All, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "boolean", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewCreateKeyRequest calls the generic CreateKey builder with application/json body +func NewCreateKeyRequest(server string, tailnet string, body CreateKeyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCreateKeyRequestWithBody(server, tailnet, "application/json", bodyReader) +} + +// NewCreateKeyRequestWithBody generates requests for CreateKey with any type of body +func NewCreateKeyRequestWithBody(server string, tailnet string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/keys", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDeleteKeyRequest generates requests for DeleteKey +func NewDeleteKeyRequest(server string, tailnet string, keyId string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "keyId", keyId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/keys/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetKeyRequest generates requests for GetKey +func NewGetKeyRequest(server string, tailnet string, keyId string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "keyId", keyId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/keys/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetTailnetSettingsRequest generates requests for GetTailnetSettings +func NewGetTailnetSettingsRequest(server string, tailnet string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/settings", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewUpdateTailnetSettingsRequest calls the generic UpdateTailnetSettings builder with application/json body +func NewUpdateTailnetSettingsRequest(server string, tailnet string, body UpdateTailnetSettingsJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewUpdateTailnetSettingsRequestWithBody(server, tailnet, "application/json", bodyReader) +} + +// NewUpdateTailnetSettingsRequestWithBody generates requests for UpdateTailnetSettings with any type of body +func NewUpdateTailnetSettingsRequestWithBody(server string, tailnet string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/settings", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewListUsersRequest generates requests for ListUsers +func NewListUsersRequest(server string, tailnet string, params *ListUsersParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "tailnet", tailnet, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/tailnet/%s/users", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Type != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "type", *params.Type, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Role != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", false, "role", *params.Role, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetUserRequest generates requests for GetUser +func NewGetUserRequest(server string, id string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v2/users/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // DeleteDeviceWithResponse request + DeleteDeviceWithResponse(ctx context.Context, id string, params *DeleteDeviceParams, reqEditors ...RequestEditorFn) (*DeleteDeviceResponse, error) + + // GetDeviceWithResponse request + GetDeviceWithResponse(ctx context.Context, id string, params *GetDeviceParams, reqEditors ...RequestEditorFn) (*GetDeviceResponse, error) + + // AuthorizeDeviceWithBodyWithResponse request with any body + AuthorizeDeviceWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AuthorizeDeviceResponse, error) + + AuthorizeDeviceWithResponse(ctx context.Context, id string, body AuthorizeDeviceJSONRequestBody, reqEditors ...RequestEditorFn) (*AuthorizeDeviceResponse, error) + + // SetDeviceKeyWithBodyWithResponse request with any body + SetDeviceKeyWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetDeviceKeyResponse, error) + + SetDeviceKeyWithResponse(ctx context.Context, id string, body SetDeviceKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*SetDeviceKeyResponse, error) + + // SetDeviceNameWithBodyWithResponse request with any body + SetDeviceNameWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetDeviceNameResponse, error) + + SetDeviceNameWithResponse(ctx context.Context, id string, body SetDeviceNameJSONRequestBody, reqEditors ...RequestEditorFn) (*SetDeviceNameResponse, error) + + // GetDeviceRoutesWithResponse request + GetDeviceRoutesWithResponse(ctx context.Context, id string, params *GetDeviceRoutesParams, reqEditors ...RequestEditorFn) (*GetDeviceRoutesResponse, error) + + // SetDeviceRoutesWithBodyWithResponse request with any body + SetDeviceRoutesWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetDeviceRoutesResponse, error) + + SetDeviceRoutesWithResponse(ctx context.Context, id string, body SetDeviceRoutesJSONRequestBody, reqEditors ...RequestEditorFn) (*SetDeviceRoutesResponse, error) + + // SetDeviceTagsWithBodyWithResponse request with any body + SetDeviceTagsWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetDeviceTagsResponse, error) + + SetDeviceTagsWithResponse(ctx context.Context, id string, body SetDeviceTagsJSONRequestBody, reqEditors ...RequestEditorFn) (*SetDeviceTagsResponse, error) + + // GetACLWithResponse request + GetACLWithResponse(ctx context.Context, tailnet string, params *GetACLParams, reqEditors ...RequestEditorFn) (*GetACLResponse, error) + + // SetACLWithBodyWithResponse request with any body + SetACLWithBodyWithResponse(ctx context.Context, tailnet string, params *SetACLParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetACLResponse, error) + + SetACLWithResponse(ctx context.Context, tailnet string, params *SetACLParams, body SetACLJSONRequestBody, reqEditors ...RequestEditorFn) (*SetACLResponse, error) + + // ListDevicesWithResponse request + ListDevicesWithResponse(ctx context.Context, tailnet string, params *ListDevicesParams, reqEditors ...RequestEditorFn) (*ListDevicesResponse, error) + + // ListKeysWithResponse request + ListKeysWithResponse(ctx context.Context, tailnet string, params *ListKeysParams, reqEditors ...RequestEditorFn) (*ListKeysResponse, error) + + // CreateKeyWithBodyWithResponse request with any body + CreateKeyWithBodyWithResponse(ctx context.Context, tailnet string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateKeyResponse, error) + + CreateKeyWithResponse(ctx context.Context, tailnet string, body CreateKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateKeyResponse, error) + + // DeleteKeyWithResponse request + DeleteKeyWithResponse(ctx context.Context, tailnet string, keyId string, reqEditors ...RequestEditorFn) (*DeleteKeyResponse, error) + + // GetKeyWithResponse request + GetKeyWithResponse(ctx context.Context, tailnet string, keyId string, reqEditors ...RequestEditorFn) (*GetKeyResponse, error) + + // GetTailnetSettingsWithResponse request + GetTailnetSettingsWithResponse(ctx context.Context, tailnet string, reqEditors ...RequestEditorFn) (*GetTailnetSettingsResponse, error) + + // UpdateTailnetSettingsWithBodyWithResponse request with any body + UpdateTailnetSettingsWithBodyWithResponse(ctx context.Context, tailnet string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateTailnetSettingsResponse, error) + + UpdateTailnetSettingsWithResponse(ctx context.Context, tailnet string, body UpdateTailnetSettingsJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateTailnetSettingsResponse, error) + + // ListUsersWithResponse request + ListUsersWithResponse(ctx context.Context, tailnet string, params *ListUsersParams, reqEditors ...RequestEditorFn) (*ListUsersResponse, error) + + // GetUserWithResponse request + GetUserWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*GetUserResponse, error) +} + +type DeleteDeviceResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *EmptyOutputBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r DeleteDeviceResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteDeviceResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DeleteDeviceResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetDeviceResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *Device + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r GetDeviceResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetDeviceResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetDeviceResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type AuthorizeDeviceResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *EmptyOutputBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r AuthorizeDeviceResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r AuthorizeDeviceResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r AuthorizeDeviceResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type SetDeviceKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *EmptyOutputBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r SetDeviceKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetDeviceKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SetDeviceKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type SetDeviceNameResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *EmptyOutputBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r SetDeviceNameResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetDeviceNameResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SetDeviceNameResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetDeviceRoutesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DeviceRoutes + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r GetDeviceRoutesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetDeviceRoutesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetDeviceRoutesResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type SetDeviceRoutesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DeviceRoutes + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r SetDeviceRoutesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetDeviceRoutesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SetDeviceRoutesResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type SetDeviceTagsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *EmptyOutputBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r SetDeviceTagsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetDeviceTagsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SetDeviceTagsResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetACLResponse struct { + Body []byte + HTTPResponse *http.Response + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r GetACLResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetACLResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetACLResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type SetACLResponse struct { + Body []byte + HTTPResponse *http.Response + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON412 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r SetACLResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetACLResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r SetACLResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ListDevicesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ListDevicesOutputBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ListDevicesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListDevicesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ListDevicesResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ListKeysResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ListKeysOutputBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ListKeysResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListKeysResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ListKeysResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type CreateKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *Key + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r CreateKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CreateKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r CreateKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type DeleteKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DeleteKeyOutputBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r DeleteKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DeleteKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *Key + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r GetKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetKeyResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetTailnetSettingsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *TailnetSettings + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r GetTailnetSettingsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetTailnetSettingsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetTailnetSettingsResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type UpdateTailnetSettingsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *TailnetSettings + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r UpdateTailnetSettingsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r UpdateTailnetSettingsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r UpdateTailnetSettingsResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ListUsersResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ListUsersOutputBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r ListUsersResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListUsersResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ListUsersResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetUserResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *User + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel +} + +// Status returns HTTPResponse.Status +func (r GetUserResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetUserResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetUserResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +// DeleteDeviceWithResponse request returning *DeleteDeviceResponse +func (c *ClientWithResponses) DeleteDeviceWithResponse(ctx context.Context, id string, params *DeleteDeviceParams, reqEditors ...RequestEditorFn) (*DeleteDeviceResponse, error) { + rsp, err := c.DeleteDevice(ctx, id, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteDeviceResponse(rsp) +} + +// GetDeviceWithResponse request returning *GetDeviceResponse +func (c *ClientWithResponses) GetDeviceWithResponse(ctx context.Context, id string, params *GetDeviceParams, reqEditors ...RequestEditorFn) (*GetDeviceResponse, error) { + rsp, err := c.GetDevice(ctx, id, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetDeviceResponse(rsp) +} + +// AuthorizeDeviceWithBodyWithResponse request with arbitrary body returning *AuthorizeDeviceResponse +func (c *ClientWithResponses) AuthorizeDeviceWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*AuthorizeDeviceResponse, error) { + rsp, err := c.AuthorizeDeviceWithBody(ctx, id, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAuthorizeDeviceResponse(rsp) +} + +func (c *ClientWithResponses) AuthorizeDeviceWithResponse(ctx context.Context, id string, body AuthorizeDeviceJSONRequestBody, reqEditors ...RequestEditorFn) (*AuthorizeDeviceResponse, error) { + rsp, err := c.AuthorizeDevice(ctx, id, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseAuthorizeDeviceResponse(rsp) +} + +// SetDeviceKeyWithBodyWithResponse request with arbitrary body returning *SetDeviceKeyResponse +func (c *ClientWithResponses) SetDeviceKeyWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetDeviceKeyResponse, error) { + rsp, err := c.SetDeviceKeyWithBody(ctx, id, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetDeviceKeyResponse(rsp) +} + +func (c *ClientWithResponses) SetDeviceKeyWithResponse(ctx context.Context, id string, body SetDeviceKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*SetDeviceKeyResponse, error) { + rsp, err := c.SetDeviceKey(ctx, id, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetDeviceKeyResponse(rsp) +} + +// SetDeviceNameWithBodyWithResponse request with arbitrary body returning *SetDeviceNameResponse +func (c *ClientWithResponses) SetDeviceNameWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetDeviceNameResponse, error) { + rsp, err := c.SetDeviceNameWithBody(ctx, id, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetDeviceNameResponse(rsp) +} + +func (c *ClientWithResponses) SetDeviceNameWithResponse(ctx context.Context, id string, body SetDeviceNameJSONRequestBody, reqEditors ...RequestEditorFn) (*SetDeviceNameResponse, error) { + rsp, err := c.SetDeviceName(ctx, id, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetDeviceNameResponse(rsp) +} + +// GetDeviceRoutesWithResponse request returning *GetDeviceRoutesResponse +func (c *ClientWithResponses) GetDeviceRoutesWithResponse(ctx context.Context, id string, params *GetDeviceRoutesParams, reqEditors ...RequestEditorFn) (*GetDeviceRoutesResponse, error) { + rsp, err := c.GetDeviceRoutes(ctx, id, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetDeviceRoutesResponse(rsp) +} + +// SetDeviceRoutesWithBodyWithResponse request with arbitrary body returning *SetDeviceRoutesResponse +func (c *ClientWithResponses) SetDeviceRoutesWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetDeviceRoutesResponse, error) { + rsp, err := c.SetDeviceRoutesWithBody(ctx, id, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetDeviceRoutesResponse(rsp) +} + +func (c *ClientWithResponses) SetDeviceRoutesWithResponse(ctx context.Context, id string, body SetDeviceRoutesJSONRequestBody, reqEditors ...RequestEditorFn) (*SetDeviceRoutesResponse, error) { + rsp, err := c.SetDeviceRoutes(ctx, id, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetDeviceRoutesResponse(rsp) +} + +// SetDeviceTagsWithBodyWithResponse request with arbitrary body returning *SetDeviceTagsResponse +func (c *ClientWithResponses) SetDeviceTagsWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetDeviceTagsResponse, error) { + rsp, err := c.SetDeviceTagsWithBody(ctx, id, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetDeviceTagsResponse(rsp) +} + +func (c *ClientWithResponses) SetDeviceTagsWithResponse(ctx context.Context, id string, body SetDeviceTagsJSONRequestBody, reqEditors ...RequestEditorFn) (*SetDeviceTagsResponse, error) { + rsp, err := c.SetDeviceTags(ctx, id, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetDeviceTagsResponse(rsp) +} + +// GetACLWithResponse request returning *GetACLResponse +func (c *ClientWithResponses) GetACLWithResponse(ctx context.Context, tailnet string, params *GetACLParams, reqEditors ...RequestEditorFn) (*GetACLResponse, error) { + rsp, err := c.GetACL(ctx, tailnet, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetACLResponse(rsp) +} + +// SetACLWithBodyWithResponse request with arbitrary body returning *SetACLResponse +func (c *ClientWithResponses) SetACLWithBodyWithResponse(ctx context.Context, tailnet string, params *SetACLParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetACLResponse, error) { + rsp, err := c.SetACLWithBody(ctx, tailnet, params, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetACLResponse(rsp) +} + +func (c *ClientWithResponses) SetACLWithResponse(ctx context.Context, tailnet string, params *SetACLParams, body SetACLJSONRequestBody, reqEditors ...RequestEditorFn) (*SetACLResponse, error) { + rsp, err := c.SetACL(ctx, tailnet, params, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetACLResponse(rsp) +} + +// ListDevicesWithResponse request returning *ListDevicesResponse +func (c *ClientWithResponses) ListDevicesWithResponse(ctx context.Context, tailnet string, params *ListDevicesParams, reqEditors ...RequestEditorFn) (*ListDevicesResponse, error) { + rsp, err := c.ListDevices(ctx, tailnet, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListDevicesResponse(rsp) +} + +// ListKeysWithResponse request returning *ListKeysResponse +func (c *ClientWithResponses) ListKeysWithResponse(ctx context.Context, tailnet string, params *ListKeysParams, reqEditors ...RequestEditorFn) (*ListKeysResponse, error) { + rsp, err := c.ListKeys(ctx, tailnet, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListKeysResponse(rsp) +} + +// CreateKeyWithBodyWithResponse request with arbitrary body returning *CreateKeyResponse +func (c *ClientWithResponses) CreateKeyWithBodyWithResponse(ctx context.Context, tailnet string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateKeyResponse, error) { + rsp, err := c.CreateKeyWithBody(ctx, tailnet, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateKeyResponse(rsp) +} + +func (c *ClientWithResponses) CreateKeyWithResponse(ctx context.Context, tailnet string, body CreateKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateKeyResponse, error) { + rsp, err := c.CreateKey(ctx, tailnet, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateKeyResponse(rsp) +} + +// DeleteKeyWithResponse request returning *DeleteKeyResponse +func (c *ClientWithResponses) DeleteKeyWithResponse(ctx context.Context, tailnet string, keyId string, reqEditors ...RequestEditorFn) (*DeleteKeyResponse, error) { + rsp, err := c.DeleteKey(ctx, tailnet, keyId, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteKeyResponse(rsp) +} + +// GetKeyWithResponse request returning *GetKeyResponse +func (c *ClientWithResponses) GetKeyWithResponse(ctx context.Context, tailnet string, keyId string, reqEditors ...RequestEditorFn) (*GetKeyResponse, error) { + rsp, err := c.GetKey(ctx, tailnet, keyId, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetKeyResponse(rsp) +} + +// GetTailnetSettingsWithResponse request returning *GetTailnetSettingsResponse +func (c *ClientWithResponses) GetTailnetSettingsWithResponse(ctx context.Context, tailnet string, reqEditors ...RequestEditorFn) (*GetTailnetSettingsResponse, error) { + rsp, err := c.GetTailnetSettings(ctx, tailnet, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetTailnetSettingsResponse(rsp) +} + +// UpdateTailnetSettingsWithBodyWithResponse request with arbitrary body returning *UpdateTailnetSettingsResponse +func (c *ClientWithResponses) UpdateTailnetSettingsWithBodyWithResponse(ctx context.Context, tailnet string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateTailnetSettingsResponse, error) { + rsp, err := c.UpdateTailnetSettingsWithBody(ctx, tailnet, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUpdateTailnetSettingsResponse(rsp) +} + +func (c *ClientWithResponses) UpdateTailnetSettingsWithResponse(ctx context.Context, tailnet string, body UpdateTailnetSettingsJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateTailnetSettingsResponse, error) { + rsp, err := c.UpdateTailnetSettings(ctx, tailnet, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUpdateTailnetSettingsResponse(rsp) +} + +// ListUsersWithResponse request returning *ListUsersResponse +func (c *ClientWithResponses) ListUsersWithResponse(ctx context.Context, tailnet string, params *ListUsersParams, reqEditors ...RequestEditorFn) (*ListUsersResponse, error) { + rsp, err := c.ListUsers(ctx, tailnet, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListUsersResponse(rsp) +} + +// GetUserWithResponse request returning *GetUserResponse +func (c *ClientWithResponses) GetUserWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*GetUserResponse, error) { + rsp, err := c.GetUser(ctx, id, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetUserResponse(rsp) +} + +// ParseDeleteDeviceResponse parses an HTTP response from a DeleteDeviceWithResponse call +func ParseDeleteDeviceResponse(rsp *http.Response) (*DeleteDeviceResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteDeviceResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest EmptyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseGetDeviceResponse parses an HTTP response from a GetDeviceWithResponse call +func ParseGetDeviceResponse(rsp *http.Response) (*GetDeviceResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetDeviceResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Device + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseAuthorizeDeviceResponse parses an HTTP response from a AuthorizeDeviceWithResponse call +func ParseAuthorizeDeviceResponse(rsp *http.Response) (*AuthorizeDeviceResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &AuthorizeDeviceResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest EmptyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseSetDeviceKeyResponse parses an HTTP response from a SetDeviceKeyWithResponse call +func ParseSetDeviceKeyResponse(rsp *http.Response) (*SetDeviceKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetDeviceKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest EmptyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseSetDeviceNameResponse parses an HTTP response from a SetDeviceNameWithResponse call +func ParseSetDeviceNameResponse(rsp *http.Response) (*SetDeviceNameResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetDeviceNameResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest EmptyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseGetDeviceRoutesResponse parses an HTTP response from a GetDeviceRoutesWithResponse call +func ParseGetDeviceRoutesResponse(rsp *http.Response) (*GetDeviceRoutesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetDeviceRoutesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DeviceRoutes + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseSetDeviceRoutesResponse parses an HTTP response from a SetDeviceRoutesWithResponse call +func ParseSetDeviceRoutesResponse(rsp *http.Response) (*SetDeviceRoutesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetDeviceRoutesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DeviceRoutes + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseSetDeviceTagsResponse parses an HTTP response from a SetDeviceTagsWithResponse call +func ParseSetDeviceTagsResponse(rsp *http.Response) (*SetDeviceTagsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetDeviceTagsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest EmptyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseGetACLResponse parses an HTTP response from a GetACLWithResponse call +func ParseGetACLResponse(rsp *http.Response) (*GetACLResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetACLResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseSetACLResponse parses an HTTP response from a SetACLWithResponse call +func ParseSetACLResponse(rsp *http.Response) (*SetACLResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &SetACLResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 412: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON412 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseListDevicesResponse parses an HTTP response from a ListDevicesWithResponse call +func ParseListDevicesResponse(rsp *http.Response) (*ListDevicesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListDevicesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListDevicesOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseListKeysResponse parses an HTTP response from a ListKeysWithResponse call +func ParseListKeysResponse(rsp *http.Response) (*ListKeysResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListKeysResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListKeysOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseCreateKeyResponse parses an HTTP response from a CreateKeyWithResponse call +func ParseCreateKeyResponse(rsp *http.Response) (*CreateKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CreateKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Key + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseDeleteKeyResponse parses an HTTP response from a DeleteKeyWithResponse call +func ParseDeleteKeyResponse(rsp *http.Response) (*DeleteKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DeleteKeyOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseGetKeyResponse parses an HTTP response from a GetKeyWithResponse call +func ParseGetKeyResponse(rsp *http.Response) (*GetKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Key + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseGetTailnetSettingsResponse parses an HTTP response from a GetTailnetSettingsWithResponse call +func ParseGetTailnetSettingsResponse(rsp *http.Response) (*GetTailnetSettingsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetTailnetSettingsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest TailnetSettings + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseUpdateTailnetSettingsResponse parses an HTTP response from a UpdateTailnetSettingsWithResponse call +func ParseUpdateTailnetSettingsResponse(rsp *http.Response) (*UpdateTailnetSettingsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &UpdateTailnetSettingsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest TailnetSettings + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest + + } + + return response, nil +} + +// ParseListUsersResponse parses an HTTP response from a ListUsersWithResponse call +func ParseListUsersResponse(rsp *http.Response) (*ListUsersResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListUsersResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListUsersOutputBody + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseGetUserResponse parses an HTTP response from a GetUserWithResponse call +func ParseGetUserResponse(rsp *http.Response) (*GetUserResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetUserResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest User + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} diff --git a/hscontrol/app.go b/hscontrol/app.go index 7564c9094..8feef1065 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -319,6 +319,11 @@ func (h *Headscale) scheduledTasks(ctx context.Context) { revokedKeyGCChan = revokedKeyTicker.C } + // OAuth access tokens are short-lived (1h) and re-minted on demand; reap + // expired rows hourly so the table stays bounded. + accessTokenTicker := time.NewTicker(time.Hour) + defer accessTokenTicker.Stop() + for { select { case <-ctx.Done(): @@ -335,6 +340,14 @@ func (h *Headscale) scheduledTasks(ctx context.Context) { log.Info().Int("count", reaped).Msg("reaped revoked pre-auth keys") } + case <-accessTokenTicker.C: + reaped, err := h.state.DeleteExpiredAccessTokens(time.Now()) + if err != nil { + log.Error().Err(err).Msg("reaping expired oauth access tokens") + } else if reaped > 0 { + log.Debug().Int64("count", reaped).Msg("reaped expired oauth access tokens") + } + case <-expireTicker.C: var ( expiredNodeChanges []change.Change @@ -627,20 +640,26 @@ func (h *Headscale) Serve() error { Cfg: h.cfg, }) - // The Headscale v2 API. It is served only over the remote - // listener (Basic/Bearer auth); no unix-socket mount yet, as nothing local - // calls it — the CLI still speaks v1. + // The Headscale v2 API. Served behind Basic/Bearer auth on the remote + // listener, and over the local unix socket (local trust) so the CLI can + // manage OAuth clients through the same v2 keys handler the Tailscale + // ecosystem uses. humaV2Mux, _ := apiv2.Handler(apiv2.Backend{ State: h.state, Change: h.Change, Cfg: h.cfg, }) - // Serve the Huma API over the unix socket without TLS or auth: socket access - // implies trust. WithLocalTrust marks these requests so the security - // middleware skips the API-key check. + // Serve both Huma APIs over the unix socket without TLS or auth: socket + // access implies trust. WithLocalTrust marks these requests so each API's + // security middleware skips the credential check. v2 paths route to the v2 + // mux; everything else (the v1 paths) to v1. + socketHandler := http.NewServeMux() + socketHandler.Handle("/api/v2/", apiv2.WithLocalTrust(humaV2Mux)) + socketHandler.Handle("/", apiv1.WithLocalTrust(humaMux)) + socketServer := &http.Server{ - Handler: apiv1.WithLocalTrust(humaMux), + Handler: socketHandler, ReadTimeout: types.HTTPTimeout, } From af46fe88677e5554550211fcd77c90ebed82e22c Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sun, 21 Jun 2026 17:35:44 +0000 Subject: [PATCH 106/127] hscontrol, integration: test OAuth scope enforcement and the oauth-clients CLI Add scope-enforcement coverage and integration tests for the oauth-clients CLI across the test matrix. --- .github/workflows/test-integration.yaml | 2 + hscontrol/api/v2/guard_test.go | 58 ++ hscontrol/apiv2_oauth_matrix_test.go | 280 ++++++++++ hscontrol/apiv2_oauth_test.go | 511 ++++++++++++++++++ .../servertest/apiv2_oauth_scopes_test.go | 391 ++++++++++++++ integration/cli_oauth_clients_test.go | 126 +++++ 6 files changed, 1368 insertions(+) create mode 100644 hscontrol/api/v2/guard_test.go create mode 100644 hscontrol/apiv2_oauth_matrix_test.go create mode 100644 hscontrol/apiv2_oauth_test.go create mode 100644 hscontrol/servertest/apiv2_oauth_scopes_test.go create mode 100644 integration/cli_oauth_clients_test.go diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index bfdb3f574..4326d03de 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -275,6 +275,8 @@ jobs: - TestNodeTagCommand - TestNodeRouteCommands - TestNodeBackfillIPsCommand + - TestOAuthClientCommand + - TestOAuthClientCommandValidation - TestPolicyCheckCommand - TestSSHTestsRejectFailingPolicy - TestPolicyCommand diff --git a/hscontrol/api/v2/guard_test.go b/hscontrol/api/v2/guard_test.go new file mode 100644 index 000000000..7d52e0c79 --- /dev/null +++ b/hscontrol/api/v2/guard_test.go @@ -0,0 +1,58 @@ +package apiv2 + +import ( + "testing" + + "github.com/danielgtaylor/huma/v2" + "github.com/go-chi/chi/v5" + "github.com/juanfont/headscale/hscontrol/scope" +) + +// selfEnforcedKeyOps are the authenticated operations that intentionally declare +// NO static scope because they multiplex on keyType and authorize inside the +// handler via requireKeyScope (see keys.go). Every other authenticated operation +// must declare a scope. +var selfEnforcedKeyOps = map[string]bool{ + "POST /api/v2/tailnet/{tailnet}/keys": true, + "GET /api/v2/tailnet/{tailnet}/keys": true, + "GET /api/v2/tailnet/{tailnet}/keys/{keyId}": true, + "DELETE /api/v2/tailnet/{tailnet}/keys/{keyId}": true, +} + +// TestEveryAuthenticatedOperationDeclaresScope is the structural guarantee that no +// v2 operation ships unprotected: any operation that requires authentication +// (non-empty Security) must either declare a required scope via requireScope, or +// be one of the keyType-multiplexed keys operations that self-enforce. A new +// operation added without scope protection fails this test. +func TestEveryAuthenticatedOperationDeclaresScope(t *testing.T) { + api := NewAPI(chi.NewMux(), Backend{}) + + for path, item := range api.OpenAPI().Paths { + for method, op := range humaOperations(item) { + if op == nil || len(op.Security) == 0 { + continue // unregistered method or a public operation + } + + key := method + " " + path + if selfEnforcedKeyOps[key] { + continue + } + + if _, ok := op.Metadata[scopeMetaKey].(scope.Scope); !ok { + t.Errorf("operation %q is authenticated but declares no required scope; "+ + "wrap it in requireScope, or add it to selfEnforcedKeyOps if it "+ + "authorizes inside the handler", key) + } + } + } +} + +func humaOperations(item *huma.PathItem) map[string]*huma.Operation { + return map[string]*huma.Operation{ + "GET": item.Get, + "POST": item.Post, + "PUT": item.Put, + "DELETE": item.Delete, + "PATCH": item.Patch, + } +} diff --git a/hscontrol/apiv2_oauth_matrix_test.go b/hscontrol/apiv2_oauth_matrix_test.go new file mode 100644 index 000000000..3e71d730f --- /dev/null +++ b/hscontrol/apiv2_oauth_matrix_test.go @@ -0,0 +1,280 @@ +package hscontrol + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" + "github.com/juanfont/headscale/hscontrol/scope" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// apiReq issues an arbitrary-method request with an optional bearer token and JSON +// body, returning the status and raw body. It owns the response body. +func apiReq(t *testing.T, method, target, bearer string, body any) (int, []byte) { + t.Helper() + + var r io.Reader + + if body != nil { + b, err := json.Marshal(body) + require.NoError(t, err) + + r = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(t.Context(), method, target, r) + require.NoError(t, err) + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + data, _ := io.ReadAll(resp.Body) + + return resp.StatusCode, data +} + +// scopeDenied reports whether a response is a scope-enforcement denial. The scope +// gate runs in the middleware before the handler, so a denial is exactly a 403 +// carrying this message; any other status (200, or a 400/404 from the handler on +// minimal input) means the request passed the scope gate. +func scopeDenied(status int, body []byte) bool { + return status == http.StatusForbidden && + strings.Contains(string(body), "missing the required scope") +} + +// mintScopedToken creates an OAuth client holding exactly scope s (tagged tag:ci so +// tag-requiring scopes are valid) and returns an access token for it. +func mintScopedToken(t *testing.T, baseURL, admin string, s scope.Scope) string { + t.Helper() + + _, secret := createClient(t, baseURL, admin, []string{string(s)}, []string{"tag:ci"}) + + return tokenFor(t, baseURL, secret) +} + +// createAdminAuthKey creates a tagged auth key with the admin key and returns its id. +func createAdminAuthKey(t *testing.T, baseURL, admin string) string { + t.Helper() + + status, body := apiReq(t, http.MethodPost, baseURL+"/api/v2/tailnet/-/keys", admin, apiv2.CreateKeyRequest{ + Capabilities: &apiv2.KeyCapabilities{Devices: apiv2.KeyDeviceCapabilities{ + Create: apiv2.KeyDeviceCreateCapabilities{Reusable: true, Tags: []string{"tag:ci"}}, + }}, + }) + require.Equalf(t, http.StatusOK, status, "create admin auth key: %s", body) + + var key apiv2.Key + require.NoError(t, json.Unmarshal(body, &key)) + + return key.ID +} + +type matrixOp struct { + name string + method string + path string + need scope.Scope + body any + // multiplexed marks the keyType-multiplexed keys ops. They self-enforce in + // the handler and, to avoid an existence oracle, deny an unauthorized token + // with a uniform not-found rather than the middleware's scope-403. So denial + // is "resource not returned" (status != 200), not a specific 403. + multiplexed bool +} + +// TestAPIv2OAuthMatrix_Enforcement is the exhaustive operation×scope cross-product: +// for every scope-gated operation and every scope in the vocabulary, a token +// holding exactly that scope is allowed iff scope.Grants permits it (P3) and denied +// otherwise (P2). +func TestAPIv2OAuthMatrix_Enforcement(t *testing.T) { + app, baseURL, admin := newOAuthTestServer(t) + + // Stable ids for the keyType-multiplexed get-by-id operations. + clientID, _ := createClient(t, baseURL, admin, []string{"oauth_keys:read"}, nil) + authKeyID := createAdminAuthKey(t, baseURL, admin) + _ = app + + ops := []matrixOp{ + {"getDevice", http.MethodGet, "/api/v2/device/1", scope.DevicesCoreRead, nil, false}, + {"listDevices", http.MethodGet, "/api/v2/tailnet/-/devices", scope.DevicesCoreRead, nil, false}, + {"deleteDevice", http.MethodDelete, "/api/v2/device/1", scope.DevicesCore, nil, false}, + {"authorizeDevice", http.MethodPost, "/api/v2/device/1/authorized", scope.DevicesCore, map[string]any{}, false}, + {"setDeviceName", http.MethodPost, "/api/v2/device/1/name", scope.DevicesCore, map[string]any{}, false}, + {"setDeviceTags", http.MethodPost, "/api/v2/device/1/tags", scope.DevicesCore, map[string]any{}, false}, + {"setDeviceKey", http.MethodPost, "/api/v2/device/1/key", scope.DevicesCore, map[string]any{}, false}, + {"setDeviceRoutes", http.MethodPost, "/api/v2/device/1/routes", scope.DevicesRoutes, map[string]any{}, false}, + {"getDeviceRoutes", http.MethodGet, "/api/v2/device/1/routes", scope.DevicesRoutesRead, nil, false}, + {"getACL", http.MethodGet, "/api/v2/tailnet/-/acl", scope.PolicyFileRead, nil, false}, + {"setACL", http.MethodPost, "/api/v2/tailnet/-/acl", scope.PolicyFile, map[string]any{}, false}, + {"getSettings", http.MethodGet, "/api/v2/tailnet/-/settings", scope.FeatureSettingsRead, nil, false}, + {"updateSettings", http.MethodPatch, "/api/v2/tailnet/-/settings", scope.FeatureSettings, map[string]any{}, false}, + {"getKeyClient", http.MethodGet, "/api/v2/tailnet/-/keys/" + clientID, scope.OAuthKeysRead, nil, true}, + {"getKeyAuth", http.MethodGet, "/api/v2/tailnet/-/keys/" + authKeyID, scope.AuthKeysRead, nil, true}, + } + + // One token per scope, reused across every operation. + tokens := make(map[scope.Scope]string, len(scope.Known())) + for _, s := range scope.Known() { + tokens[s] = mintScopedToken(t, baseURL, admin, s) + } + + for _, op := range ops { + for _, s := range scope.Known() { + status, body := apiReq(t, op.method, baseURL+op.path, tokens[s], op.body) + + wantDenied := !scope.Grants([]scope.Scope{s}, op.need) + + // A multiplexed keys op denies via uniform not-found (no existence + // oracle), so denial is "resource not returned"; every other op + // denies with the middleware's scope-403. + denied := scopeDenied(status, body) + if op.multiplexed { + denied = status != http.StatusOK + } + + assert.Equalf(t, wantDenied, denied, + "op %s with scope %q (needs %q): status=%d body=%s", + op.name, s, op.need, status, body) + } + } +} + +// TestAPIv2OAuthMatrix_ScopeNarrowing proves P1 for token minting: a client may +// only mint a token for scopes within its own grant. For every held scope X and +// requested scope Y, the mint succeeds iff scope.Grants([X], Y). +func TestAPIv2OAuthMatrix_ScopeNarrowing(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + for _, held := range scope.Known() { + _, secret := createClient(t, baseURL, admin, []string{string(held)}, []string{"tag:ci"}) + + for _, want := range scope.Known() { + status, m := mintToken(t, baseURL, "", secret, string(want), false) + + wantOK := scope.Grants([]scope.Scope{held}, want) + if wantOK { + assert.Equalf(t, http.StatusOK, status, "held %q narrow to %q: %v", held, want, m) + } else { + assert.Equalf(t, http.StatusBadRequest, status, + "held %q must not mint %q: %v", held, want, m) + assert.Equal(t, "invalid_scope", m["error"]) + } + } + } +} + +// TestAPIv2OAuthMatrix_TagNarrowing proves P1 for tags at mint time: a client may +// only mint a token for tags within its grant (closing the /oauth/token tags-param +// path). A client with the "all" scope may request any tag. +func TestAPIv2OAuthMatrix_TagNarrowing(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + _, secret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:a", "tag:b"}) + + // In-grant tags mint; an out-of-grant tag is rejected. + status, m := mintToken(t, baseURL, "", secret, "", false) + require.Equalf(t, http.StatusOK, status, "%v", m) + + for _, tag := range []string{"tag:a", "tag:b"} { + st, mm := mintTokenWithTags(t, baseURL, secret, tag) + assert.Equalf(t, http.StatusOK, st, "in-grant tag %q: %v", tag, mm) + } + + st, mm := mintTokenWithTags(t, baseURL, secret, "tag:c") + assert.Equal(t, http.StatusBadRequest, st, mm) + assert.Equal(t, "invalid_target", mm["error"]) + + // An all-scope client may request any tag. + _, allSecret := createClient(t, baseURL, admin, []string{"all"}, []string{"tag:a"}) + stAll, mAll := mintTokenWithTags(t, baseURL, allSecret, "tag:anything") + assert.Equalf(t, http.StatusOK, stAll, "all-scope client may assign any tag: %v", mAll) +} + +// TestAPIv2OAuthMatrix_Lifecycle proves expired and revoked credentials are denied +// at the HTTP layer, and that an admin API key bypasses scope checks. +func TestAPIv2OAuthMatrix_Lifecycle(t *testing.T) { + app, baseURL, admin := newOAuthTestServer(t) + + devicesPath := baseURL + "/api/v2/tailnet/-/devices" + + // Expired token → 401. + _, client, err := app.state.CreateOAuthClient([]string{"devices:core:read"}, []string{"tag:ci"}, "expired", nil) + require.NoError(t, err) + + past := time.Now().Add(-time.Hour) + expiredTok, _, err := app.state.MintAccessToken(client.ClientID, []string{"devices:core:read"}, nil, &past) + require.NoError(t, err) + + status, _ := apiReq(t, http.MethodGet, devicesPath, expiredTok, nil) + assert.Equal(t, http.StatusUnauthorized, status, "expired token must be rejected") + + // Revoked client → its token is denied. + _, revClient, err := app.state.CreateOAuthClient([]string{"devices:core:read"}, []string{"tag:ci"}, "revoked", nil) + require.NoError(t, err) + + future := time.Now().Add(time.Hour) + revTok, _, err := app.state.MintAccessToken(revClient.ClientID, []string{"devices:core:read"}, nil, &future) + require.NoError(t, err) + + // Works before revocation. + okStatus, okBody := apiReq(t, http.MethodGet, devicesPath, revTok, nil) + assert.Falsef(t, scopeDenied(okStatus, okBody), "token should pass the scope gate before revoke: %d", okStatus) + + require.NoError(t, app.state.RevokeOAuthClient(revClient.ClientID)) + + revStatus, _ := apiReq(t, http.MethodGet, devicesPath, revTok, nil) + assert.Equal(t, http.StatusUnauthorized, revStatus, "token of a revoked client must be rejected") + + // Admin API key bypasses scope checks: it reaches a devices op that a minimal + // (feature_settings:read) token cannot. + minimalTok := mintScopedToken(t, baseURL, admin, scope.FeatureSettingsRead) + minStatus, minBody := apiReq(t, http.MethodGet, devicesPath, minimalTok, nil) + assert.True(t, scopeDenied(minStatus, minBody), "minimal token must be scope-denied on devices") + + adminStatus, adminBody := apiReq(t, http.MethodGet, devicesPath, admin, nil) + assert.Falsef(t, scopeDenied(adminStatus, adminBody), + "admin key is all-access and must not be scope-denied: %d %s", adminStatus, adminBody) +} + +// mintTokenWithTags mints with a tags narrowing parameter. +func mintTokenWithTags(t *testing.T, baseURL, secret, tags string) (int, map[string]any) { + t.Helper() + + form := fmt.Sprintf("grant_type=client_credentials&client_secret=%s&tags=%s", secret, tags) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, + baseURL+"/api/v2/oauth/token", strings.NewReader(form)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + data, _ := io.ReadAll(resp.Body) + + var m map[string]any + + _ = json.Unmarshal(data, &m) + + return resp.StatusCode, m +} diff --git a/hscontrol/apiv2_oauth_test.go b/hscontrol/apiv2_oauth_test.go new file mode 100644 index 000000000..c3f61043c --- /dev/null +++ b/hscontrol/apiv2_oauth_test.go @@ -0,0 +1,511 @@ +package hscontrol + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newOAuthTestServer builds the full v2 API (the auth+scope middleware and the +// OAuth token endpoint, which the humatest harness in apiv2_keys_test.go does +// not mount) over a real httptest server, returning the app, its base URL, and +// an all-access admin API key. +func newOAuthTestServer(t *testing.T) (*Headscale, string, string) { + t.Helper() + + app := createTestApp(t) + + // Tag creation now requires the tag to exist in policy (matching + // SetNodeTags), so define the tags these tests assign. Tests needing + // specific tag ownership (e.g. delegation) override this policy. + const policy = `{"tagOwners":{"tag:a":[],"tag:b":[],"tag:c":[],"tag:ci":[],"tag:k8s":[],"tag:k8s-operator":[],"tag:other":[],"tag:anything":[]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + + _, err := app.state.SetPolicy([]byte(policy)) + require.NoError(t, err) + _, err = app.state.SetPolicyInDB(policy) + require.NoError(t, err) + _, err = app.state.ReloadPolicy() + require.NoError(t, err) + + mux, _ := apiv2.Handler(apiv2.Backend{State: app.state, Change: app.Change, Cfg: app.cfg}) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + adminKey, _, err := app.state.CreateAPIKey(nil) + require.NoError(t, err) + + return app, srv.URL, adminKey +} + +// apiPost POSTs a JSON body with an optional bearer token, returning the status +// and raw body. It owns the response body so callers never leak it. +func apiPost(t *testing.T, target, bearer string, body any) (int, []byte) { + t.Helper() + + b, err := json.Marshal(body) + require.NoError(t, err) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, target, bytes.NewReader(b)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + data, _ := io.ReadAll(resp.Body) + + return resp.StatusCode, data +} + +// apiGet GETs a target with an optional bearer token, returning the status. It +// drains and closes the response body so callers never leak it. +func apiGet(t *testing.T, target, bearer string) int { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, target, nil) + require.NoError(t, err) + + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + _, _ = io.Copy(io.Discard, resp.Body) + + return resp.StatusCode +} + +// createClient creates an OAuth client through the v2 keys API as the bearer +// credential, returning the client id and the once-shown secret. +func createClient(t *testing.T, baseURL, bearer string, scopes, tags []string) (string, string) { + t.Helper() + + status, body := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", bearer, apiv2.CreateKeyRequest{ + KeyType: "client", + Scopes: scopes, + Tags: tags, + }) + require.Equalf(t, http.StatusOK, status, "create client: %s", body) + + var key apiv2.Key + require.NoError(t, json.Unmarshal(body, &key)) + assert.Equal(t, "client", key.KeyType) + require.NotEmpty(t, key.ID, "client id returned") + require.NotEmpty(t, key.Key, "secret returned once on create") + + return key.ID, key.Key +} + +// mintToken runs the client-credentials grant. scope is an optional space-delimited +// narrowing of the client's scopes. credsInBasic sends the secret via HTTP Basic +// instead of the body. Returns the status and decoded JSON body. +func mintToken(t *testing.T, baseURL, clientID, secret, scope string, credsInBasic bool) (int, map[string]any) { + t.Helper() + + form := url.Values{"grant_type": {"client_credentials"}} + if scope != "" { + form.Set("scope", scope) + } + + if !credsInBasic { + form.Set("client_secret", secret) + } + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, + baseURL+"/api/v2/oauth/token", strings.NewReader(form.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + if credsInBasic { + req.SetBasicAuth(clientID, secret) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + data, _ := io.ReadAll(resp.Body) + + var m map[string]any + + _ = json.Unmarshal(data, &m) + + return resp.StatusCode, m +} + +// tokenFor mints and returns a bearer access token for the client. +func tokenFor(t *testing.T, baseURL, secret string) string { + t.Helper() + + status, m := mintToken(t, baseURL, "", secret, "", false) + require.Equalf(t, http.StatusOK, status, "mint token: %v", m) + + tok, _ := m["access_token"].(string) + require.NotEmpty(t, tok) + + return tok +} + +// createTaggedKey attempts to create a tagged auth key and returns the HTTP +// status, so allow/deny is asserted by the caller. +func createTaggedKey(t *testing.T, baseURL, bearer string, tags []string) int { + t.Helper() + + status, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", bearer, apiv2.CreateKeyRequest{ + Capabilities: &apiv2.KeyCapabilities{Devices: apiv2.KeyDeviceCapabilities{ + Create: apiv2.KeyDeviceCreateCapabilities{Reusable: true, Tags: tags}, + }}, + }) + + return status +} + +func TestAPIv2OAuth_TokenEndpoint(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + clientID, secret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"}) + + // The secret embeds the client id (Tailscale's derive-from-secret trick). + assert.Contains(t, secret, clientID) + + // Mint via the request body. + status, m := mintToken(t, baseURL, clientID, secret, "", false) + require.Equalf(t, http.StatusOK, status, "%v", m) + assert.Equal(t, "Bearer", m["token_type"]) + assert.InDelta(t, 3600, m["expires_in"], 0, "fixed 1h, in seconds") + tok, _ := m["access_token"].(string) + assert.Contains(t, tok, "hskey-oauthtok-", "distinct prefix from admin keys") + + // Mint via HTTP Basic (x/oauth2 auto-detect probes Basic first). + statusBasic, mBasic := mintToken(t, baseURL, clientID, secret, "", true) + require.Equalf(t, http.StatusOK, statusBasic, "%v", mBasic) + assert.NotEmpty(t, mBasic["access_token"]) + + // Bad credentials → RFC 6749 invalid_client. + statusBad, mBad := mintToken(t, baseURL, clientID, "hskey-client-deadbeefdead-"+stringOf("0", 64), "", false) + assert.Equal(t, http.StatusUnauthorized, statusBad) + assert.Equal(t, "invalid_client", mBad["error"]) + + // The token response carries a bearer credential and must not be cached + // (RFC 6749 §5.1). mintToken consumes the body, so probe the header raw. + form := url.Values{"grant_type": {"client_credentials"}, "client_secret": {secret}} + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, + baseURL+"/api/v2/oauth/token", strings.NewReader(form.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, "no-store", resp.Header.Get("Cache-Control")) +} + +func TestAPIv2OAuth_ScopeEnforcement(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + // A write-scoped token may create an auth key within its tag grant... + _, writeSecret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"}) + writeTok := tokenFor(t, baseURL, writeSecret) + assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, writeTok, []string{"tag:ci"}), + "write scope + in-grant tag allowed") + + // ...but not with a tag outside its grant. + assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, writeTok, []string{"tag:other"}), + "tag outside grant denied") + + // A read-only token is denied the write. + _, readSecret := createClient(t, baseURL, admin, []string{"auth_keys:read"}, []string{"tag:ci"}) + readTok := tokenFor(t, baseURL, readSecret) + assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, readTok, []string{"tag:ci"}), + "read scope denied write") + + // The wrong write scope (devices:core) cannot create auth keys. + _, devSecret := createClient(t, baseURL, admin, []string{"devices:core"}, []string{"tag:ci"}) + devTok := tokenFor(t, baseURL, devSecret) + assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, devTok, []string{"tag:ci"}), + "wrong scope denied") + + // The "all" super-scope grants auth_keys. + _, allSecret := createClient(t, baseURL, admin, []string{"all"}, []string{"tag:ci"}) + allTok := tokenFor(t, baseURL, allSecret) + assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, allTok, []string{"tag:ci"}), + "all grants auth_keys") + + // A token minted from the all-powerful client but narrowed to read-only is + // itself denied writes: narrowing is enforced on the minted token. + statusNarrow, mNarrow := mintToken(t, baseURL, "", allSecret, "auth_keys:read", false) + require.Equalf(t, http.StatusOK, statusNarrow, "narrow mint: %v", mNarrow) + narrowed, _ := mNarrow["access_token"].(string) + assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, narrowed, []string{"tag:ci"}), + "token narrowed to :read denied write") +} + +func TestAPIv2OAuth_ClientManagementScopes(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + // An oauth_keys token may create a client within its own grant... + _, okSecret := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"}) + okTok := tokenFor(t, baseURL, okSecret) + status, body := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", okTok, apiv2.CreateKeyRequest{ + KeyType: "client", Scopes: []string{"oauth_keys:read"}, + }) + assert.Equalf(t, http.StatusOK, status, "oauth_keys creates an in-grant client: %s", body) + + // ...but may NOT escalate by granting the new client a scope the token lacks. + statusEsc, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", okTok, apiv2.CreateKeyRequest{ + KeyType: "client", Scopes: []string{"auth_keys"}, Tags: []string{"tag:ci"}, + }) + assert.Equal(t, http.StatusForbidden, statusEsc, "oauth_keys token cannot mint a broader client") + + // A token without oauth_keys cannot manage clients at all. + _, akSecret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"}) + akTok := tokenFor(t, baseURL, akSecret) + statusDeny, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", akTok, apiv2.CreateKeyRequest{ + KeyType: "client", Scopes: []string{"auth_keys"}, Tags: []string{"tag:ci"}, + }) + assert.Equal(t, http.StatusForbidden, statusDeny, "auth_keys (no oauth_keys) cannot create a client") +} + +func TestAPIv2OAuth_TagOwnedBy(t *testing.T) { + app, baseURL, admin := newOAuthTestServer(t) + + // tag:k8s is owned by tag:k8s-operator: the operator's tag delegation. + // tag:other exists but is owned by no one, so it tests grant denial (403) + // rather than tag-not-in-policy (400). + const policy = `{"tagOwners":{"tag:k8s-operator":[],"tag:k8s":["tag:k8s-operator"],"tag:other":[]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + + _, err := app.state.SetPolicy([]byte(policy)) + require.NoError(t, err) + _, err = app.state.SetPolicyInDB(policy) + require.NoError(t, err) + _, err = app.state.ReloadPolicy() + require.NoError(t, err) + + _, secret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:k8s-operator"}) + tok := tokenFor(t, baseURL, secret) + + // Exact-match tag: allowed. + assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, tok, []string{"tag:k8s-operator"}), + "a token may use its own tag") + + // Owned-by tag: allowed (tag:k8s is owned by tag:k8s-operator). + assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, tok, []string{"tag:k8s"}), + "a token may use a tag owned by its tag") + + // Unrelated tag: denied. + assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, tok, []string{"tag:other"}), + "a token may not use an unowned tag") +} + +// TestAPIv2OAuth_NoClientExistenceOracle asserts a token that cannot read OAuth +// clients gets the same response for a real client id as for a missing key, so +// it cannot enumerate which ids are OAuth clients. +func TestAPIv2OAuth_NoClientExistenceOracle(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + clientID, _ := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"}) + + // A token holding only auth_keys:read (no oauth_keys:read). + _, akSecret := createClient(t, baseURL, admin, []string{"auth_keys:read"}, []string{"tag:ci"}) + akTok := tokenFor(t, baseURL, akSecret) + + statusReal := apiGet(t, baseURL+"/api/v2/tailnet/-/keys/"+clientID, akTok) + statusMissing := apiGet(t, baseURL+"/api/v2/tailnet/-/keys/deadbeefdead", akTok) + + assert.Equal(t, statusMissing, statusReal, + "a token without oauth_keys:read must not distinguish a real client id from a missing key") + assert.NotEqual(t, http.StatusOK, statusReal, "the client must not be readable") + + // A token that does hold oauth_keys:read can read the client. + _, okSecret := createClient(t, baseURL, admin, []string{"oauth_keys:read"}, []string{"tag:ci"}) + okTok := tokenFor(t, baseURL, okSecret) + + statusOK := apiGet(t, baseURL+"/api/v2/tailnet/-/keys/"+clientID, okTok) + assert.Equal(t, http.StatusOK, statusOK, "oauth_keys:read may read the client") +} + +// TestAPIv2OAuth_SetDeviceTagsGrant asserts a devices:core token may only set +// tags within its grant on a device. Without the grant check, the scope alone +// would let a token stamp any existing policy tag (e.g. tag:other) onto any node. +func TestAPIv2OAuth_SetDeviceTagsGrant(t *testing.T) { + app, baseURL, admin := newOAuthTestServer(t) + + // A registered, user-owned node to retag. + user := app.state.CreateUserForTest("dut") + node := app.state.CreateRegisteredNodeForTest(user, "dut") + node.User = user + view := app.state.PutNodeInStoreForTest(*node) + devURL := baseURL + "/api/v2/device/" + view.StringID() + "/tags" + + // A devices:core token granted only tag:ci. + _, secret := createClient(t, baseURL, admin, []string{"devices:core"}, []string{"tag:ci"}) + tok := tokenFor(t, baseURL, secret) + + // In-grant tag is allowed. + st, body := apiPost(t, devURL, tok, map[string]any{"tags": []string{"tag:ci"}}) + assert.Equalf(t, http.StatusOK, st, "in-grant tag: %s", body) + + // An existing policy tag outside the token's grant is denied, not silently set. + st, _ = apiPost(t, devURL, tok, map[string]any{"tags": []string{"tag:other"}}) + assert.Equal(t, http.StatusForbidden, st, "out-of-grant tag must be denied") + + // An admin API key is unrestricted. + st, body = apiPost(t, devURL, admin, map[string]any{"tags": []string{"tag:other"}}) + assert.Equalf(t, http.StatusOK, st, "admin may set any policy tag: %s", body) +} + +// TestAPIv2OAuth_UndefinedTagRejected asserts an OAuth token cannot create a +// client or auth key carrying a tag absent from policy (matching SetNodeTags), +// while an admin key retains the historical syntax-only validation. +func TestAPIv2OAuth_UndefinedTagRejected(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + // A token that may create clients and auth keys, holding tag:ci (in policy). + _, secret := createClient(t, baseURL, admin, []string{"oauth_keys", "auth_keys"}, []string{"tag:ci"}) + tok := tokenFor(t, baseURL, secret) + + // A token-created auth key with a tag not in policy is rejected. + assert.Equal(t, http.StatusBadRequest, createTaggedKey(t, baseURL, tok, []string{"tag:undefined"}), + "token may not create an auth key with an undefined tag") + + // A token-created client with a tag not in policy is rejected. + st, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", tok, apiv2.CreateKeyRequest{ + KeyType: "client", Scopes: []string{"auth_keys"}, Tags: []string{"tag:undefined"}, + }) + assert.Equal(t, http.StatusBadRequest, st, "token may not create a client with an undefined tag") + + // An admin key keeps the historical behaviour: a syntactically valid but + // undefined tag is accepted (consistent with the v1/CLI pre-auth-key path). + assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, admin, []string{"tag:adminhistorical"}), + "admin retains syntax-only tag validation") +} + +// TestAPIv2OAuth_DenialBranches covers the token-endpoint and bearer-dispatch +// failure paths: each must fail closed with the right status and no session. +func TestAPIv2OAuth_DenialBranches(t *testing.T) { + _, baseURL, _ := newOAuthTestServer(t) + tokenURL := baseURL + "/api/v2/oauth/token" + keysURL := baseURL + "/api/v2/tailnet/-/keys" + + post := func(form string) (int, map[string]any) { + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, tokenURL, strings.NewReader(form)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + data, _ := io.ReadAll(resp.Body) + + var m map[string]any + + _ = json.Unmarshal(data, &m) + + return resp.StatusCode, m + } + + // No credentials → invalid_client. + st, m := post("grant_type=client_credentials") + assert.Equal(t, http.StatusUnauthorized, st) + assert.Equal(t, "invalid_client", m["error"]) + + // Wrong grant_type → unsupported_grant_type. + st, m = post("grant_type=authorization_code&client_secret=x") + assert.Equal(t, http.StatusBadRequest, st) + assert.Equal(t, "unsupported_grant_type", m["error"]) + + // Unknown secret → invalid_client. + st, m = post("grant_type=client_credentials&client_secret=not-a-real-secret") + assert.Equal(t, http.StatusUnauthorized, st) + assert.Equal(t, "invalid_client", m["error"]) + + // Bearer dispatch: every malformed/unknown bearer is unauthorized. + for _, bearer := range []string{ + "hskey-oauthtok-deadbeefdead-" + stringOf("0", 64), // well-formed prefix, unknown token + "hskey-oauthtok-garbage", // malformed OAuth token + "hskey-client-deadbeefdead-" + stringOf("0", 64), // client secret presented as API bearer + "totally-bogus", + } { + assert.Equalf(t, http.StatusUnauthorized, apiGet(t, keysURL, bearer), + "bearer %q must be unauthorized", bearer) + } +} + +// TestAPIv2OAuth_KeysMultiplexIsolation asserts the multiplexed keys endpoint +// keeps the two kinds isolated by scope: a token cannot list or delete a kind it +// lacks the scope for, and an admin key sees both. +func TestAPIv2OAuth_KeysMultiplexIsolation(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + keysURL := baseURL + "/api/v2/tailnet/-/keys" + + clientID, _ := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"}) + require.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, admin, []string{"tag:ci"})) + + listKinds := func(bearer string) map[string]int { + st, body := apiReq(t, http.MethodGet, keysURL, bearer, nil) + require.Equalf(t, http.StatusOK, st, "%s", body) + + var out struct { + Keys []apiv2.Key `json:"keys"` + } + + require.NoError(t, json.Unmarshal(body, &out)) + + kinds := map[string]int{} + for _, k := range out.Keys { + kinds[k.KeyType]++ + } + + return kinds + } + + _, akReadSecret := createClient(t, baseURL, admin, []string{"auth_keys:read"}, []string{"tag:ci"}) + akKinds := listKinds(tokenFor(t, baseURL, akReadSecret)) + assert.Zero(t, akKinds["client"], "auth_keys:read must not see OAuth clients") + assert.Positive(t, akKinds["auth"], "auth_keys:read sees auth keys") + + _, okReadSecret := createClient(t, baseURL, admin, []string{"oauth_keys:read"}, []string{"tag:ci"}) + okKinds := listKinds(tokenFor(t, baseURL, okReadSecret)) + assert.Zero(t, okKinds["auth"], "oauth_keys:read must not see auth keys") + assert.Positive(t, okKinds["client"], "oauth_keys:read sees OAuth clients") + + adminKinds := listKinds(admin) + assert.Positive(t, adminKinds["auth"]) + assert.Positive(t, adminKinds["client"]) + + // An auth_keys (write) token cannot delete an OAuth client; it survives. + _, akWriteSecret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"}) + stDel, _ := apiReq(t, http.MethodDelete, keysURL+"/"+clientID, tokenFor(t, baseURL, akWriteSecret), nil) + assert.NotEqual(t, http.StatusOK, stDel, "auth_keys token must not delete an OAuth client") + assert.Positive(t, listKinds(admin)["client"], "client survives the denied delete") + + // An oauth_keys (write) token deletes the client. + _, okWriteSecret := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"}) + stDel2, body2 := apiReq(t, http.MethodDelete, keysURL+"/"+clientID, tokenFor(t, baseURL, okWriteSecret), nil) + assert.Equalf(t, http.StatusOK, stDel2, "oauth_keys deletes the client: %s", body2) +} + +func stringOf(s string, n int) string { + return strings.Repeat(s, n) +} diff --git a/hscontrol/servertest/apiv2_oauth_scopes_test.go b/hscontrol/servertest/apiv2_oauth_scopes_test.go new file mode 100644 index 000000000..5d3cff67c --- /dev/null +++ b/hscontrol/servertest/apiv2_oauth_scopes_test.go @@ -0,0 +1,391 @@ +package servertest_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/juanfont/headscale/hscontrol/servertest" + "github.com/stretchr/testify/require" +) + +// TestAPIv2OAuthScopes proves the v2 OAuth scope and tag enforcement through the +// REAL Tailscale Terraform provider's client-credentials flow: the provider mints +// a scoped access token at /api/v2/oauth/token, then runs one operation whose +// allow/deny outcome must match the token's scope and tag grant. +// +// Each client is created with exactly the scopes/tags under test, and the +// provider requests no scope narrowing, so the minted token carries the client's +// full grant. The matrix covers right-scope-allowed, wrong-scope-denied, read vs +// write, the "all" super-scope, and policy tag ownership (owned-by delegation). +// +// The oauth_keys rows drive the real provider's tailscale_oauth_client resource, +// which omits the "capabilities" body field, so the request schema must make it +// optional (it is). Drift is not asserted on the auth-key rows: the provider +// defaults preauthorized=true on create but the server reads tagged keys back as +// false, so a converged plan is never empty. That is orthogonal to scope +// enforcement, so a clean apply is the allow proof. +// +// tofu is required, not optional: it ships in the nix dev shell, so a missing +// binary means a broken environment and the test fails rather than skipping. +func TestAPIv2OAuthScopes(t *testing.T) { + srv := servertest.NewServer(t, servertest.WithRealListener()) + owner := srv.CreateUser(t, "apiv2-oauth") + creator := owner.ID + + // scopeMatrixPolicy declares every tag the matrix touches: tag:ci for the + // auth-key rows, and the tag:k8s/tag:k8s-operator delegation for the + // owned-by rows. + setScopeMatrixPolicy(t, srv) + + // A registered node is the target for the device-scope rows. Its decimal id + // is the device id the v2 API and the provider's device resources address. + // A devices:core write token also grants devices:core:read, so whatever + // get/post sequence the provider runs within the core family is satisfied; + // the read-scope row then fails on the write, proving read vs write end to + // end. devices:routes and feature_settings are proven exhaustively by the Go + // matrix (apiv2_oauth_matrix_test.go), not here: the provider's routes + // resource issues a cross-family device read a routes-only token would lack, + // and the server's settings endpoint is read-only. + deviceID := srv.CreateRegisteredNode(t, owner).StringID() + deviceAuthorizeConfig := ` +resource "tailscale_device_authorization" "d" { + device_id = "` + deviceID + `" + authorized = true +} +` + + // The configs each exercise exactly one operation so a row's allow/deny + // outcome is unambiguous. + const ( + authKeyConfig = ` +resource "tailscale_tailnet_key" "k" { + reusable = true + ephemeral = false + expiry = 3600 + description = "oauth-scope-matrix" + tags = ["tag:ci"] +} +` + aclConfig = ` +resource "tailscale_acl" "policy" { + acl = jsonencode({ + tagOwners = { + "tag:ci" = ["apiv2-oauth@"] + "tag:k8s-operator" = [] + "tag:k8s" = ["tag:k8s-operator"] + "tag:other" = [] + } + acls = [{ action = "accept", src = ["*"], dst = ["*:*"] }] + }) + overwrite_existing_content = true +} +` + devicesReadConfig = ` +data "tailscale_devices" "all" {} +output "device_count" { value = length(data.tailscale_devices.all.devices) } +` + // oauthClientConfig creates an OAuth client whose scope (oauth_keys:read) + // is within an oauth_keys grant, so the only variable under test is whether + // the caller may manage clients at all. + oauthClientConfig = ` +resource "tailscale_oauth_client" "c" { + description = "oauth-scope-matrix-client" + scopes = ["oauth_keys:read"] +} +` + // oauthClientEscalateConfig requests a scope (devices:core:read) that an + // oauth_keys-only caller does not itself hold, so the escalation guard must + // reject minting a broader client. + oauthClientEscalateConfig = ` +resource "tailscale_oauth_client" "c" { + description = "oauth-scope-matrix-escalate" + scopes = ["devices:core:read"] +} +` + ) + + // k8sAuthKeyConfig templates the auth-key tag for the owned-by rows. + k8sAuthKeyConfig := func(tag string) string { + return ` +resource "tailscale_tailnet_key" "k" { + reusable = true + ephemeral = false + expiry = 3600 + description = "oauth-scope-matrix" + tags = ["` + tag + `"] +} +` + } + + tests := []struct { + name string + // scopes/tags the OAuth client (and thus its minted token) holds. + scopes []string + tags []string + // config is the single-operation HCL applied through the OAuth provider. + config string + // deny asserts apply fails; denyContains lists substrings, any of which + // the failure output must contain. allow asserts a clean apply. + deny bool + denyContains []string + }{ + // auth_keys: write scope mints an auth key; read/wrong/all-read do not. + { + name: "auth_keys allows tailnet_key", + scopes: []string{"auth_keys"}, + tags: []string{"tag:ci"}, + config: authKeyConfig, + }, + { + name: "auth_keys:read denies tailnet_key", + scopes: []string{"auth_keys:read"}, + tags: []string{"tag:ci"}, + config: authKeyConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + { + name: "devices:core denies tailnet_key", + scopes: []string{"devices:core"}, + tags: []string{"tag:ci"}, + config: authKeyConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + { + name: "all allows tailnet_key", + scopes: []string{"all"}, + tags: []string{"tag:ci"}, + config: authKeyConfig, + }, + { + name: "all:read denies tailnet_key", + scopes: []string{"all:read"}, + tags: []string{"tag:ci"}, + config: authKeyConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + + // oauth_keys: managing OAuth clients. Read and wrong scopes are denied, and + // a caller cannot mint a client carrying authority it lacks (escalation). + { + name: "oauth_keys allows oauth_client", + scopes: []string{"oauth_keys"}, + config: oauthClientConfig, + }, + { + name: "oauth_keys:read denies oauth_client", + scopes: []string{"oauth_keys:read"}, + config: oauthClientConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + { + name: "auth_keys denies oauth_client", + scopes: []string{"auth_keys"}, + tags: []string{"tag:ci"}, + config: oauthClientConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + { + name: "oauth_keys cannot escalate client scope", + scopes: []string{"oauth_keys"}, + config: oauthClientEscalateConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "beyond the creating token"}, + }, + + // policy_file: write scope sets the ACL; read does not. + { + name: "policy_file allows acl", + scopes: []string{"policy_file"}, + config: aclConfig, + }, + { + name: "policy_file:read denies acl", + scopes: []string{"policy_file:read"}, + config: aclConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + + // devices:core:read reads the devices data source. + { + name: "devices:core:read allows devices read", + scopes: []string{"devices:core:read"}, + config: devicesReadConfig, + }, + { + name: "policy_file:read denies devices read", + scopes: []string{"policy_file:read"}, + config: devicesReadConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + + // devices:core: a write-scoped token authorizes a device; the read scope + // and a wrong-family scope are denied (read vs write through the real + // provider's tailscale_device_authorization resource). + { + name: "devices:core allows device authorize", + scopes: []string{"devices:core"}, + tags: []string{"tag:ci"}, + config: deviceAuthorizeConfig, + }, + { + name: "devices:core:read denies device authorize", + scopes: []string{"devices:core:read"}, + config: deviceAuthorizeConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + { + name: "policy_file denies device authorize", + scopes: []string{"policy_file"}, + config: deviceAuthorizeConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + + // Tag owned-by: a tag:k8s-operator client may use its own tag and the + // tag:k8s it owns, but not an unowned tag. + { + name: "owned-by exact tag allowed", + scopes: []string{"auth_keys"}, + tags: []string{"tag:k8s-operator"}, + config: k8sAuthKeyConfig("tag:k8s-operator"), + }, + { + name: "owned-by delegated tag allowed", + scopes: []string{"auth_keys"}, + tags: []string{"tag:k8s-operator"}, + config: k8sAuthKeyConfig("tag:k8s"), + }, + { + name: "owned-by unowned tag denied", + scopes: []string{"auth_keys"}, + tags: []string{"tag:k8s-operator"}, + config: k8sAuthKeyConfig("tag:other"), + deny: true, + denyContains: []string{"403", "Forbidden", "is not owned"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + secret, client, err := srv.State().CreateOAuthClient(tt.scopes, tt.tags, "scope-matrix", &creator) + require.NoError(t, err) + + tf := newTofuOAuth(t, srv.URL, client.ClientID, secret, oauthHCL(tt.config)) + tf.run("init", "-no-color", "-input=false") + + if tt.deny { + tf.runExpectError(t, tt.denyContains...) + return + } + + tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + }) + } +} + +// setScopeMatrixPolicy installs the policy the scope matrix relies on: tag:ci for +// the auth-key rows, and the tag:k8s-operator → tag:k8s delegation for the +// owned-by rows. +func setScopeMatrixPolicy(t *testing.T, srv *servertest.TestServer) { + t.Helper() + + // tag:other exists but is owned by no one, so the owned-by denial row tests a + // grant denial (403) rather than a tag-not-in-policy rejection (400). + const policy = `{"tagOwners":{"tag:ci":["apiv2-oauth@"],"tag:k8s-operator":[],"tag:k8s":["tag:k8s-operator"],"tag:other":[]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + + st := srv.State() + + _, err := st.SetPolicy([]byte(policy)) + require.NoError(t, err) + + _, err = st.SetPolicyInDB(policy) + require.NoError(t, err) + + _, err = st.ReloadPolicy() + require.NoError(t, err) +} + +// oauthHCL wraps a single-operation body with the provider block. The provider +// authenticates via the OAuth env vars newTofuOAuth sets, so the block is empty. +func oauthHCL(body string) string { + return ` +terraform { + required_providers { + tailscale = { + source = "tailscale/tailscale" + version = "~> 0.21" + } + } +} + +provider "tailscale" {} +` + body +} + +// newTofuOAuth is a newTofu variant whose provider authenticates with OAuth +// client credentials instead of an API key: it sets TAILSCALE_OAUTH_CLIENT_ID / +// TAILSCALE_OAUTH_CLIENT_SECRET (the env vars the tailscale/tailscale provider +// honors), so the real provider runs the client-credentials grant against +// baseURL/api/v2/oauth/token. The client id is embedded in the secret, so the +// secret embeds the client id (the provider sends both; the server derives the +// client from the secret alone). +func newTofuOAuth(t *testing.T, baseURL, clientID, clientSecret, config string) *tofu { + t.Helper() + + bin, err := exec.LookPath("tofu") + require.NoErrorf(t, err, "tofu is required for TestAPIv2OAuthScopes (provided by the nix dev shell)") + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(config), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "plugin-cache"), 0o755)) + + env := append( + os.Environ(), + "TAILSCALE_BASE_URL="+baseURL, + "TAILSCALE_OAUTH_CLIENT_ID="+clientID, + "TAILSCALE_OAUTH_CLIENT_SECRET="+clientSecret, + "TAILSCALE_TAILNET=-", + // Keep provider plugins inside the temp dir so the run is self-contained. + "TF_PLUGIN_CACHE_DIR="+filepath.Join(dir, "plugin-cache"), + ) + + cmd := func(args ...string) *exec.Cmd { + c := exec.CommandContext(t.Context(), bin, args...) + c.Dir = dir + c.Env = env + + return c + } + + return &tofu{t: t, cmd: cmd} +} + +// runExpectError runs apply expecting FAILURE, asserting the combined output +// contains at least one of mustContain. This is the deny half of every matrix +// row: a scoped token attempting an operation it lacks the scope (or tag) for. +func (tf *tofu) runExpectError(t *testing.T, mustContain ...string) { + t.Helper() + + out, err := tf.cmd("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1").CombinedOutput() + require.Errorf(t, err, "expected apply to fail, but it succeeded:\n%s", out) + + combined := string(out) + for _, want := range mustContain { + if strings.Contains(combined, want) { + return + } + } + + t.Fatalf("apply failed but output contained none of %v:\n%s", mustContain, combined) +} diff --git a/integration/cli_oauth_clients_test.go b/integration/cli_oauth_clients_test.go new file mode 100644 index 000000000..8c6a11d63 --- /dev/null +++ b/integration/cli_oauth_clients_test.go @@ -0,0 +1,126 @@ +package integration + +import ( + "encoding/json" + "testing" + "time" + + "github.com/juanfont/headscale/integration/hsic" + "github.com/juanfont/headscale/integration/integrationutil" + "github.com/juanfont/headscale/integration/tsic" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// cliOAuthClient is the JSON the `oauth-clients` commands emit (the v2 Key shape, +// only the fields the test asserts on). +type cliOAuthClient struct { + ID string `json:"id"` + Key string `json:"key"` + KeyType string `json:"keyType"` + Scopes []string `json:"scopes"` + Tags []string `json:"tags"` + Description string `json:"description"` +} + +// TestOAuthClientCommand exercises the `headscale oauth-clients` CLI end to end: +// create (with scopes and tags) -> list (secret hidden) -> delete. The CLI talks +// to the v2 keys handler over the local unix socket, so this also proves the v2 +// API is reachable over the socket with local trust. +func TestOAuthClientCommand(t *testing.T) { + IntegrationSkip(t) + + scenario, err := NewScenario(ScenarioSpec{Users: []string{}}) + require.NoError(t, err) + + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("cli-oauthclient")) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + // Create an OAuth client with scopes and a tag. devices:core/auth_keys + // require a tag, which is supplied. + createOut, err := headscale.Execute([]string{ + "headscale", "oauth-clients", "create", + "--scope", "auth_keys", + "--scope", "devices:core", + "--tag", "tag:k8s-operator", + "--description", "operator", + "--output", "json", + }) + require.NoError(t, err) + + var created cliOAuthClient + require.NoError(t, json.Unmarshal([]byte(createOut), &created)) + assert.Equal(t, "client", created.KeyType) + assert.NotEmpty(t, created.ID, "client id returned") + assert.NotEmpty(t, created.Key, "secret returned once on create") + assert.ElementsMatch(t, []string{"auth_keys", "devices:core"}, created.Scopes) + assert.Equal(t, []string{"tag:k8s-operator"}, created.Tags) + + // List shows the client without its secret. + var listed []cliOAuthClient + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err := executeAndUnmarshal(headscale, + []string{"headscale", "oauth-clients", "list", "--output", "json"}, &listed) + assert.NoError(c, err) + assert.Len(c, listed, 1) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "waiting for oauth client list") + + assert.Equal(t, created.ID, listed[0].ID) + assert.Empty(t, listed[0].Key, "secret is never exposed on list") + assert.ElementsMatch(t, []string{"auth_keys", "devices:core"}, listed[0].Scopes) + assert.Equal(t, "operator", listed[0].Description) + + // Delete it. + _, err = headscale.Execute([]string{"headscale", "oauth-clients", "delete", "--id", created.ID}) + require.NoError(t, err) + + var afterDelete []cliOAuthClient + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err := executeAndUnmarshal(headscale, + []string{"headscale", "oauth-clients", "list", "--output", "json"}, &afterDelete) + assert.NoError(c, err) + assert.Empty(c, afterDelete) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "waiting for oauth client list after delete") +} + +// TestOAuthClientCommandValidation covers the CLI's input validation and the +// server's 404 on deleting an unknown client. +func TestOAuthClientCommandValidation(t *testing.T) { + IntegrationSkip(t) + + scenario, err := NewScenario(ScenarioSpec{Users: []string{}}) + require.NoError(t, err) + + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("cli-oauthclientval")) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + tests := []struct { + name string + args []string + wantErr string + }{ + {name: "no scope", args: []string{"oauth-clients", "create"}, wantErr: "at least one --scope is required"}, + {name: "devices:core needs tag", args: []string{"oauth-clients", "create", "--scope", "devices:core"}, wantErr: "tags are required"}, + {name: "delete no id", args: []string{"oauth-clients", "delete"}, wantErr: "--id is required"}, + {name: "delete nonexistent id", args: []string{"oauth-clients", "delete", "--id", "doesnotexist"}, wantErr: "404"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := headscale.Execute(append([]string{"headscale"}, tt.args...)) + require.ErrorContains(t, err, tt.wantErr) + }) + } +} From 79bf201ccb8401d4c5cdfbf177244a4845398939 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sun, 21 Jun 2026 17:35:57 +0000 Subject: [PATCH 107/127] changelog: note OAuth clients and scopes --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26469eaf7..0c4028cbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,18 @@ HTTP API directly. [#3324](https://github.com/juanfont/headscale/pull/3324) +### OAuth clients and scopes for the v2 API + +The v2 API now authenticates with OAuth 2.0 client-credentials, the way the +Tailscale ecosystem does. An OAuth client mints short-lived access tokens whose +scopes limit which operations they may perform and whose tags limit the devices +they may create, so a credential can be issued with only the access it needs. +The `headscale oauth-clients` command manages them. This lets the Tailscale +Terraform provider and Kubernetes operator drive Headscale unchanged; admin API +keys remain all-access. + +[#3334](https://github.com/juanfont/headscale/pull/3334) + ### BREAKING #### API From 622e08f5e6a346ad6011c79915004644bdea2905 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 24 Jun 2026 12:45:47 +0000 Subject: [PATCH 108/127] oidc: harden callback CSRF cookies, state reuse, and issuer config Defence-in-depth fixes to the OIDC login flow. Set the state/nonce cookie Secure flag from the configured server_url scheme rather than req.TLS, so the cookies stay Secure behind a TLS-terminating reverse proxy where the proxy-to-Headscale hop is plain HTTP. Deriving it from config avoids trusting a spoofable X-Forwarded-Proto header. Make the OIDC state single-use: consume it from the cache on the callback and clear the state/nonce cookies once validated, so a replayed callback cannot resolve the same session and the cookies do not linger until expiry. Bound OIDC discovery to the caller's context so a slow or unreachable issuer fails startup within the timeout instead of hanging, and validate the issuer URL and required client_id/client_secret at config load so an unworkable setup fails fast. --- hscontrol/oidc.go | 54 ++++++++++++++++++++------ hscontrol/oidc_test.go | 70 +++++++++++++++++++++++++++++++++- hscontrol/types/config.go | 39 +++++++++++++++++-- hscontrol/types/config_test.go | 67 ++++++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 17 deletions(-) diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index d432a052b..bd660e4d1 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -85,8 +85,8 @@ func NewAuthProviderOIDC( serverURL string, cfg *types.OIDCConfig, ) (*AuthProviderOIDC, error) { - var err error - // grab oidc config if it hasn't been already + // Use the caller's context (bounded, see app.go) so a slow or unreachable + // issuer fails discovery within the timeout instead of hanging startup. oidcProvider, err := oidc.NewProvider(ctx, cfg.Issuer) if err != nil { return nil, fmt.Errorf("creating OIDC provider from issuer config: %w", err) @@ -117,6 +117,15 @@ func NewAuthProviderOIDC( }, nil } +// cookiesSecure reports whether the OIDC cookies should carry the Secure flag. +// It keys off the configured server_url scheme, not req.TLS, so cookies stay +// Secure behind a TLS-terminating reverse proxy (where the proxy→Headscale hop +// is plain HTTP and req.TLS is nil). Deriving it from config avoids trusting a +// spoofable X-Forwarded-Proto header. +func (a *AuthProviderOIDC) cookiesSecure() bool { + return strings.HasPrefix(a.serverURL, "https://") +} + func (a *AuthProviderOIDC) AuthURL(authID types.AuthID) string { return authPathURL(a.serverURL, "auth", authID) } @@ -156,10 +165,10 @@ func (a *AuthProviderOIDC) authHandler( } // Set the state and nonce cookies to protect against CSRF attacks - state := setCSRFCookie(writer, req, "state") + state := setCSRFCookie(writer, req, "state", a.cookiesSecure()) // Set the state and nonce cookies to protect against CSRF attacks - nonce := setCSRFCookie(writer, req, "nonce") + nonce := setCSRFCookie(writer, req, "nonce", a.cookiesSecure()) registrationInfo := AuthInfo{ AuthID: authID, @@ -263,6 +272,11 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler( return } + // The state/nonce cookies have served their CSRF purpose; clear them so a + // single-use pair does not linger in the browser until MaxAge. + clearOIDCCallbackCookie(writer, stateCookieName) + clearOIDCCallbackCookie(writer, nonceCookieName) + nodeExpiry := a.determineNodeExpiry(idToken.Expiry) var claims types.OIDCClaims @@ -589,13 +603,17 @@ func doOIDCAuthorization( return nil } -// getAuthInfoFromState retrieves the registration ID from the state. +// getAuthInfoFromState retrieves and consumes the auth info for a state. The +// entry is removed on read so a state is single-use: a replayed callback cannot +// resolve the same auth session twice, even within the cache TTL. func (a *AuthProviderOIDC) getAuthInfoFromState(state string) *AuthInfo { authInfo, ok := a.authCache.Get(state) if !ok { return nil } + a.authCache.Remove(state) + return &authInfo } @@ -658,14 +676,15 @@ func setRegisterConfirmCookie( authID types.AuthID, value string, maxAge int, + secure bool, ) { - //nolint:gosec // G124: Secure set conditionally via req.TLS; HttpOnly + SameSite already set + //nolint:gosec // G124: Secure from server_url scheme or req.TLS; HttpOnly + SameSite already set http.SetCookie(writer, &http.Cookie{ Name: registerConfirmCSRFCookie, Value: value, Path: "/register/confirm/" + authID.String(), MaxAge: maxAge, - Secure: req.TLS != nil, + Secure: secure || req.TLS != nil, HttpOnly: true, SameSite: http.SameSiteStrictMode, }) @@ -707,7 +726,7 @@ func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial( CSRF: csrf, }) - setRegisterConfirmCookie(writer, req, authID, csrf, int(authCacheExpiration.Seconds())) + setRegisterConfirmCookie(writer, req, authID, csrf, int(authCacheExpiration.Seconds()), a.cookiesSecure()) regData := authReq.RegistrationData() @@ -824,7 +843,7 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler( } // Clear the CSRF cookie now that the registration is final. - setRegisterConfirmCookie(writer, req, authID, "", -1) + setRegisterConfirmCookie(writer, req, authID, "", -1, a.cookiesSecure()) content := renderRegistrationSuccessTemplate(user, newNode) @@ -920,16 +939,27 @@ func getCookieName(baseName, value string) string { return fmt.Sprintf("%s_%s", baseName, value[:n]) } -func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) string { +// clearOIDCCallbackCookie expires a /oidc/callback cookie by name. Matching the +// path the cookie was set with is required for the browser to drop it. +func clearOIDCCallbackCookie(w http.ResponseWriter, name string) { + //nolint:gosec // G124: a deletion cookie (empty value, MaxAge<0); security attributes are moot + http.SetCookie(w, &http.Cookie{ + Name: name, + Path: "/oidc/callback", + MaxAge: -1, + }) +} + +func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string, secure bool) string { val := rands.HexString(64) - //nolint:gosec // G124: Secure set conditionally via r.TLS; HttpOnly + SameSite set below + //nolint:gosec // G124: Secure from server_url scheme or req.TLS; HttpOnly + SameSite set below c := &http.Cookie{ Path: "/oidc/callback", Name: getCookieName(name, val), Value: val, MaxAge: int(time.Hour.Seconds()), - Secure: r.TLS != nil, + Secure: secure || r.TLS != nil, HttpOnly: true, // Lax, not Strict: the OIDC callback is a cross-site top-level GET // redirect from the IdP that must still carry this cookie. Strict diff --git a/hscontrol/oidc_test.go b/hscontrol/oidc_test.go index 2c5814078..04d331649 100644 --- a/hscontrol/oidc_test.go +++ b/hscontrol/oidc_test.go @@ -4,7 +4,9 @@ import ( "net/http" "net/http/httptest" "testing" + "time" + "github.com/hashicorp/golang-lru/v2/expirable" "github.com/juanfont/headscale/hscontrol/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -186,10 +188,76 @@ func TestSetCSRFCookieSameSite(t *testing.T) { w := httptest.NewRecorder() r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/abcdef0123456789", nil) - setCSRFCookie(w, r, "state") + setCSRFCookie(w, r, "state", false) cookies := w.Result().Cookies() require.Len(t, cookies, 1) assert.Equal(t, http.SameSiteLaxMode, cookies[0].SameSite, "OIDC CSRF cookie must explicitly set SameSite=Lax") } + +// TestExtractCodeAndStateParam covers the callback's first trust-boundary +// checks: both params required, and a too-short state is rejected before +// getCookieName can slice out of range. +func TestExtractCodeAndStateParam(t *testing.T) { + _, _, err := extractCodeAndStateParamFromRequest( + httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback", nil)) + require.Error(t, err) + + _, _, err = extractCodeAndStateParamFromRequest( + httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback?code=c&state=abc", nil)) + require.ErrorIs(t, err, errOIDCStateTooShort) + + code, state, err := extractCodeAndStateParamFromRequest( + httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback?code=c&state=abcdef0123", nil)) + require.NoError(t, err) + assert.Equal(t, "c", code) + assert.Equal(t, "abcdef0123", state) +} + +// TestGetAuthInfoFromStateSingleUse asserts a consumed OIDC state cannot be +// resolved twice, so a replayed callback cannot re-bind the same session. +func TestGetAuthInfoFromStateSingleUse(t *testing.T) { + a := &AuthProviderOIDC{ + authCache: expirable.NewLRU[string, AuthInfo](16, nil, time.Minute), + } + a.authCache.Add("state-x", AuthInfo{Registration: true}) + + got := a.getAuthInfoFromState("state-x") + require.NotNil(t, got) + assert.True(t, got.Registration) + + assert.Nil(t, a.getAuthInfoFromState("state-x"), "a consumed state must not resolve again") +} + +// TestClearOIDCCallbackCookie asserts the cookie is expired (negative MaxAge) on +// the same path it was set with, so the browser drops it. +func TestClearOIDCCallbackCookie(t *testing.T) { + w := httptest.NewRecorder() + clearOIDCCallbackCookie(w, "state_abcdef") + + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + assert.Equal(t, "state_abcdef", cookies[0].Name) + assert.Negative(t, cookies[0].MaxAge, "deletion cookie must have negative MaxAge") +} + +// TestSetCSRFCookieSecure verifies the Secure flag is driven by the secure +// argument (derived from the configured https server_url), not only req.TLS, so +// cookies stay Secure behind a TLS-terminating reverse proxy where req.TLS is +// nil. +func TestSetCSRFCookieSecure(t *testing.T) { + r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/abcdef0123456789", nil) + + secureRec := httptest.NewRecorder() + setCSRFCookie(secureRec, r, "state", true) + require.Len(t, secureRec.Result().Cookies(), 1) + assert.True(t, secureRec.Result().Cookies()[0].Secure, + "https server_url must set Secure even when req.TLS is nil (proxy case)") + + plainRec := httptest.NewRecorder() + setCSRFCookie(plainRec, r, "state", false) + require.Len(t, plainRec.Result().Cookies(), 1) + assert.False(t, plainRec.Result().Cookies()[0].Secure, + "plain-http server_url without req.TLS must not set Secure") +} diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 4ef0ba343..4f0b4ecfa 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -33,6 +33,9 @@ const ( var ( errOidcMutuallyExclusive = errors.New("oidc_client_secret and oidc_client_secret_path are mutually exclusive") + errOIDCIssuerInvalid = errors.New("oidc.issuer must be a valid http(s) URL") + errOIDCClientIDRequired = errors.New("oidc.client_id is required when oidc.issuer is set") + errOIDCClientSecretRequired = errors.New("oidc.client_secret or oidc.client_secret_path is required when oidc.issuer is set") errServerURLSuffix = errors.New("server_url cannot be part of base_domain in a way that could make the DERP and headscale server unreachable") errServerURLSame = errors.New("server_url cannot use the same domain as base_domain in a way that could make the DERP and headscale server unreachable") errInvalidPKCEMethod = errors.New("pkce.method must be either 'plain' or 'S256'") @@ -363,6 +366,35 @@ func validatePKCEMethod(method string) error { return nil } +// validateOIDCConfig validates the OIDC settings, called when oidc.issuer is +// set. It fails fast on a setup that cannot work: an invalid PKCE method, a +// malformed issuer URL (which would otherwise surface as an opaque discovery +// error or, worse, resolve to an unintended provider), or a missing client +// id/secret. +func validateOIDCConfig() error { + err := validatePKCEMethod(viper.GetString("oidc.pkce.method")) + if err != nil { + return err + } + + issuer := viper.GetString("oidc.issuer") + + u, err := url.Parse(issuer) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" { + return fmt.Errorf("%w: got %q", errOIDCIssuerInvalid, issuer) + } + + if viper.GetString("oidc.client_id") == "" { + return errOIDCClientIDRequired + } + + if viper.GetString("oidc.client_secret") == "" && viper.GetString("oidc.client_secret_path") == "" { + return errOIDCClientSecretRequired + } + + return nil +} + // Domain returns the hostname/domain part of the [Config.ServerURL]. // If the [Config.ServerURL] is not a valid URL, it returns the [Config.BaseDomain]. func (c *Config) Domain() string { @@ -564,11 +596,10 @@ func validateServerConfig() error { depr.fatalIfSet("oidc.expiry", "node.expiry") // OIDC is activated by setting oidc.issuer (see app.go), not by a - // dedicated oidc.enabled key. Gate PKCE method validation on the real - // activation condition so an invalid method fails at startup instead of - // silently disabling PKCE at runtime. + // dedicated oidc.enabled key. Gate validation on the real activation + // condition so a misconfiguration fails at startup. if viper.GetString("oidc.issuer") != "" { - err := validatePKCEMethod(viper.GetString("oidc.pkce.method")) + err := validateOIDCConfig() if err != nil { return err } diff --git a/hscontrol/types/config_test.go b/hscontrol/types/config_test.go index d9acf2535..d0859758c 100644 --- a/hscontrol/types/config_test.go +++ b/hscontrol/types/config_test.go @@ -444,6 +444,73 @@ oidc: assert.Contains(t, err.Error(), errInvalidPKCEMethod.Error()) } +// TestOIDCConfigValidation covers the issuer-URL and required-field checks that +// fail an unworkable OIDC setup fast at config load. +func TestOIDCConfigValidation(t *testing.T) { + tests := []struct { + name string + oidcBlock string + wantErr string + }{ + { + name: "non-http issuer", + oidcBlock: ` + issuer: ftp://idp.example.com + client_id: headscale + client_secret: sekret`, + wantErr: "valid http(s) URL", + }, + { + name: "missing client_id", + oidcBlock: ` + issuer: https://idp.example.com + client_secret: sekret`, + wantErr: "client_id is required", + }, + { + name: "missing client_secret", + oidcBlock: ` + issuer: https://idp.example.com + client_id: headscale`, + wantErr: "client_secret", + }, + { + name: "valid", + oidcBlock: ` + issuer: https://idp.example.com + client_id: headscale + client_secret: sekret`, + wantErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + configYaml := []byte(`--- +noise: + private_key_path: noise_private.key +server_url: http://127.0.0.1:8080 +dns: + override_local_dns: false +oidc:` + tt.oidcBlock + "\n") + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), configYaml, 0o600)) + require.NoError(t, LoadConfig(tmpDir, false)) + + err := validateServerConfig() + if tt.wantErr == "" { + require.NoError(t, err) + + return + } + + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + // OK // server_url: headscale.com, base: clients.headscale.com // server_url: headscale.com, base: headscale.net From 1867213b69badaa6ebe3c9574de69acf2bdecb30 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 26 Jun 2026 13:16:36 +0000 Subject: [PATCH 109/127] capver: order TailscaleLatestMajorMinor versions with cmpver Lexical sort placed v1.100 before v1.98; cmpver compares numerically. --- hscontrol/capver/capver.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/hscontrol/capver/capver.go b/hscontrol/capver/capver.go index 6ff102b15..a4c535e88 100644 --- a/hscontrol/capver/capver.go +++ b/hscontrol/capver/capver.go @@ -5,10 +5,10 @@ package capver import ( "maps" "slices" - "sort" "strings" "tailscale.com/tailcfg" + "tailscale.com/util/cmpver" "tailscale.com/util/set" ) @@ -74,7 +74,9 @@ func TailscaleLatestMajorMinor(n int, stripV bool) []string { } majorSl := majors.Slice() - sort.Strings(majorSl) + // cmpver orders versions numerically, so v1.100 sorts after v1.98 rather than + // lexically before it. + slices.SortFunc(majorSl, cmpver.Compare) if n > len(majorSl) { return majorSl From a4ec76605e1b45e5e01f52545c3b7eab34a016ba Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 26 Jun 2026 13:16:43 +0000 Subject: [PATCH 110/127] integration/tsic: add WithDERPOverHTTP for the non-TLS embedded DERP Sets TS_DEBUG_DERP_WS_CLIENT + TS_DEBUG_USE_DERP_HTTP so a client can reach hsic's plain-HTTP embedded DERP over websockets; the counterpart to hsic.WithoutTLS. --- integration/tsic/tsic.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/integration/tsic/tsic.go b/integration/tsic/tsic.go index 901a263e9..3e19d2b14 100644 --- a/integration/tsic/tsic.go +++ b/integration/tsic/tsic.go @@ -98,6 +98,7 @@ type TailscaleInContainer struct { caCerts [][]byte headscaleHostname string withWebsocketDERP bool + withDERPOverHTTP bool withSSH bool withTags []string withEntrypoint []string @@ -160,6 +161,18 @@ func WithWebsocketDERP(enabled bool) Option { } } +// WithDERPOverHTTP makes the client reach the DERP server over plain-HTTP +// websockets (TS_DEBUG_DERP_WS_CLIENT + TS_DEBUG_USE_DERP_HTTP). It is the +// counterpart to [hsic.WithoutTLS]: a Headscale serving its embedded DERP without +// TLS is otherwise unreachable, because the client defaults to dialing DERP over +// HTTPS. +func WithDERPOverHTTP() Option { + return func(tsic *TailscaleInContainer) { + tsic.withWebsocketDERP = true + tsic.withDERPOverHTTP = true + } +} + // WithSSH enables SSH for the Tailscale instance. func WithSSH() Option { return func(tsic *TailscaleInContainer) { @@ -372,6 +385,12 @@ func New( tailscaleOptions.Env, fmt.Sprintf("TS_DEBUG_DERP_WS_CLIENT=%t", tsic.withWebsocketDERP), ) + + // Plain-HTTP DERP additionally needs the client to dial http:// instead of + // the default https://; see [WithDERPOverHTTP]. + if tsic.withDERPOverHTTP { + tailscaleOptions.Env = append(tailscaleOptions.Env, "TS_DEBUG_USE_DERP_HTTP=true") + } } tailscaleOptions.ExtraHosts = append(tailscaleOptions.ExtraHosts, From c7b162509ce5bef40ea451d76398a6e2ec879881 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 26 Jun 2026 13:16:49 +0000 Subject: [PATCH 111/127] integration/hsic: add CreateOAuthClient via the v2 API client Mints an admin API key and creates a keyType=client OAuth credential through gen/client/v2, exposed on ControlServer. Reusable by tests needing OAuth client credentials. --- integration/control.go | 3 ++ integration/hsic/hsic.go | 86 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/integration/control.go b/integration/control.go index 2b2fc651f..f0b5611f7 100644 --- a/integration/control.go +++ b/integration/control.go @@ -1,6 +1,7 @@ package integration import ( + "context" "net/netip" clientv1 "github.com/juanfont/headscale/gen/client/v1" @@ -22,6 +23,8 @@ type ControlServer interface { ConnectToNetwork(network *dockertest.Network) error GetHealthEndpoint() string GetEndpoint() string + GetIPEndpoint() string + CreateOAuthClient(ctx context.Context, scopes, tags []string) (string, string, error) WaitForRunning() error Restart() error CreateUser(user string) (*clientv1.User, error) diff --git a/integration/hsic/hsic.go b/integration/hsic/hsic.go index ed5809921..d37bac9af 100644 --- a/integration/hsic/hsic.go +++ b/integration/hsic/hsic.go @@ -6,6 +6,7 @@ import ( "cmp" "context" "crypto/tls" + "crypto/x509" "encoding/json" "errors" "fmt" @@ -25,6 +26,7 @@ import ( "github.com/davecgh/go-spew/spew" clientv1 "github.com/juanfont/headscale/gen/client/v1" + clientv2 "github.com/juanfont/headscale/gen/client/v2" "github.com/juanfont/headscale/hscontrol" policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" "github.com/juanfont/headscale/hscontrol/types" @@ -1023,6 +1025,90 @@ func (t *HeadscaleInContainer) GetEndpoint() string { return t.getEndpoint(false) } +var errOAuthSecretMissing = errors.New(`OAuth client response missing secret in "key" field`) + +// CreateOAuthClient mints an admin API key and uses it to create an OAuth client +// via the v2 keys HTTP API (POST /api/v2/tailnet/-/keys, keyType=client), +// returning the client id and secret. The secret is only returned once, in the +// "key" field. It is a reusable building block for tests that need OAuth client +// credentials (such as the Kubernetes operator). +func (t *HeadscaleInContainer) CreateOAuthClient( + ctx context.Context, + scopes, tags []string, +) (string, string, error) { + apiKey, err := t.Execute([]string{"headscale", "apikeys", "create", "--expiration", "24h"}) + if err != nil { + return "", "", fmt.Errorf("creating admin api key: %w", err) + } + + apiKey = strings.TrimSpace(apiKey) + + client, err := clientv2.NewClientWithResponses( + t.GetEndpoint(), + clientv2.WithHTTPClient(t.httpClient()), + clientv2.WithRequestEditorFn(func(_ context.Context, req *http.Request) error { + req.Header.Set("Authorization", "Bearer "+apiKey) + + return nil + }), + ) + if err != nil { + return "", "", fmt.Errorf("building v2 API client: %w", err) + } + + keyType := "client" + + resp, err := client.CreateKeyWithResponse(ctx, "-", clientv2.CreateKeyRequest{ + KeyType: &keyType, + Scopes: &scopes, + Tags: &tags, + }) + if err != nil { + return "", "", fmt.Errorf("creating OAuth client: %w", err) + } + + if resp.JSON200 == nil { + return "", "", fmt.Errorf( //nolint:err113 + "creating OAuth client: status %s: %s", resp.Status(), strings.TrimSpace(string(resp.Body))) + } + + if resp.JSON200.Key == nil || *resp.JSON200.Key == "" { + return "", "", errOAuthSecretMissing + } + + // The operator expects clientId and clientSecret as separate values. When the + // server returns a single opaque credential, the client-credentials grant + // splits it on "-" (id-secret), matching the Tailscale SaaS shape. Fall back + // to the whole key as the secret when no id is given. + clientID, clientSecret := resp.JSON200.Id, *resp.JSON200.Key + + if clientID == "" { + if id, secret, ok := strings.Cut(*resp.JSON200.Key, "-"); ok { + clientID, clientSecret = id, secret + } + } + + return clientID, clientSecret, nil +} + +// httpClient returns an HTTP client that trusts this Headscale's TLS CA when TLS +// is enabled, or a default client when it serves plain HTTP. +func (t *HeadscaleInContainer) httpClient() *http.Client { + if !t.hasTLS() { + return &http.Client{Timeout: 30 * time.Second} + } + + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(t.tlsCACert) + + return &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + }, + } +} + // GetIPEndpoint returns the Headscale endpoint using IP address instead of hostname. func (t *HeadscaleInContainer) GetIPEndpoint() string { return t.getEndpoint(true) From 99fd62477deb4d48e6cb4695906268de804819e3 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 26 Jun 2026 13:17:08 +0000 Subject: [PATCH 112/127] integration: add a k3s and Tailscale Kubernetes operator test Run the real operator in a single-container k3s cluster against an in-test Headscale over plain HTTP. k3sic exposes reusable building blocks (InstallOperator, DeployConnector, DeployEchoServer, ExposeServiceToTailnet, DeployProxyGroup); the test covers operator registration, an egress connector, ingress connectivity from a tailnet node, and proxy groups. tls-ca-baking.md records the private-CA TLS variant. Updates #1202 --- integration/k3sic/k3sic.go | 603 +++++++++++++++++++++++++++++ integration/k3sic/operator.go | 243 ++++++++++++ integration/k3sic/tls-ca-baking.md | 70 ++++ integration/k8s_operator_test.go | 266 +++++++++++++ 4 files changed, 1182 insertions(+) create mode 100644 integration/k3sic/k3sic.go create mode 100644 integration/k3sic/operator.go create mode 100644 integration/k3sic/tls-ca-baking.md create mode 100644 integration/k8s_operator_test.go diff --git a/integration/k3sic/k3sic.go b/integration/k3sic/k3sic.go new file mode 100644 index 000000000..6dc805634 --- /dev/null +++ b/integration/k3sic/k3sic.go @@ -0,0 +1,603 @@ +// Package k3sic wraps a single-container k3s cluster (server + agent) as a +// privileged sibling container on the host docker daemon, like the +// DERP-in-container wrapper in the dsic package. +// +// It exists so that integration tests can install the real Tailscale +// Kubernetes operator (via its Helm chart) into a real Kubernetes cluster and +// point it at an in-test Headscale. k3s bundles kubectl and we run helm inside +// the container through Execute, so the host dev shell needs no kube tooling. +// +// The operator is pointed at Headscale over plain HTTP (see +// hsic.WithoutTLS), so the operator and proxy pods need no CA: there is no +// image baking and no CoreDNS hostname mapping. To run instead against a TLS +// Headscale with a private CA, see tls-ca-baking.md. +// +// The harness runs as sibling containers on the host docker daemon (the +// test-suite container has the host docker socket bind-mounted); it is NOT +// docker-in-docker. We therefore run the purpose-built single-container k3s image +// as one more privileged sibling joined to the scenario networks, rather than using +// k3d/kind which shell out to the docker daemon and fight the sibling model. +// Privileged is the harness-wide norm here, not a k3s-specific escalation: the +// tsic (client) and dsic (DERP) containers run privileged too (see +// dockertestutil.DockerAllowNetworkAdministration). +package k3sic + +import ( + "archive/tar" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "log" + "net/http" + "runtime" + "strings" + "time" + + "github.com/juanfont/headscale/hscontrol/capver" + "github.com/juanfont/headscale/integration/dockertestutil" + "github.com/juanfont/headscale/integration/integrationutil" + "github.com/ory/dockertest/v3" + "github.com/ory/dockertest/v3/docker" + "tailscale.com/util/rands" +) + +const ( + k3sicHashLength = 6 + + // K3sImage is the single-container k3s server+agent image, pinned on ghcr (no + // anonymous Docker Hub rate limit). Pinned rather than resolved from the k3s + // stable channel because that channel floats the k8s minor unpredictably and + // would outrun the cgroup-v2 and br_netfilter workarounds below. Bump by hand. + K3sImage = "ghcr.io/k3s-io/k3s:v1.35.5-k3s1" + + // operatorImageRepo and proxyImageRepo are the ghcr-hosted operator and + // proxy images. ghcr is used instead of Docker Hub to avoid anonymous pull + // rate limits; the pods pull these directly. + operatorImageRepo = "ghcr.io/tailscale/k8s-operator" + proxyImageRepo = "ghcr.io/tailscale/tailscale" + + dockerExecuteTimeout = 300 * time.Second + + // helmVersionFallback is used when the latest helm release cannot be + // resolved at runtime (see resolveHelmVersion). The image ships no helm and + // cannot fetch it itself, so we inject a binary that matches the container + // arch. + helmVersionFallback = "v3.19.1" + + // kubeconfigPath is where k3s writes the kubeconfig (see RunOptions.Env); + // helm needs it pointed explicitly, kubectl finds it by default. + kubeconfigPath = "/etc/rancher/k3s/k3s.yaml" + + // kubectlBin is the in-container kubectl the k3s image ships on PATH. + kubectlBin = "kubectl" + + // shellBin is the in-container shell used for compound commands. + shellBin = "/bin/sh" + + // tailscaleNamespace is where the operator and its proxies are installed. + tailscaleNamespace = "tailscale" + + // kubeSystemNamespace holds CoreDNS and the rest of the k3s system addons. + kubeSystemNamespace = "kube-system" +) + +var ( + errHelmDownload = errors.New("helm download failed") + errHelmNotInTarball = errors.New("helm binary not found in release tarball") + + errNoKubeDNSEndpoints = errors.New("kube-dns Service has no ready endpoints yet") +) + +// OperatorImageTag is the image tag the operator and proxy images use, derived +// from the Tailscale minor Headscale tracks via capver (e.g. "v1.98"). The +// operator shares the Tailscale release train, so this keeps the images and the +// Helm chart in lockstep with the client versions Headscale is tested against, +// without a hand-pinned constant. The tag is a rolling tag within the minor. +func OperatorImageTag() string { + return capver.TailscaleLatestMajorMinor(1, false)[0] +} + +// OperatorImage and ProxyImage are the ghcr operator/proxy image references the +// test wires into the Helm chart. +func OperatorImage() string { return operatorImageRepo + ":" + OperatorImageTag() } +func ProxyImage() string { return proxyImageRepo + ":" + OperatorImageTag() } + +// OperatorChartVersion is the Helm chart version constraint matching the derived +// minor; helm resolves the latest patch in that line. The top-level loginServer +// value the operator needs to target Headscale instead of the Tailscale SaaS +// first shipped in chart 1.98.4. +func OperatorChartVersion() string { + return strings.TrimPrefix(OperatorImageTag(), "v") + ".*" +} + +// K3sInContainer represents a k3s cluster running in a single privileged +// container (K3sInContainer, hence k3sic). +type K3sInContainer struct { + hostname string + + pool *dockertest.Pool + container *dockertest.Resource + networks []*dockertest.Network +} + +// New starts a new [K3sInContainer] joined to the given networks. +func New( + pool *dockertest.Pool, + networks []*dockertest.Network, +) (*K3sInContainer, error) { + hash := rands.HexString(k3sicHashLength) + + // Include the run ID in the hostname for easier identification of which + // test run owns this container, matching the dsic/tsic convention. + runID := dockertestutil.GetIntegrationRunID() + + var hostname string + + if runID != "" { + runIDShort := runID[len(runID)-6:] + hostname = fmt.Sprintf("k3s-%s-%s", runIDShort, hash) + } else { + hostname = "k3s-" + hash + } + + k := &K3sInContainer{ + hostname: hostname, + pool: pool, + networks: networks, + } + + // Pull the k3s image (ghcr, not built) via PullWithAuth, as hsic does for + // prebuilt/pulled images. + err := dockertestutil.PullWithAuth(pool, K3sImage) + if err != nil { + return nil, fmt.Errorf("pulling %s: %w", K3sImage, err) + } + + repo, tag, ok := strings.Cut(K3sImage, ":") + if !ok { + return nil, fmt.Errorf("invalid k3s image reference %q", K3sImage) //nolint:err113 + } + + runOptions := &dockertest.RunOptions{ + Name: hostname, + Repository: repo, + Tag: tag, + Networks: networks, + // "server" runs both the control plane and a built-in agent in one + // container. --disable traefik/servicelb/metrics-server keeps the + // cluster lean: the test only needs the API server and the ability to + // schedule the operator pods. --tls-san pins the hostname into the + // apiserver cert (not strictly needed since we exec kubectl in-container, + // but harmless and future-proof). + Cmd: []string{ + "server", + "--disable", "traefik", + "--disable", "servicelb", + "--disable", "metrics-server", + "--disable-network-policy", + "--snapshotter", "native", + "--tls-san", hostname, + }, + Env: []string{ + "K3S_KUBECONFIG_OUTPUT=" + kubeconfigPath, + "K3S_KUBECONFIG_MODE=0644", + }, + } + + // Stamp the run-id label or the reaper leaks the container. + dockertestutil.DockerAddIntegrationLabels(runOptions, "k3s") + + // dockertest does not handle pre-existing containers well; make sure a + // stale one with this name is gone first. + err = pool.RemoveContainerByName(hostname) + if err != nil { + return nil, err + } + + container, err := pool.RunWithOptions( + runOptions, + dockertestutil.DockerRestartPolicy, + // Privileged + NET_ADMIN: k3s manages iptables/ipvs, mounts cgroups and + // runs containerd. This is the same knob dsic uses for the DERP server. + dockertestutil.DockerAllowNetworkAdministration, + withK3sHostConfig, + ) + if err != nil { + return nil, fmt.Errorf("%s starting k3s container: %w", hostname, err) + } + + log.Printf("Created %s container\n", hostname) + + k.container = container + + // Make ClusterIP DNAT work before k3s programs kube-proxy rules; without it + // in-cluster DNS times out on hosts where br_netfilter is not preloaded. + k.ensureBridgeNetfilter() + + return k, nil +} + +// withK3sHostConfig sets the HostConfig knobs k3s needs beyond +// privileged/NET_ADMIN: a tmpfs on /run and /var/run, which the k3s image +// expects. +// +// It deliberately does NOT bind-mount the host /sys/fs/cgroup. On a cgroup-v2 +// host, bind-mounting the host cgroup tree into a container that keeps its own +// (private) cgroup namespace makes the cgroup root visible to the container +// disagree with the namespace runc places workload pods under. The kubelet can +// then start (the apiserver and node go Ready), but every workload pod fails to +// create its sandbox with "failed to apply cgroup configuration: ... +// cgroup.procs: no such file or directory", so nothing the operator schedules +// ever runs. Leaving the bind-mount off lets the privileged k3s entrypoint set +// up cgroup-v2 delegation within its own namespace, which it is +// designed to do, and workload pods schedule normally. +func withK3sHostConfig(config *docker.HostConfig) { + config.Tmpfs = map[string]string{ + "/run": "", + "/var/run": "", + } + + // Bind the host kernel modules read-only so the container can load + // br_netfilter (see ensureBridgeNetfilter). Without bridge netfilter, + // kube-proxy's ClusterIP DNAT rules do not apply to bridged pod-to-pod + // traffic, so kube-dns (and every other Service) is unreachable from pods — + // the in-cluster DNS timeout seen on the arm64 CI runner. The container + // shares the host kernel, so the modules match. + config.Binds = append(config.Binds, "/lib/modules:/lib/modules:ro") +} + +// ensureBridgeNetfilter loads br_netfilter and enables the sysctls that make +// kube-proxy's ClusterIP DNAT apply to bridged pod-to-pod traffic. On a host +// where the module is already loaded (e.g. the amd64 dev box, where Docker +// loads it for bridge networks) these are no-ops; on the arm64 CI runner the +// module is absent and pods cannot reach any Service IP — kube-dns included — +// so in-cluster DNS times out. Best-effort: k3s also loads the module, and a +// genuinely missing module surfaces in DumpDiagnostics rather than here. +func (k *K3sInContainer) ensureBridgeNetfilter() { + for _, cmd := range []string{ + "modprobe br_netfilter || true", + "sysctl -w net.bridge.bridge-nf-call-iptables=1 || true", + "sysctl -w net.bridge.bridge-nf-call-ip6tables=1 || true", + "sysctl -w net.ipv4.ip_forward=1 || true", + } { + out, stderr, err := k.Execute([]string{shellBin, "-c", cmd}) + if err != nil { + log.Printf("[k3s] %q failed: %v (stdout: %s, stderr: %s)", cmd, err, out, stderr) + } + } +} + +// Hostname returns the hostname of the [K3sInContainer]. +func (k *K3sInContainer) Hostname() string { + return k.hostname +} + +// ID returns the docker container ID of the [K3sInContainer]. +func (k *K3sInContainer) ID() string { + return k.container.Container.ID +} + +// ConnectToNetwork connects the cluster container to an additional network. +func (k *K3sInContainer) ConnectToNetwork(network *dockertest.Network) error { + return k.container.ConnectToNetwork(network) +} + +// Execute runs a command inside the k3s container and returns its stdout. +// kubectl and the k3s-bundled tools (and helm, once installed via +// [K3sInContainer.InstallHelm]) are on PATH. KUBECONFIG is exported so helm, +// which (unlike the image's kubectl) does not default to the k3s config, can +// reach the cluster. +func (k *K3sInContainer) Execute(command []string) (string, string, error) { + return dockertestutil.ExecuteCommand( + k.container, + command, + []string{"KUBECONFIG=" + kubeconfigPath}, + dockertestutil.ExecuteCommandTimeout(dockerExecuteTimeout), + ) +} + +// WriteFile saves a file inside the container. +func (k *K3sInContainer) WriteFile(path string, data []byte) error { + return integrationutil.WriteFileToContainer(k.pool, k.container, path, data) +} + +// WaitForRunning blocks until the cluster is ready to schedule DNS-dependent +// workloads: the kube-apiserver is serving, the single node reports Ready, and +// in-cluster DNS is servable (CoreDNS rolled out with a backed kube-dns +// Service). Gating on DNS here pins a missing-DNS failure at its source rather +// than letting the operator crashloop on an opaque lookup timeout. +func (k *K3sInContainer) WaitForRunning() error { + log.Printf("waiting for k3s API server in %s to be ready", k.hostname) + + err := k.pool.Retry(func() error { + // `kubectl get --raw=/readyz` returns "ok" once the apiserver is up. + out, _, err := k.Execute([]string{ + kubectlBin, "get", "--raw=/readyz", + }) + if err != nil { + return fmt.Errorf("k3s apiserver not ready: %w", err) + } + + if !strings.Contains(out, "ok") { + return fmt.Errorf("k3s apiserver readyz returned %q", strings.TrimSpace(out)) //nolint:err113 + } + + // Wait for the node object to exist and be Ready before returning so + // pods can actually be scheduled. + nodeOut, _, err := k.Execute([]string{ + kubectlBin, "get", "nodes", "--no-headers", + }) + if err != nil { + return fmt.Errorf("k3s node not ready: %w", err) + } + + if !strings.Contains(nodeOut, " Ready") { + return fmt.Errorf("k3s node not Ready yet: %q", strings.TrimSpace(nodeOut)) //nolint:err113 + } + + return nil + }) + if err != nil { + return err + } + + return k.waitForClusterDNS() +} + +// InstallHelm installs the helm binary into the container so the operator can be +// installed. The k3s image ships kubectl but not helm, has no curl, and its +// busybox wget cannot do HTTPS, so we download helm in the test process (which +// has network egress) and inject the binary. helm's own HTTPS client then +// fetches the operator chart from inside the container. +func (k *K3sInContainer) InstallHelm() error { + bin, err := fetchHelmBinary(resolveHelmVersion(), runtime.GOARCH) + if err != nil { + return fmt.Errorf("fetching helm: %w", err) + } + + err = k.WriteFile("/usr/local/bin/helm", bin) + if err != nil { + return fmt.Errorf("writing helm binary: %w", err) + } + + // WriteFile uploads with mode 0; make it readable+executable. + _, stderr, err := k.Execute([]string{"chmod", "0755", "/usr/local/bin/helm"}) + if err != nil { + return fmt.Errorf("chmod helm (stderr: %s): %w", stderr, err) + } + + return nil +} + +// waitForClusterDNS blocks until CoreDNS is rolled out and the kube-dns Service +// has at least one ready endpoint — i.e. in-cluster name resolution is actually +// servable, which every workload (starting with the operator) depends on. k3s +// deploys CoreDNS via its addon manager shortly after the node reports Ready, so +// the deployment may not exist yet; retry until it does before checking rollout. +func (k *K3sInContainer) waitForClusterDNS() error { + err := k.pool.Retry(func() error { + _, stderr, err := k.Execute([]string{ + kubectlBin, "-n", kubeSystemNamespace, "get", "deployment", "coredns", + }) + if err != nil { + return fmt.Errorf("coredns deployment not present yet (stderr: %s): %w", stderr, err) + } + + return nil + }) + if err != nil { + return fmt.Errorf("waiting for coredns deployment to appear: %w", err) + } + + _, stderr, err := k.Execute([]string{ + kubectlBin, "-n", kubeSystemNamespace, "rollout", "status", + "deployment/coredns", "--timeout=150s", + }) + if err != nil { + return fmt.Errorf("coredns did not become available (stderr: %s): %w", stderr, err) + } + + return k.pool.Retry(func() error { + // A populated endpoint set means a CoreDNS pod is serving :53 and + // kube-proxy has a backend to DNAT the kube-dns ClusterIP to; empty means + // in-cluster lookups will time out no matter how long a client waits. + out, stderr, err := k.Execute([]string{ + kubectlBin, "-n", kubeSystemNamespace, "get", "endpoints", "kube-dns", + "-o", "jsonpath={.subsets[*].addresses[*].ip}", + }) + if err != nil { + return fmt.Errorf("reading kube-dns endpoints (stderr: %s): %w", stderr, err) + } + + if strings.TrimSpace(out) == "" { + return errNoKubeDNSEndpoints + } + + return nil + }) +} + +// ConfigureCoreDNSHost makes in-cluster pods resolve hostname to ip via CoreDNS. +// The operator targets Headscale's control plane by IP, but the embedded DERP map +// references Headscale by hostname; without this, the proxy pods cannot resolve +// the DERP server, never connect to it, and — since they only advertise +// unreachable pod-network endpoints — get no data path to nodes outside the +// cluster. It installs a coredns-custom ConfigMap (a k3s-native extension point: +// keys ending in .server become additional server blocks), which CoreDNS's reload +// plugin picks up without a restart. +func (k *K3sInContainer) ConfigureCoreDNSHost(hostname, ip string) error { + manifest := fmt.Sprintf(`apiVersion: v1 +kind: ConfigMap +metadata: + name: coredns-custom + namespace: kube-system +data: + headscale.server: | + %s { + hosts { + %s %s + fallthrough + } + } +`, hostname, ip, hostname) + + return k.ApplyManifest("coredns-custom", manifest) +} + +// DumpDiagnostics logs cluster state useful for debugging a failed operator +// install: pod status across namespaces and the operator's own logs and events. +// Best-effort — every command's failure is logged, not returned. +func (k *K3sInContainer) DumpDiagnostics() { + for _, c := range [][]string{ + {kubectlBin, "get", "pods", "-A", "-o", "wide"}, + {kubectlBin, "-n", tailscaleNamespace, "get", "events", "--sort-by=.lastTimestamp"}, + {kubectlBin, "-n", tailscaleNamespace, "describe", "pods"}, + {kubectlBin, "-n", tailscaleNamespace, "logs", "deployment/operator", "--tail=200"}, + // A crashlooping operator's fatal error is in the previous container. + {kubectlBin, "-n", tailscaleNamespace, "logs", "deployment/operator", "--previous", "--tail=200"}, + {kubectlBin, "-n", tailscaleNamespace, "get", "statefulsets,pods", "-o", "wide"}, + // Proxy pods' tailscaled logs: DERP-connection and registration failures + // (the data-path culprits) surface here, not in the operator log. + { + shellBin, "-c", + "for p in $(kubectl -n " + tailscaleNamespace + " get pods -o name | grep /ts-); do " + + "echo \"== $p ==\"; kubectl -n " + tailscaleNamespace + + " logs $p -c tailscale --tail=80 2>&1; done", + }, + // In-cluster DNS: the operator's first dependency. A "lookup + // kubernetes.default.svc ... i/o timeout" crash means CoreDNS is not + // serving, so capture its pod state, logs (Corefile parse errors land + // here), and the Service endpoints. + {kubectlBin, "-n", kubeSystemNamespace, "get", "pods", "-l", "k8s-app=kube-dns", "-o", "wide"}, + {kubectlBin, "-n", kubeSystemNamespace, "logs", "-l", "k8s-app=kube-dns", "--tail=100"}, + {kubectlBin, "-n", kubeSystemNamespace, "get", "endpoints", "kube-dns", "-o", "wide"}, + // Host network state behind a ClusterIP-unreachable DNS timeout: whether + // br_netfilter is loaded and the call-iptables/forward sysctls are on, and + // whether kube-proxy actually programmed the kube-dns DNAT rule. If CoreDNS + // is healthy (above) but these are missing, the fault is the Service DNAT + // path, not DNS. + {shellBin, "-c", "lsmod | grep -E 'br_netfilter|nf_conntrack' || echo 'br_netfilter NOT loaded'"}, + {shellBin, "-c", "sysctl net.bridge.bridge-nf-call-iptables net.ipv4.ip_forward 2>&1 || true"}, + {shellBin, "-c", "iptables-save -t nat 2>/dev/null | grep -iE 'KUBE-SERVICES|kube-dns|10.43.0.10' | head -40 || echo 'no kube-dns nat rules'"}, + } { + out, stderr, err := k.Execute(c) + label := strings.Join(c, " ") + + if err != nil { + log.Printf("[k3s diag] %s failed: %v (stderr: %s)", label, err, stderr) + continue + } + + log.Printf("[k3s diag] %s:\n%s", label, out) + } +} + +// resolveHelmVersion returns the latest published helm release tag (e.g. +// "v3.19.1"), falling back to [helmVersionFallback] if it cannot be resolved. +// get.helm.sh is not Docker Hub and has no anonymous rate limit, so a "rolling" +// latest is cheap; the fallback keeps a broken release from breaking CI. +func resolveHelmVersion() string { + req, err := http.NewRequestWithContext( + context.Background(), http.MethodGet, "https://get.helm.sh/helm-latest-version", nil) + if err != nil { + return helmVersionFallback + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return helmVersionFallback + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return helmVersionFallback + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 32)) + if err != nil { + return helmVersionFallback + } + + version := strings.TrimSpace(string(body)) + if !strings.HasPrefix(version, "v") { + return helmVersionFallback + } + + return version +} + +// fetchHelmBinary downloads the helm release tarball for version and goarch and +// returns the helm binary bytes. +func fetchHelmBinary(version, goarch string) ([]byte, error) { + url := fmt.Sprintf("https://get.helm.sh/helm-%s-linux-%s.tar.gz", version, goarch) + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: %s returned status %d", errHelmDownload, url, resp.StatusCode) + } + + gz, err := gzip.NewReader(resp.Body) + if err != nil { + return nil, err + } + defer gz.Close() + + want := "linux-" + goarch + "/helm" + tr := tar.NewReader(gz) + + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + + if err != nil { + return nil, err + } + + if hdr.Name == want { + return io.ReadAll(tr) + } + } + + return nil, fmt.Errorf("%w: %s", errHelmNotInTarball, want) +} + +// Shutdown saves the container log and then runs k3s-killall in-container so +// k3s's own child processes/containers (containerd-shims, pods) do not leak, +// before purging the container itself. +func (k *K3sInContainer) Shutdown() error { + err := k.SaveLog("/tmp/control") + if err != nil { + log.Printf("saving log from %s: %s", k.hostname, err) + } + + // k3s spawns containerd and a tree of child processes inside this + // container; the bundled k3s-killall.sh tears them down. Best-effort: the + // Purge below removes the container regardless. + _, _, err = k.Execute([]string{shellBin, "-c", "k3s-killall.sh || true"}) + if err != nil { + log.Printf("running k3s-killall in %s: %s", k.hostname, err) + } + + return k.pool.Purge(k.container) +} + +// SaveLog saves the container stdout/stderr logs to a path on the host. +func (k *K3sInContainer) SaveLog(path string) error { + _, _, err := dockertestutil.SaveLog(k.pool, k.container, path) + + return err +} diff --git a/integration/k3sic/operator.go b/integration/k3sic/operator.go new file mode 100644 index 000000000..e9eee1709 --- /dev/null +++ b/integration/k3sic/operator.go @@ -0,0 +1,243 @@ +package k3sic + +import ( + "fmt" + "strings" +) + +// This file holds the reusable building blocks for driving the Tailscale +// Kubernetes operator inside the cluster: installing it and applying the CRs and +// workloads a test needs. Tests compose these methods rather than embedding +// kubectl/helm invocations, so adding a new operator test is a few method calls. + +// ApplyManifest writes manifest into the container as /tmp/.yaml and +// kubectl-applies it. It is the building block the helpers below use, and is +// exported so tests can apply ad-hoc manifests without a bespoke method. +func (k *K3sInContainer) ApplyManifest(name, manifest string) error { + path := "/tmp/" + name + ".yaml" + + err := k.WriteFile(path, []byte(manifest)) + if err != nil { + return fmt.Errorf("writing manifest %s: %w", name, err) + } + + _, stderr, err := k.Execute([]string{kubectlBin, "apply", "-f", path}) + if err != nil { + return fmt.Errorf("applying manifest %s (stderr: %s): %w", name, stderr, err) + } + + return nil +} + +// InstallOperator installs the Tailscale Kubernetes operator via Helm into the +// tailscale namespace, pointed at loginServer with the given OAuth client +// credentials. loginServer is used by the operator for both the control plane +// and the management API; for an in-test Headscale pass its HTTP endpoint by IP +// (hsic.HeadscaleInContainer.GetIPEndpoint) so the pods need no DNS or CA. The +// operator and proxy images come from ghcr at the capver-derived tag. Blocks +// (helm --wait) until the operator deployment is available. +func (k *K3sInContainer) InstallOperator(loginServer, clientID, clientSecret string) error { + repoAdd := "helm repo add tailscale https://pkgs.tailscale.com/helmcharts && helm repo update" + + _, stderr, err := k.Execute([]string{shellBin, "-c", repoAdd}) + if err != nil { + return fmt.Errorf("helm repo add/update (stderr: %s): %w", stderr, err) + } + + _, stderr, err = k.Execute([]string{ + kubectlBin, "create", "namespace", tailscaleNamespace, + }) + if err != nil { + return fmt.Errorf("creating %s namespace (stderr: %s): %w", tailscaleNamespace, stderr, err) + } + + // Precreate the operator-oauth Secret with --from-literal instead of the + // chart's oauth.clientId/clientSecret: the chart interpolates those unquoted, + // so an all-digit credential renders as a YAML number and the apiserver + // rejects it. --from-literal always stores strings. The chart uses a Secret + // named operator-oauth when oauth.clientId is unset. + _, stderr, err = k.Execute([]string{ + kubectlBin, "-n", tailscaleNamespace, "create", "secret", "generic", "operator-oauth", + "--from-literal=client_id=" + clientID, + "--from-literal=client_secret=" + clientSecret, + }) + if err != nil { + return fmt.Errorf("creating operator-oauth secret (stderr: %s): %w", stderr, err) + } + + opRepo, opTag, _ := strings.Cut(OperatorImage(), ":") + proxyRepo, proxyTag, _ := strings.Cut(ProxyImage(), ":") + + const set = "--set-string" + + install := []string{ + "helm", "upgrade", "--install", "tailscale-operator", + "tailscale/tailscale-operator", + "--version", OperatorChartVersion(), + "--namespace", tailscaleNamespace, + set, "loginServer=" + loginServer, + set, "operatorConfig.image.repository=" + opRepo, + set, "operatorConfig.image.tag=" + opTag, + set, "proxyConfig.image.repository=" + proxyRepo, + set, "proxyConfig.image.tag=" + proxyTag, + "--wait", "--timeout", "5m", + } + + _, stderr, err = k.Execute(install) + if err != nil { + k.DumpDiagnostics() + return fmt.Errorf("helm install operator (stderr: %s): %w", stderr, err) + } + + // hsic serves the embedded DERP without TLS, but a proxy dials DERP over HTTPS + // and so cannot relay through it. Pods on the k3s pod network can only reach + // off-cluster nodes via DERP (their only endpoint is an unreachable pod IP), + // so without this the ingress/egress proxies get no data path. The ProxyClass + // injects TS_DEBUG_DERP_WS_CLIENT + TS_DEBUG_USE_DERP_HTTP, switching proxies to + // plain-HTTP websocket DERP. The proxy-creating helpers below reference it. + return k.applyDERPWebsocketProxyClass() +} + +// DERPWebsocketProxyClass is the ProxyClass [InstallOperator] creates to make +// operator proxies reach the embedded (non-TLS) DERP over websocket. Proxy +// resources reference it via spec.proxyClass / the tailscale.com/proxy-class +// annotation. +const DERPWebsocketProxyClass = "headscale-derp-ws" //nolint:gosec // G101 false positive: a ProxyClass name, not a credential + +func (k *K3sInContainer) applyDERPWebsocketProxyClass() error { + manifest := fmt.Sprintf(`apiVersion: tailscale.com/v1alpha1 +kind: ProxyClass +metadata: + name: %s +spec: + statefulSet: + pod: + tailscaleContainer: + env: + - name: TS_DEBUG_DERP_WS_CLIENT + value: "true" + - name: TS_DEBUG_USE_DERP_HTTP + value: "true" +`, DERPWebsocketProxyClass) + + return k.ApplyManifest("proxyclass-"+DERPWebsocketProxyClass, manifest) +} + +// DeployConnector applies a Connector CR advertising an egress subnet router for +// advertiseRoutes, tagged with tags. The operator provisions a proxy and +// registers it as a node in Headscale. +func (k *K3sInContainer) DeployConnector(name string, tags, advertiseRoutes []string) error { + manifest := fmt.Sprintf(`apiVersion: tailscale.com/v1alpha1 +kind: Connector +metadata: + name: %s +spec: + proxyClass: %s + tags: +%s + subnetRouter: + advertiseRoutes: +%s +`, name, DERPWebsocketProxyClass, yamlList(tags, 4), yamlList(advertiseRoutes, 6)) + + return k.ApplyManifest("connector-"+name, manifest) +} + +// DeployProxyGroup applies a ProxyGroup CR of the given type ("ingress" or +// "egress") with replicas proxies tagged with tags. ProxyGroups are the current +// way to run a pool of operator proxies for HA ingress/egress. +func (k *K3sInContainer) DeployProxyGroup(name, proxyType string, replicas int, tags []string) error { + manifest := fmt.Sprintf(`apiVersion: tailscale.com/v1alpha1 +kind: ProxyGroup +metadata: + name: %s +spec: + type: %s + replicas: %d + proxyClass: %s + tags: +%s +`, name, proxyType, replicas, DERPWebsocketProxyClass, yamlList(tags, 4)) + + return k.ApplyManifest("proxygroup-"+name, manifest) +} + +// DeployEchoServer deploys a minimal HTTP server (agnhost, served from +// registry.k8s.io to avoid Docker Hub rate limits) labelled app= with a +// ClusterIP Service of the same name on port 80. Use it as the in-cluster target +// for connectivity tests; expose it to the tailnet with [ExposeServiceToTailnet]. +func (k *K3sInContainer) DeployEchoServer(name string) error { + manifest := fmt.Sprintf(`apiVersion: apps/v1 +kind: Deployment +metadata: + name: %s +spec: + replicas: 1 + selector: + matchLabels: + app: %s + template: + metadata: + labels: + app: %s + spec: + containers: + - name: echo + image: registry.k8s.io/e2e-test-images/agnhost:2.47 + args: ["netexec", "--http-port=80"] + ports: + - containerPort: 80 +--- +apiVersion: v1 +kind: Service +metadata: + name: %s +spec: + selector: + app: %s + ports: + - port: 80 + targetPort: 80 +`, name, name, name, name, name) + + return k.ApplyManifest("echo-"+name, manifest) +} + +// ExposeServiceToTailnet creates a tailscale LoadBalancer Service named +// "-ts" that exposes the pods labelled app= to the tailnet, tagged +// with tags. The operator provisions an ingress proxy and registers a node, so a +// node outside the cluster (a regular tsic client) can reach the service over +// the tailnet. +func (k *K3sInContainer) ExposeServiceToTailnet(name string, tags []string) error { + manifest := fmt.Sprintf(`apiVersion: v1 +kind: Service +metadata: + name: %s-ts + annotations: + tailscale.com/tags: "%s" + tailscale.com/proxy-class: %s +spec: + type: LoadBalancer + loadBalancerClass: tailscale + selector: + app: %s + ports: + - port: 80 + targetPort: 80 +`, name, strings.Join(tags, ","), DERPWebsocketProxyClass, name) + + return k.ApplyManifest("expose-"+name, manifest) +} + +// yamlList renders items as a YAML block sequence indented by indent spaces, +// e.g. " - tag:k8s". Returns "" for an empty list. +func yamlList(items []string, indent int) string { + var b strings.Builder + + pad := strings.Repeat(" ", indent) + for _, item := range items { + fmt.Fprintf(&b, "%s- %s\n", pad, item) + } + + return strings.TrimRight(b.String(), "\n") +} diff --git a/integration/k3sic/tls-ca-baking.md b/integration/k3sic/tls-ca-baking.md new file mode 100644 index 000000000..7d2507f34 --- /dev/null +++ b/integration/k3sic/tls-ca-baking.md @@ -0,0 +1,70 @@ +# Running the operator against a private-CA TLS Headscale + +`TestK8sOperator` points the Tailscale Kubernetes operator at an **HTTP** +Headscale (`hsic.WithoutTLS`, `loginServer = http://:`). That keeps +the harness small: the operator and proxy pods need no CA, so there is no image +baking, no containerd import, and no CoreDNS hostname mapping. + +This note records how to run the same test against a **TLS** Headscale serving a +private CA, in case a future test needs to exercise realistic TLS. It is not +wired up; reconstruct it from here. + +## Why it is involved + +tailscaled/tsnet verify the control connection against Go's +`x509.SystemCertPool` (on the Alpine images: `/etc/ssl/certs/ca-certificates.crt`). +A private CA must therefore be in the pod's **system trust store**. + +As of the tailscale operator chart on `main`, there is no supported way to hand +a CA _file_ to a proxy pod: + +- `ProxyClass.spec.statefulSet.pod` has no `volumes`. +- `ProxyClass...tailscaleContainer` has no `volumeMounts`, and its `env` is a + reduced schema (`name`/`value` only — no `valueFrom`/`envFrom`). +- `operatorConfig` exposes `extraEnv` but no `extraVolumes`. + +`SSL_CERT_FILE`/`SSL_CERT_DIR` _are_ honoured by tailscaled, but they need a +file that cannot be projected in. So the CA has to be baked into the images. + +## The recipe + +1. Serve Headscale with TLS (the `hsic` default) and grab `headscale.GetCert()`. + Pass it to the cluster so in-container `helm`/`kubectl` trust it. + +2. Bake the CA into derived operator and proxy images. For each of + `tailscale/k8s-operator:` and `tailscale/tailscale:`, build: + + ```Dockerfile + FROM + COPY headscale-ca.crt /usr/local/share/ca-certificates/headscale-ca.crt + RUN update-ca-certificates + ``` + + Build on the host docker daemon (it has egress to pull the base images), + `ExportImage` the result to a docker-format tarball, stream it into the k3s + container, and import it into the kubelet's containerd namespace: + + ``` + ctr --namespace k8s.io images import + ``` + + Tag the derived images `headscale.local/...:-ca` and run them with + `imagePullPolicy: Never` (the `headscale.local/` prefix is never resolved by + a registry). + +3. Wire the derived images into the chart via `operatorConfig.image` / + `proxyConfig.image`. The operator's proxy StatefulSet template hard-codes + `imagePullPolicy: Always`, so a `ProxyClass` must override the proxy image + **and** set `imagePullPolicy: Never`; Connectors must reference that + ProxyClass explicitly (`defaultProxyClass` does not apply to them). + +4. Pods resolve Headscale through CoreDNS, not the container's `/etc/hosts`, and + the cert's only SAN is the Headscale hostname (dialing by IP fails TLS + verification). Install a `coredns-custom` ConfigMap mapping the hostname to + the Headscale IP and gate on the `kube-dns` Service having ready endpoints + before starting the operator. + +The full implementation lived in `integration/k3sic/k3sic.go` and +`integration/k8s_operator_test.go` before the switch to HTTP; recover it from +git history (`PrepareTailscaleImages`, `bakeAndImportImage`, `caBuildContext`, +`ConfigureCoreDNSHost`) if needed. diff --git a/integration/k8s_operator_test.go b/integration/k8s_operator_test.go new file mode 100644 index 000000000..7af22ae84 --- /dev/null +++ b/integration/k8s_operator_test.go @@ -0,0 +1,266 @@ +package integration + +import ( + "fmt" + "net/netip" + "slices" + "strings" + "testing" + "time" + + clientv1 "github.com/juanfont/headscale/gen/client/v1" + policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" + "github.com/juanfont/headscale/integration/hsic" + "github.com/juanfont/headscale/integration/integrationutil" + "github.com/juanfont/headscale/integration/k3sic" + "github.com/juanfont/headscale/integration/tsic" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" +) + +const ( + tagK8sOperator = "tag:k8s-operator" + tagK8s = "tag:k8s" +) + +// k8sOperatorPolicy is the tagOwners policy the Tailscale Kubernetes operator +// requires: tag:k8s-operator is self/admin-owned, and the operator +// (tag:k8s-operator) owns tag:k8s so it can mint auth keys for the proxy nodes +// it spins up. The wildcard ACL lets the in-cluster proxies and the out-of-cluster +// tsic client reach each other for the connectivity checks. +func k8sOperatorPolicy() *policyv2.Policy { + return &policyv2.Policy{ + TagOwners: policyv2.TagOwners{ + tagK8sOperator: policyv2.Owners{}, + tagK8s: policyv2.Owners{new(policyv2.Tag(tagK8sOperator))}, + }, + ACLs: []policyv2.ACL{ + { + Action: "accept", + Sources: []policyv2.Alias{policyv2.Wildcard}, + Destinations: []policyv2.AliasWithPorts{ + {Alias: policyv2.Wildcard, Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}}, + }, + }, + }, + } +} + +// TestK8sOperator verifies that the real Tailscale Kubernetes operator, installed +// into a real k3s cluster via its Helm chart and pointed at an in-test Headscale, +// can authenticate with OAuth client credentials, mint auth keys, and register +// nodes that then interoperate with a regular tailnet node. +// +// The operator targets Headscale over plain HTTP by IP (hsic.WithoutTLS + +// GetIPEndpoint), so the operator and proxy pods need no CA and no DNS entry for +// Headscale. See integration/k3sic/tls-ca-baking.md for the TLS variant. +// +// The cluster-side steps are reusable building blocks on k3sic.K3sInContainer +// (InstallOperator, DeployConnector, DeployEchoServer, ExposeServiceToTailnet, +// DeployProxyGroup), so further operator scenarios are a few method calls. +// +// Run it with `go run ./cmd/hi run "TestK8sOperator"`. +func TestK8sOperator(t *testing.T) { + IntegrationSkip(t) + + // One regular user+node provides the out-of-cluster tailnet peer used by the + // connectivity subtest; the operator registers its own nodes on top. + spec := ScenarioSpec{ + Users: []string{"k8s-user"}, + NodesPerUser: 1, + } + + scenario, err := NewScenario(spec) + require.NoError(t, err) + + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + // The tsic client reaches the in-cluster proxy only via DERP (no direct + // path to the k3s pod network), and hsic's embedded DERP is non-TLS, so the + // client must reach DERP over plain-HTTP websockets — as the proxy pods do. + []tsic.Option{tsic.WithDERPOverHTTP()}, + hsic.WithTestName("k8soperator"), + hsic.WithoutTLS(), + hsic.WithACLPolicy(k8sOperatorPolicy()), + ) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + // Mint an OAuth client for the operator: devices:core + auth_keys scopes, + // tagged tag:k8s-operator. CreateOAuthClient mints the admin API key and calls + // the v2 keys API itself. + clientID, clientSecret, err := headscale.CreateOAuthClient( + t.Context(), + []string{"devices:core", "auth_keys"}, + []string{tagK8sOperator}, + ) + require.NoError(t, err, "creating OAuth client (server-side OAuth must be implemented)") + require.NotEmpty(t, clientID) + require.NotEmpty(t, clientSecret) + + // Bring up the k3s cluster on the scenario networks. + k3s, err := k3sic.New(scenario.Pool(), scenario.Networks()) + require.NoError(t, err) + + defer func() { + shutdownErr := k3s.Shutdown() + if shutdownErr != nil { + t.Logf("shutting down k3s: %s", shutdownErr) + } + }() + + // Registered after Shutdown so it runs first (defers are LIFO): dump cluster + // state while it is still up if anything below fails. + defer func() { + if t.Failed() { + k3s.DumpDiagnostics() + } + }() + + require.NoError(t, k3s.WaitForRunning()) + require.NoError(t, k3s.InstallHelm()) + + // The operator reaches the control plane by IP, but the embedded DERP map + // references Headscale by hostname; teach CoreDNS to resolve it so the proxy + // pods can connect to DERP and get a data path to nodes outside the cluster. + hsIP := headscale.GetIPInNetwork(scenario.Networks()[0]) + require.NoError(t, k3s.ConfigureCoreDNSHost(headscale.GetHostname(), hsIP)) + + // loginServer is the in-cluster-reachable HTTP endpoint by IP; the operator + // uses it for both the control plane and the management API. + loginServer := headscale.GetIPEndpoint() + require.NoError(t, k3s.InstallOperator(loginServer, clientID, clientSecret)) + + t.Run("operator-registers", func(t *testing.T) { + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes() + assert.NoError(c, err) + assert.True(c, hasNodeWithTag(nodes, tagK8sOperator), + "expected a node tagged %s registered by the operator, got %s", + tagK8sOperator, describeNodes(nodes)) + }, integrationutil.ScaledTimeout(180*time.Second), 2*time.Second, + "operator node should register and be tagged "+tagK8sOperator) + }) + + t.Run("egress-connector", func(t *testing.T) { + require.NoError(t, k3s.DeployConnector("k8s-egress", []string{tagK8s}, []string{"10.40.0.0/14"})) + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes() + assert.NoError(c, err) + assert.True(c, hasNodeWithTag(nodes, tagK8s), + "expected a proxy node tagged %s registered by the operator, got %s", + tagK8s, describeNodes(nodes)) + }, integrationutil.ScaledTimeout(180*time.Second), 2*time.Second, + "egress proxy node should register and be tagged "+tagK8s) + }) + + t.Run("ingress-service-reachable-from-tailnet", func(t *testing.T) { + require.NoError(t, k3s.DeployEchoServer("echo")) + require.NoError(t, k3s.ExposeServiceToTailnet("echo", []string{tagK8s})) + + clients, err := scenario.ListTailscaleClients("k8s-user") + require.NoError(t, err) + require.NotEmpty(t, clients) + + // The operator registers the ingress proxy as a tailnet node named after + // the exposed Service (-, here default-echo-ts). Read + // its IP from Headscale rather than the Service's LoadBalancer status, + // which the operator does not populate against Headscale. + var svcIP string + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes() + assert.NoError(c, err) + + ip, ok := nodeIPv4ByName(nodes, "echo") + assert.True(c, ok, "ingress proxy node for echo should register, got %s", describeNodes(nodes)) + + svcIP = ip + }, integrationutil.ScaledTimeout(180*time.Second), 2*time.Second, + "operator should register an ingress proxy node for the exposed service") + + // The out-of-cluster node reaches the in-cluster service through the proxy + // over the tailnet. /hostname is an agnhost endpoint that returns the + // backend pod's hostname, proving the request reached the service. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + body, err := clients[0].Curl("http://" + svcIP + "/hostname") + assert.NoError(c, err) + assert.NotEmpty(c, body, "expected a response from the exposed service") + }, integrationutil.ScaledTimeout(120*time.Second), 2*time.Second, + "tsic node should reach the k8s-exposed service over the tailnet") + }) + + t.Run("proxy-group", func(t *testing.T) { + nodes, err := headscale.ListNodes() + require.NoError(t, err) + + before := countNodesWithTag(nodes, tagK8s) + + const replicas = 2 + + require.NoError(t, k3s.DeployProxyGroup("ts-ingress", "ingress", replicas, []string{tagK8s})) + + // A ProxyGroup runs a pool of proxies; each replica registers its own node. + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nodes, err := headscale.ListNodes() + assert.NoError(c, err) + assert.GreaterOrEqual(c, countNodesWithTag(nodes, tagK8s), before+replicas, + "expected %d more %s nodes from the ProxyGroup, got %s", + replicas, tagK8s, describeNodes(nodes)) + }, integrationutil.ScaledTimeout(180*time.Second), 2*time.Second, + "ProxyGroup replicas should each register a node tagged "+tagK8s) + }) +} + +// hasNodeWithTag reports whether any node carries the given tag. +func hasNodeWithTag(nodes []*clientv1.Node, tag string) bool { + return countNodesWithTag(nodes, tag) > 0 +} + +// countNodesWithTag counts the nodes carrying the given tag. +func countNodesWithTag(nodes []*clientv1.Node, tag string) int { + count := 0 + + for _, node := range nodes { + if slices.Contains(node.Tags, tag) { + count++ + } + } + + return count +} + +// nodeIPv4ByName returns the IPv4 of the first node whose given name contains +// substr, identifying an operator-registered proxy by the Service/Connector it +// fronts (e.g. "echo" matches the default-echo-ts ingress proxy). +func nodeIPv4ByName(nodes []*clientv1.Node, substr string) (string, bool) { + for _, node := range nodes { + if !strings.Contains(node.GivenName, substr) { + continue + } + + for _, ip := range node.IpAddresses { + addr, err := netip.ParseAddr(ip) + if err == nil && addr.Is4() { + return ip, true + } + } + } + + return "", false +} + +// describeNodes renders a compact name->tags summary for failure messages. +func describeNodes(nodes []*clientv1.Node) string { + parts := make([]string, 0, len(nodes)) + for _, node := range nodes { + parts = append(parts, fmt.Sprintf("%s%v", node.Name, node.Tags)) + } + + return "[" + strings.Join(parts, " ") + "]" +} From 8d91e42a9f5e69afaa6a76398e7d8f4eebb62616 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 26 Jun 2026 13:17:12 +0000 Subject: [PATCH 113/127] cmd/hi: share the test-container prefix matcher and check the k3s image Factor the hs-/ts-/derp-/k3s- prefix set into one helper used by cleanup and docker; add a doctor check for the ghcr k3s image. --- cmd/hi/cleanup.go | 21 ++++++++++++++++++--- cmd/hi/docker.go | 2 +- cmd/hi/doctor.go | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/cmd/hi/cleanup.go b/cmd/hi/cleanup.go index 5ad624ce1..d58ab779e 100644 --- a/cmd/hi/cleanup.go +++ b/cmd/hi/cleanup.go @@ -187,14 +187,29 @@ func removeContainerWithRetry(ctx context.Context, cli *client.Client, container return err == nil } +// testContainerNamePrefixes are the name prefixes used by containers that the +// integration test harness creates (headscale, tailscale, DERP, and k3s). +var testContainerNamePrefixes = []string{"hs-", "ts-", "derp-", "k3s-"} + +// matchesTestContainerPrefix reports whether name belongs to an integration +// test container, ignoring any leading "/" that Docker prefixes names with. +func matchesTestContainerPrefix(name string) bool { + name = strings.TrimPrefix(name, "/") + for _, prefix := range testContainerNamePrefixes { + if strings.HasPrefix(name, prefix) { + return true + } + } + + return false +} + // isTestContainerName reports whether any of the container names belong to an // integration test container. func isTestContainerName(names []string) bool { for _, name := range names { if strings.Contains(name, "headscale-test-suite") || - strings.Contains(name, "hs-") || - strings.Contains(name, "ts-") || - strings.Contains(name, "derp-") { + matchesTestContainerPrefix(name) { return true } } diff --git a/cmd/hi/docker.go b/cmd/hi/docker.go index cdfa775c0..102ee526e 100644 --- a/cmd/hi/docker.go +++ b/cmd/hi/docker.go @@ -735,7 +735,7 @@ func getCurrentTestContainers(containers []container.Summary, testContainerID st for _, cont := range containers { for _, name := range cont.Names { containerName := strings.TrimPrefix(name, "/") - if strings.HasPrefix(containerName, "hs-") || strings.HasPrefix(containerName, "ts-") { + if matchesTestContainerPrefix(containerName) { // Check if container has matching run ID label if cont.Labels != nil && cont.Labels["hi.run-id"] == runID { testRunContainers = append(testRunContainers, testContainer{ diff --git a/cmd/hi/doctor.go b/cmd/hi/doctor.go index 593e2f378..7bd42fb90 100644 --- a/cmd/hi/doctor.go +++ b/cmd/hi/doctor.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/juanfont/headscale/integration/dockertestutil" + "github.com/juanfont/headscale/integration/k3sic" ) const ( @@ -21,6 +22,7 @@ const ( nameDockerContext = "Docker Context" nameDockerSocket = "Docker Socket" nameGolangImage = "Golang Image" + nameK3sImage = "K3s Image" nameGoInstall = "Go Installation" ) @@ -66,6 +68,7 @@ func runDoctorCheck(ctx context.Context) error { results = append(results, checkDockerSocket(ctx)) results = append(results, checkDockerHubCredentials()) results = append(results, checkGolangImage(ctx)) + results = append(results, checkK3sImage(ctx)) } // Check 3: Go installation @@ -242,6 +245,44 @@ func checkGolangImage(ctx context.Context) DoctorResult { return pass(nameGolangImage, fmt.Sprintf("Golang image %s is now available", imageName)) } +// checkK3sImage verifies the ghcr k3s image used by TestK8sOperator is available +// locally or can be pulled. The image is pinned (see [k3sic.K3sImage]). +func checkK3sImage(ctx context.Context) DoctorResult { + cli, err := createDockerClient(ctx) + if err != nil { + return fail(nameK3sImage, "Cannot create Docker client for image check") + } + defer cli.Close() + + imageName := k3sic.K3sImage + + available, err := checkImageAvailableLocally(ctx, cli, imageName) + if err != nil { + return fail( + nameK3sImage, + fmt.Sprintf("Cannot check k3s image %s: %v", imageName, err), + "Check Docker daemon status", + "Try: docker images | grep k3s", + ) + } + + if available { + return pass(nameK3sImage, fmt.Sprintf("K3s image %s is available locally", imageName)) + } + + err = ensureImageAvailable(ctx, cli, imageName, false) + if err != nil { + return warn( + nameK3sImage, + fmt.Sprintf("K3s image %s not available locally and could not pull: %v", imageName, err), + "Only TestK8sOperator needs this image; other tests are unaffected", + "Try: docker pull "+imageName, + ) + } + + return pass(nameK3sImage, fmt.Sprintf("K3s image %s is now available", imageName)) +} + // checkGoInstallation verifies Go is installed and working. func checkGoInstallation(ctx context.Context) DoctorResult { _, err := exec.LookPath("go") From 518b497be90d2f3c0bd0e226ff5d238e7da818c0 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 26 Jun 2026 13:17:16 +0000 Subject: [PATCH 114/127] ci: run TestK8sOperator in the shared sqlite matrix It needs no special runner (every integration container already runs privileged), so it joins the generated matrix; load br_netfilter only for that test. Updates #1202 --- .github/workflows/integration-test-template.yml | 7 +++++++ .github/workflows/test-integration.yaml | 1 + 2 files changed, 8 insertions(+) diff --git a/.github/workflows/integration-test-template.yml b/.github/workflows/integration-test-template.yml index 68dbfd366..590185c96 100644 --- a/.github/workflows/integration-test-template.yml +++ b/.github/workflows/integration-test-template.yml @@ -78,6 +78,13 @@ jobs: echo '{"storage-driver":"overlay2"}' | sudo tee /etc/docker/daemon.json sudo systemctl restart docker docker version + - name: Load br_netfilter for in-cluster service routing + if: inputs.test == 'TestK8sOperator' + # TestK8sOperator runs k3s in a container; without br_netfilter on the + # host, bridged pod-to-pod traffic skips kube-proxy's ClusterIP DNAT and + # in-cluster DNS (kube-dns) is unreachable. The module cannot be loaded + # from inside the unprivileged-module rancher/k3s image, so load it here. + run: sudo modprobe br_netfilter - name: Login to Docker Hub env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_CI_USERNAME }} diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index 4326d03de..974e55233 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -311,6 +311,7 @@ jobs: - Test2118DeletingOnlineNodePanics - TestGrantCapRelay - TestGrantCapDrive + - TestK8sOperator - TestEnablingRoutes - TestHASubnetRouterFailover - TestSubnetRouteACL From 6f317c7576474c5d19c799f4e225d66d2b597e50 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 1 Jul 2026 09:40:21 +0000 Subject: [PATCH 115/127] policy: take RLock for reads so map generation runs concurrently One exclusive mutex serialized every policy read, so a mass reconnect on autogroup:self/via/relay policies stalled clients into "unexpected EOF" retries. Per-node caches become xsync.Maps for lazy population under RLock. Fixes #3346 --- CHANGELOG.md | 8 ++ hscontrol/policy/v2/policy.go | 142 +++++++++++++++-------------- hscontrol/policy/v2/policy_test.go | 20 ++-- hscontrol/policy/v2/sshtest.go | 4 +- hscontrol/policy/v2/test.go | 4 +- 5 files changed, 101 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c4028cbd..dcd5da831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,14 @@ keys remain all-access. - Expiring or deleting a non-existent pre-auth key now returns an error instead of silently succeeding [#3324](https://github.com/juanfont/headscale/pull/3324) - Improve systemd service file hardening [#3341](https://github.com/juanfont/headscale/pull/3341) +## 0.29.2 (202x-xx-xx) + +**Minimum supported Tailscale client version: v1.80.0** + +### Changes + +- Fix map generation serializing on the policy lock, so a mass reconnect on `autogroup:self`, via or relay policies no longer stalls clients into `unexpected EOF` retry loops [#3358](https://github.com/juanfont/headscale/pull/3358) + ## 0.29.1 (2026-06-18) **Minimum supported Tailscale client version: v1.80.0** diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go index 7da5dcb22..ae76ad267 100644 --- a/hscontrol/policy/v2/policy.go +++ b/hscontrol/policy/v2/policy.go @@ -15,6 +15,7 @@ import ( "github.com/juanfont/headscale/hscontrol/policy/matcher" "github.com/juanfont/headscale/hscontrol/policy/policyutil" "github.com/juanfont/headscale/hscontrol/types" + "github.com/puzpuzpuz/xsync/v4" "github.com/rs/zerolog/log" "go4.org/netipx" "tailscale.com/net/tsaddr" @@ -28,7 +29,10 @@ import ( var ErrInvalidTagOwner = errors.New("tag owner is not an Alias") type PolicyManager struct { - mu sync.Mutex + // RWMutex, not Mutex, so concurrent map generation does not serialise on + // reads. The per-node caches are xsync.Maps so a read can fill them without + // taking the write lock. + mu sync.RWMutex pol *Policy users []types.User nodes views.Slice[types.NodeView] @@ -55,7 +59,7 @@ type PolicyManager struct { viaTargetTags map[Tag]struct{} // Lazy map of SSH policies - sshPolicyMap map[types.NodeID]*tailcfg.SSHPolicy + sshPolicyMap *xsync.Map[types.NodeID, *tailcfg.SSHPolicy] // compiledGrants are the grants with sources pre-resolved. // The single source of truth for filter compilation. Both @@ -64,12 +68,12 @@ type PolicyManager struct { userNodeIdx userNodeIndex // Lazy map of per-node filter rules (reduced, for packet filters) - filterRulesMap map[types.NodeID][]tailcfg.FilterRule + filterRulesMap *xsync.Map[types.NodeID, []tailcfg.FilterRule] // Lazy map of per-node matchers derived from UNREDUCED filter // rules. Only populated on the slow path when needsPerNodeFilter // is true; the fast path returns pm.matchers directly. - matchersForNodeMap map[types.NodeID][]matcher.Match + matchersForNodeMap *xsync.Map[types.NodeID, []matcher.Match] // needsPerNodeFilter is true when any compiled grant requires // per-node work (autogroup:self or via grants). @@ -197,9 +201,9 @@ func NewPolicyManager(b []byte, users []types.User, nodes views.Slice[types.Node pol: policy, users: users, nodes: nodes, - sshPolicyMap: make(map[types.NodeID]*tailcfg.SSHPolicy, nodes.Len()), - filterRulesMap: make(map[types.NodeID][]tailcfg.FilterRule, nodes.Len()), - matchersForNodeMap: make(map[types.NodeID][]matcher.Match, nodes.Len()), + sshPolicyMap: xsync.NewMap[types.NodeID, *tailcfg.SSHPolicy](), + filterRulesMap: xsync.NewMap[types.NodeID, []tailcfg.FilterRule](), + matchersForNodeMap: xsync.NewMap[types.NodeID, []matcher.Match](), } _, err = pm.updateLocked() @@ -354,9 +358,9 @@ func (pm *PolicyManager) updateLocked() (bool, error) { // TODO(kradalby): This could potentially be optimized by only clearing the // policies for nodes that have changed. Particularly if the only difference is // that nodes has been added or removed. - clear(pm.sshPolicyMap) - clear(pm.filterRulesMap) - clear(pm.matchersForNodeMap) + pm.sshPolicyMap.Clear() + pm.filterRulesMap.Clear() + pm.matchersForNodeMap.Clear() } // If nothing changed, no need to update nodes @@ -400,8 +404,8 @@ func (pm *PolicyManager) NodeNeedsPeerRecompute(node types.NodeView) bool { return true } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() if pm.relayTargetIPs != nil && node.InIPSet(pm.relayTargetIPs) { return true @@ -422,10 +426,10 @@ func (pm *PolicyManager) NodeNeedsPeerRecompute(node types.NodeView) bool { // /machine/ssh/action/{src}/to/{dst}?local_user={local_user} per the // SaaS wire format. Cache is invalidated on policy reload. func (pm *PolicyManager) SSHPolicy(baseURL string, node types.NodeView) (*tailcfg.SSHPolicy, error) { - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() - if sshPol, ok := pm.sshPolicyMap[node.ID()]; ok { + if sshPol, ok := pm.sshPolicyMap.Load(node.ID()); ok { return sshPol, nil } @@ -434,7 +438,7 @@ func (pm *PolicyManager) SSHPolicy(baseURL string, node types.NodeView) (*tailcf return nil, fmt.Errorf("compiling SSH policy: %w", err) } - pm.sshPolicyMap[node.ID()] = sshPol + pm.sshPolicyMap.Store(node.ID(), sshPol) return sshPol, nil } @@ -450,8 +454,8 @@ func (pm *PolicyManager) SSHPolicy(baseURL string, node types.NodeView) (*tailcf func (pm *PolicyManager) SSHCheckParams( srcNodeID, dstNodeID types.NodeID, ) (time.Duration, bool) { - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() if pm.pol == nil || len(pm.pol.SSHs) == 0 { return 0, false @@ -584,8 +588,8 @@ func (pm *PolicyManager) Filter() ([]tailcfg.FilterRule, []matcher.Match) { return nil, nil } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() return pm.filter, pm.matchers } @@ -604,8 +608,8 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ return nil } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() // Precompute each node's subnet routes and exit-node status once; the // O(n^2) pair scans below would otherwise recompute them for every pair. @@ -726,7 +730,7 @@ func (pm *PolicyManager) filterForNodeLocked( return nil } - if rules, ok := pm.filterRulesMap[node.ID()]; ok { + if rules, ok := pm.filterRulesMap.Load(node.ID()); ok { return rules } @@ -738,7 +742,7 @@ func (pm *PolicyManager) filterForNodeLocked( } reduced := policyutil.ReduceFilterRules(node, unreduced) - pm.filterRulesMap[node.ID()] = reduced + pm.filterRulesMap.Store(node.ID(), reduced) return reduced } @@ -755,8 +759,8 @@ func (pm *PolicyManager) FilterForNode(node types.NodeView) ([]tailcfg.FilterRul return nil, nil } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() return pm.filterForNodeLocked(node), nil } @@ -776,8 +780,8 @@ func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match, return nil, nil } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() // For global policies, return the shared global matchers. // Via grants require per-node matchers because the global matchers @@ -786,7 +790,7 @@ func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match, return pm.matchers, nil } - if cached, ok := pm.matchersForNodeMap[node.ID()]; ok { + if cached, ok := pm.matchersForNodeMap.Load(node.ID()); ok { return cached, nil } @@ -794,7 +798,7 @@ func (pm *PolicyManager) MatchersForNode(node types.NodeView) ([]matcher.Match, // the stored compiled grants for this specific node. unreduced := pm.filterRulesForNodeLocked(node) matchers := matcher.MatchesFromFilterRules(unreduced) - pm.matchersForNodeMap[node.ID()] = matchers + pm.matchersForNodeMap.Store(node.ID(), matchers) return matchers, nil } @@ -813,7 +817,7 @@ func (pm *PolicyManager) SetUsers(users []types.User) (bool, error) { // Clear SSH policy map when users change to force SSH policy recomputation // This ensures that if SSH policy compilation previously failed due to missing users, // it will be retried with the new user list - clear(pm.sshPolicyMap) + pm.sshPolicyMap.Clear() changed, err := pm.updateLocked() if err != nil { @@ -866,9 +870,9 @@ func (pm *PolicyManager) SetNodes(nodes views.Slice[types.NodeView]) (bool, erro if !needsUpdate { // This ensures fresh filter rules are generated for all nodes - clear(pm.sshPolicyMap) - clear(pm.filterRulesMap) - clear(pm.matchersForNodeMap) + pm.sshPolicyMap.Clear() + pm.filterRulesMap.Clear() + pm.matchersForNodeMap.Clear() } // Always return true when nodes changed, even if filter hash didn't change // (can happen with autogroup:self or when nodes are added but don't affect rules) @@ -929,8 +933,8 @@ func (pm *PolicyManager) NodeCanHaveTag(node types.NodeView, tag string) bool { return false } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() // pm.pol is written by SetPolicy under pm.mu; reading it before the // lock races with concurrent policy reloads. @@ -991,8 +995,8 @@ func (pm *PolicyManager) TagOwnedByTags(tag string, ownerTags []string) bool { return true } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() // Owned-by delegation requires the policy's tagOwners. if pm.pol == nil { @@ -1078,8 +1082,8 @@ func (pm *PolicyManager) TagExists(tag string) bool { return false } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() // pm.pol is written by SetPolicy under pm.mu; reading it before the // lock races with concurrent policy reloads. @@ -1097,8 +1101,8 @@ func (pm *PolicyManager) NodeCanApproveRoute(node types.NodeView, route netip.Pr return false } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() // If the route to-be-approved is an exit route, then we need to check // if the node is in allowed to approve it. This is treated differently @@ -1160,8 +1164,8 @@ func (pm *PolicyManager) ViaRoutesForPeer(viewer, peer types.NodeView) types.Via return result } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() // pm.pol is written by SetPolicy under pm.mu; reading it before the // lock races with concurrent policy reloads. @@ -1379,8 +1383,8 @@ func (pm *PolicyManager) DebugString() string { // pm.pol, filter, matchers, and the derived maps are all written // under pm.mu by SetPolicy/SetUsers/SetNodes. - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() var sb strings.Builder @@ -1525,7 +1529,7 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S // Clear cache entries for affected users only. // For autogroup:self, we need to clear all nodes belonging to affected users // because autogroup:self rules depend on the entire user's device set. - for nodeID := range pm.filterRulesMap { + pm.filterRulesMap.Range(func(nodeID types.NodeID, _ []tailcfg.FilterRule) bool { // Find the user for this cached node using the already-built indexes. node, ok := newNodeMap[nodeID] if !ok { @@ -1534,10 +1538,10 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S // Node not found in either old or new list, clear it. if !ok { - delete(pm.filterRulesMap, nodeID) - delete(pm.matchersForNodeMap, nodeID) + pm.filterRulesMap.Delete(nodeID) + pm.matchersForNodeMap.Delete(nodeID) - continue + return true } // Tagged nodes don't participate in autogroup:self, so their cache @@ -1549,15 +1553,17 @@ func (pm *PolicyManager) invalidateAutogroupSelfCache(oldNodes, newNodes views.S // If the owning user is affected, clear this cache entry. if _, affected := affectedUsers[nodeUserID]; affected { - delete(pm.filterRulesMap, nodeID) - delete(pm.matchersForNodeMap, nodeID) + pm.filterRulesMap.Delete(nodeID) + pm.matchersForNodeMap.Delete(nodeID) } - } + + return true + }) if len(affectedUsers) > 0 { log.Debug(). Int("affected_users", len(affectedUsers)). - Int("remaining_cache_entries", len(pm.filterRulesMap)). + Int("remaining_cache_entries", pm.filterRulesMap.Size()). Msg("Selectively cleared autogroup:self cache for affected users") } } @@ -1591,23 +1597,27 @@ func (pm *PolicyManager) invalidateGlobalPolicyCache(newNodes views.Slice[types. } if newNode.HasNetworkChanges(oldNode) { - delete(pm.filterRulesMap, nodeID) - delete(pm.matchersForNodeMap, nodeID) + pm.filterRulesMap.Delete(nodeID) + pm.matchersForNodeMap.Delete(nodeID) } } // Remove deleted nodes from cache - for nodeID := range pm.filterRulesMap { + pm.filterRulesMap.Range(func(nodeID types.NodeID, _ []tailcfg.FilterRule) bool { if _, exists := newNodeMap[nodeID]; !exists { - delete(pm.filterRulesMap, nodeID) + pm.filterRulesMap.Delete(nodeID) } - } - for nodeID := range pm.matchersForNodeMap { + return true + }) + + pm.matchersForNodeMap.Range(func(nodeID types.NodeID, _ []matcher.Match) bool { if _, exists := newNodeMap[nodeID]; !exists { - delete(pm.matchersForNodeMap, nodeID) + pm.matchersForNodeMap.Delete(nodeID) } - } + + return true + }) } // flattenTags resolves nested tag-owner references. Cycles @@ -1789,8 +1799,8 @@ func (pm *PolicyManager) NodeCapMap(id types.NodeID) tailcfg.NodeCapMap { return nil } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() src := pm.nodeAttrsMap[id] if len(src) == 0 { @@ -1813,8 +1823,8 @@ func (pm *PolicyManager) NodeCapMaps() map[types.NodeID]tailcfg.NodeCapMap { return nil } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() out := make(map[types.NodeID]tailcfg.NodeCapMap, len(pm.nodeAttrsMap)) maps.Copy(out, pm.nodeAttrsMap) diff --git a/hscontrol/policy/v2/policy_test.go b/hscontrol/policy/v2/policy_test.go index bc8b0f256..6a0d7ada9 100644 --- a/hscontrol/policy/v2/policy_test.go +++ b/hscontrol/policy/v2/policy_test.go @@ -8,6 +8,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/juanfont/headscale/hscontrol/policy/matcher" "github.com/juanfont/headscale/hscontrol/types" + "github.com/puzpuzpuz/xsync/v4" "github.com/stretchr/testify/require" "gorm.io/gorm" "tailscale.com/net/tsaddr" @@ -122,7 +123,7 @@ func TestInvalidateAutogroupSelfCache(t *testing.T) { require.NoError(t, err) } - require.Len(t, pm.filterRulesMap, len(initialNodes)) + require.Equal(t, len(initialNodes), pm.filterRulesMap.Size()) tests := []struct { name string @@ -207,19 +208,20 @@ func TestInvalidateAutogroupSelfCache(t *testing.T) { } } - pm.filterRulesMap = make(map[types.NodeID][]tailcfg.FilterRule) + pm.filterRulesMap.Clear() + for _, n := range initialNodes { _, err := pm.FilterForNode(n.View()) require.NoError(t, err) } - initialCacheSize := len(pm.filterRulesMap) + initialCacheSize := pm.filterRulesMap.Size() require.Equal(t, len(initialNodes), initialCacheSize) pm.invalidateAutogroupSelfCache(initialNodes.ViewSlice(), tt.newNodes.ViewSlice()) // Verify the expected number of cache entries were cleared - finalCacheSize := len(pm.filterRulesMap) + finalCacheSize := pm.filterRulesMap.Size() clearedEntries := initialCacheSize - finalCacheSize require.Equal(t, tt.expectedCleared, clearedEntries, tt.description) }) @@ -498,15 +500,19 @@ func TestInvalidateGlobalPolicyCache(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { pm := &PolicyManager{ - nodes: tt.oldNodes.ViewSlice(), - filterRulesMap: tt.initialCache, + nodes: tt.oldNodes.ViewSlice(), + filterRulesMap: xsync.NewMap[types.NodeID, []tailcfg.FilterRule](), + matchersForNodeMap: xsync.NewMap[types.NodeID, []matcher.Match](), + } + for id, rules := range tt.initialCache { + pm.filterRulesMap.Store(id, rules) } pm.invalidateGlobalPolicyCache(tt.newNodes.ViewSlice()) // Verify cache state for nodeID, shouldExist := range tt.expectedCacheAfter { - _, exists := pm.filterRulesMap[nodeID] + _, exists := pm.filterRulesMap.Load(nodeID) require.Equal(t, shouldExist, exists, "node %d cache existence mismatch", nodeID) } }) diff --git a/hscontrol/policy/v2/sshtest.go b/hscontrol/policy/v2/sshtest.go index ec0abb03b..89d339c76 100644 --- a/hscontrol/policy/v2/sshtest.go +++ b/hscontrol/policy/v2/sshtest.go @@ -122,8 +122,8 @@ func (pm *PolicyManager) RunSSHTests() error { return nil } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() cache := make(map[types.NodeID]*tailcfg.SSHPolicy) results := runSSHPolicyTests(pm.pol, pm.users, pm.nodes, cache) diff --git a/hscontrol/policy/v2/test.go b/hscontrol/policy/v2/test.go index 876204501..da79f7c72 100644 --- a/hscontrol/policy/v2/test.go +++ b/hscontrol/policy/v2/test.go @@ -215,8 +215,8 @@ func (pm *PolicyManager) RunTests() error { return nil } - pm.mu.Lock() - defer pm.mu.Unlock() + pm.mu.RLock() + defer pm.mu.RUnlock() results := runPolicyTests(pm.pol, pm.filter, pm.users, pm.nodes) From d528686f14f309beedc9f2307ca108aad819a4d0 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 1 Jul 2026 09:40:28 +0000 Subject: [PATCH 116/127] mapper,policy: add reconnect-storm and lock-concurrency regression tests TestInitialMapNotStarvedByReconnectStorm reproduces the #3346 stall; TestPolicyManagerConcurrentReads guards the RLock cache access under -race. Updates #3346 --- hscontrol/mapper/initialmap_storm_test.go | 189 ++++++++++++++++++ .../policy/v2/policy_concurrency_test.go | 103 ++++++++++ 2 files changed, 292 insertions(+) create mode 100644 hscontrol/mapper/initialmap_storm_test.go create mode 100644 hscontrol/policy/v2/policy_concurrency_test.go diff --git a/hscontrol/mapper/initialmap_storm_test.go b/hscontrol/mapper/initialmap_storm_test.go new file mode 100644 index 000000000..877a8d97b --- /dev/null +++ b/hscontrol/mapper/initialmap_storm_test.go @@ -0,0 +1,189 @@ +//go:build !race + +// This is a timing-sensitive performance regression test; the race detector's +// ~10x slowdown makes its wall-clock assertion meaningless, so it is excluded +// from -race builds. The concurrency correctness of the policy lock change it +// guards is covered under -race by TestPolicyManagerConcurrentReads in +// hscontrol/policy/v2. + +package mapper + +import ( + "net/netip" + "runtime" + "slices" + "sync" + "testing" + "time" + + "github.com/juanfont/headscale/hscontrol/db" + "github.com/juanfont/headscale/hscontrol/derp" + "github.com/juanfont/headscale/hscontrol/state" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" +) + +// setupStormBatcher builds a real state+batcher with production-default +// NodeStore batching so the reconnect-storm contention is realistic. It mirrors +// setupBatcherWithTestData but lets the test control BatcherWorkers and the +// policy. +func setupStormBatcher(tb testing.TB, nodeCount, workers int, policy string) (*TestData, func()) { + tb.Helper() + + tmpDir := tb.TempDir() + prefixV4 := netip.MustParsePrefix("100.64.0.0/10") + prefixV6 := netip.MustParsePrefix("fd7a:115c:a1e0::/48") + + cfg := &types.Config{ + Database: types.DatabaseConfig{ + Type: types.DatabaseSqlite, + Sqlite: types.SqliteConfig{Path: tmpDir + "/headscale_test.db"}, + }, + PrefixV4: &prefixV4, + PrefixV6: &prefixV6, + IPAllocation: types.IPAllocationStrategySequential, + BaseDomain: "headscale.test", + Policy: types.PolicyConfig{Mode: types.PolicyModeDB}, + DERP: types.DERPConfig{ + ServerEnabled: false, + DERPMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{999: {RegionID: 999}}, + }, + }, + Tuning: types.Tuning{ + BatchChangeDelay: 10 * time.Millisecond, + BatcherWorkers: workers, + // Production defaults: coalesce writes so the storm is not + // exaggerated by an unrealistically small NodeStore batch. + NodeStoreBatchSize: 100, + NodeStoreBatchTimeout: 500 * time.Millisecond, + }, + } + + database, err := db.NewHeadscaleDatabase(cfg) + require.NoError(tb, err) + + users := database.CreateUsersForTest(1, "testuser") + dbNodes := database.CreateRegisteredNodesForTest(users[0], nodeCount, "node") + + allNodes := make([]node, 0, nodeCount) + for i := range dbNodes { + allNodes = append(allNodes, node{ + n: dbNodes[i], + ch: make(chan *tailcfg.MapResponse, normalBufferSize), + }) + } + + st, err := state.NewState(cfg) + require.NoError(tb, err) + + derpMap, err := derp.GetDERPMap(cfg.DERP) + require.NoError(tb, err) + st.SetDERPMap(derpMap) + + _, err = st.SetPolicy([]byte(policy)) + require.NoError(tb, err) + + batcher := wrapBatcherForTest(NewBatcherAndMapper(cfg, st), st) + batcher.Start() + + td := &TestData{ + Database: database, + Users: users, + Nodes: allNodes, + State: st, + Config: cfg, + Batcher: batcher, + } + + return td, func() { + batcher.Close() + st.Close() + database.Close() + } +} + +// TestInitialMapNotStarvedByReconnectStorm reproduces juanfont/headscale#3346. +// +// When every node redials at once (e.g. after a server upgrade restart), each +// connection writes the NodeStore (UpdateNodeFromMapRequest + Connect) and the +// batcher generates its initial map. All of that reads the policy through the +// PolicyManager. Before the fix the PolicyManager guarded every read with a +// single exclusive mutex, so the NodeStore writer's O(n^2) BuildPeerMap and +// every node's FilterForNode serialised against each other. On a per-node +// filter policy (autogroup:self, via, relay grants) each hold is expensive, so +// under the storm time-to-initial-map grew without bound. +// +// On the production server in #3346 this drove the batcher's per-node +// total.duration from ~4s to ~76s; tailscale clients aborted the map POST +// first and reported +// +// PollNetMap: Post ".../machine/map": unexpected EOF +// +// then redialled, feeding the storm so it never converged. An allow-all policy +// does NOT reproduce this — BuildPeerMap is cheap there; the per-node filter +// path is what makes it expensive, matching a real deployment's ACLs. +// +// The fix makes PolicyManager reads take a shared RLock so map generation runs +// concurrently. AddNode blocks until the initial map is generated and handed to +// the node channel, so its wall-clock duration is the time-to-initial-map the +// client experiences. Without the fix this test's slowest node takes ~10s+ at +// this scale (lock-bound, and more workers do not help); with it, generation +// parallelises across workers and stays well within a client's patience. +func TestInitialMapNotStarvedByReconnectStorm(t *testing.T) { + if testing.Short() { + t.Skip("timing-sensitive storm regression; skipped in -short") + } + + const ( + nodeCount = 300 + + // A per-node-filter policy: forces BuildPeerMap and FilterForNode onto + // the slow path that recompiles filter rules per node, the same shape + // as a real ACL using autogroup:self / via / relay grants. + perNodeFilterPolicy = `{"acls":[{"action":"accept","src":["autogroup:member"],"dst":["autogroup:self:*"]}]}` + + // Deliberately roomy so it passes on CI's few-core runners, where the + // single-writer BuildPeerMap sets the floor (~10s) whatever the reads + // do. It still trips on a hang or a return to the ~76s serialised + // behaviour; the fine-grained concurrency is verified separately by + // TestPolicyManagerConcurrentReads under -race. + maxAcceptableLatency = 30 * time.Second + ) + + // Use the real available parallelism, as production does. + workers := runtime.NumCPU() + + td, cleanup := setupStormBatcher(t, nodeCount, workers, perNodeFilterPolicy) + defer cleanup() + + latencies := make([]time.Duration, nodeCount) + + var wg sync.WaitGroup + + for i := range td.Nodes { + wg.Go(func() { + n := &td.Nodes[i] + + start := time.Now() + err := td.Batcher.AddNode(n.n.ID, n.ch, tailcfg.CapabilityVersion(100), nil) + latencies[i] = time.Since(start) + + assert.NoError(t, err) //nolint:testifylint // assert (not require) is correct off the test goroutine + }) + } + + wg.Wait() + + slices.Sort(latencies) + p50 := latencies[len(latencies)/2] + p95 := latencies[len(latencies)*95/100] + maxLatency := latencies[len(latencies)-1] + t.Logf("initial-map latency over %d nodes (workers=%d): p50=%s p95=%s max=%s", + nodeCount, workers, p50, p95, maxLatency) + + require.Less(t, maxLatency, maxAcceptableLatency, + "slowest initial map took %s: policy reads are serialising instead of running concurrently (issue #3346)", maxLatency) +} diff --git a/hscontrol/policy/v2/policy_concurrency_test.go b/hscontrol/policy/v2/policy_concurrency_test.go new file mode 100644 index 000000000..b4b759f59 --- /dev/null +++ b/hscontrol/policy/v2/policy_concurrency_test.go @@ -0,0 +1,103 @@ +package v2 + +import ( + "fmt" + "sync" + "testing" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +// TestPolicyManagerConcurrentReads is the correctness guard for the #3346 fix: +// PolicyManager read methods take a shared RLock and populate their per-node +// caches (filterRulesMap, matchersForNodeMap) concurrently. This test hammers +// those reads from many goroutines while a writer mutates the node set, so the +// race detector catches any unsafe access to the shared caches or policy state. +// +// It uses an autogroup:self policy so reads take the per-node filter slow path +// — the same path that made #3346's reconnect storm expensive — which is where +// the lazy caches are written. +func TestPolicyManagerConcurrentReads(t *testing.T) { + users := types.Users{ + {Model: gorm.Model{ID: 1}, Name: "user1", Email: "user1@headscale.net"}, + {Model: gorm.Model{ID: 2}, Name: "user2", Email: "user2@headscale.net"}, + {Model: gorm.Model{ID: 3}, Name: "user3", Email: "user3@headscale.net"}, + } + + policy := `{ + "acls": [ + { + "action": "accept", + "src": ["autogroup:member"], + "dst": ["autogroup:self:*"] + } + ] + }` + + const nodeCount = 60 + + nodes := make(types.Nodes, 0, nodeCount) + for i := range nodeCount { + n := node( + fmt.Sprintf("node%d", i), + fmt.Sprintf("100.64.0.%d", i+1), + fmt.Sprintf("fd7a:115c:a1e0::%d", i+1), + users[i%len(users)], + ) + n.ID = types.NodeID(i + 1) //nolint:gosec // safe in test + nodes = append(nodes, n) + } + + pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice()) + require.NoError(t, err) + + const ( + readers = 16 + iterations = 60 + mutatorReloads = 30 + ) + + var wg sync.WaitGroup + + // Concurrent readers exercise every converted RLock read path, including + // the two lazily populated per-node caches. Assertions inside the + // goroutines use assert (not require) so a failure does not call + // t.FailNow from a non-test goroutine. + for r := range readers { + wg.Go(func() { + for i := range iterations { + nv := nodes[(r+i)%len(nodes)].View() + + rules, err := pm.FilterForNode(nv) + assert.NoError(t, err) //nolint:testifylint // assert (not require) is correct off the test goroutine + assert.NotNil(t, rules) + + _, err = pm.MatchersForNode(nv) + assert.NoError(t, err) //nolint:testifylint // assert (not require) is correct off the test goroutine + + pm.Filter() + pm.NodeCapMap(nv.ID()) + + // BuildPeerMap is the O(n^2) writer-side read; exercise it + // under RLock too, but not every iteration. + if i%8 == 0 { + assert.NotNil(t, pm.BuildPeerMap(nodes.ViewSlice())) + } + } + }) + } + + // A writer repeatedly re-sets the node set, invalidating and racing the + // caches the readers are populating. + wg.Go(func() { + for range mutatorReloads { + _, err := pm.SetNodes(nodes.ViewSlice()) + assert.NoError(t, err) //nolint:testifylint // assert (not require) is correct off the test goroutine + } + }) + + wg.Wait() +} From 08956d51a45c52434b1dbc7aa3d33fd373fb0192 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 26 Jun 2026 14:19:19 +0000 Subject: [PATCH 117/127] mapper: skip peers with invalid names instead of failing the map A peer whose GivenName fails GetFQDN aborted the whole map for every node that could see it. Drop and log it; SSH policy errors degrade too. Fixes #3346 --- hscontrol/mapper/builder.go | 26 +++++++++- hscontrol/mapper/mapper_test.go | 89 +++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/hscontrol/mapper/builder.go b/hscontrol/mapper/builder.go index 4a8e25aed..eaf4dde44 100644 --- a/hscontrol/mapper/builder.go +++ b/hscontrol/mapper/builder.go @@ -9,6 +9,8 @@ import ( "github.com/juanfont/headscale/hscontrol/policy" policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util/zlog/zf" + "github.com/rs/zerolog/log" "tailscale.com/tailcfg" "tailscale.com/types/views" "tailscale.com/util/multierr" @@ -152,7 +154,15 @@ func (b *MapResponseBuilder) WithSSHPolicy() *MapResponseBuilder { sshPolicy, err := b.mapper.state.SSHPolicy(node) if err != nil { - b.addError(err) + // SSH policy is optional for a node to function. Rather than fail the + // whole map (leaving the node unable to connect), log and continue + // without it; the node still receives a usable netmap. + log.Warn().Caller(). + Err(err). + Uint64(zf.NodeID, node.ID().Uint64()). + Str(zf.NodeHostname, node.Hostname()). + Msg("building map response: skipping SSH policy for node; node will receive a map without SSH rules") + return b } @@ -284,7 +294,19 @@ func (b *MapResponseBuilder) buildTailPeers(peers views.Slice[types.NodeView]) ( return b.mapper.state.RoutesForPeer(node, peer, matchers) }, b.mapper.cfg, allCapMaps[peer.ID()]) if err != nil { - return nil, err + // One peer with invalid data (e.g. an empty or over-long + // GivenName that fails GetFQDN) must not blank out the map for + // every node that can see it. Drop the offending peer, log it + // with the identity an operator needs to fix it, and keep + // building from the remaining valid peers. + log.Warn().Caller(). + Err(err). + Uint64(zf.NodeID, peer.ID().Uint64()). + Str(zf.NodeHostname, peer.Hostname()). + Uint64("map.viewer.node.id", b.nodeID.Uint64()). + Msgf("dropping peer %d from map response: invalid node data; fix with `headscale nodes rename %d `", peer.ID(), peer.ID()) + + continue } // [tailcfg.Node.CapMap] on a peer carries the small set of diff --git a/hscontrol/mapper/mapper_test.go b/hscontrol/mapper/mapper_test.go index 94880c783..9b19c9498 100644 --- a/hscontrol/mapper/mapper_test.go +++ b/hscontrol/mapper/mapper_test.go @@ -3,6 +3,7 @@ package mapper import ( "fmt" "net/netip" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -536,6 +537,94 @@ func TestBuildFromChangeVisibilityMatchesFullMap(t *testing.T) { } } +// TestFullMapResponseSurvivesPeerWithInvalidName proves a single node with an +// FQDN-invalid GivenName must not break map generation for its peers. +// +// A node whose stored GivenName is empty (ErrNodeHasNoGivenName) or yields an +// FQDN longer than MaxHostnameLength (ErrHostnameTooLong) makes GetFQDN, and +// therefore TailNode, return an error. buildTailPeers used to abort the entire +// peer list on the first such error, so MapResponseBuilder.Build() failed for +// every node that could see the bad peer; on the initial-connection path that +// surfaced as "PollNetMap: ... unexpected EOF" and the "Unable to connect to +// the Tailscale coordination server" health warning. A legacy DB row loads +// verbatim (NewNodeStore reads db.ListNodes() without re-sanitising names), so +// the bad peer persists across restart. The build for an unaffected viewer +// must succeed: the bad peer is dropped, valid peers and self survive. +func TestFullMapResponseSurvivesPeerWithInvalidName(t *testing.T) { + for _, tt := range []struct { + name string + badName string + }{ + {"empty given name", ""}, + {"over-long fqdn", strings.Repeat("a", types.MaxHostnameLength+1)}, + } { + t.Run(tt.name, func(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) + + user := database.CreateUserForTest("u1") + n1 := database.CreateRegisteredNodeForTest(user, "n1") // viewer, valid + bad := database.CreateRegisteredNodeForTest(user, "bad") // peer, name corrupted below + good := database.CreateRegisteredNodeForTest(user, "good") // peer, valid control + + // Simulate a legacy/corrupt row that v29 loads verbatim. + require.NoError(t, database.DB. + Model(&types.Node{}). + Where("id = ?", bad.ID). + Update("given_name", tt.badName).Error) + require.NoError(t, database.Close()) + + s, err := state.NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + // Allow-all so n1 sees both peers; the bad one must still be dropped. + _, err = s.SetPolicy([]byte(`{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`)) + require.NoError(t, err) + + m := &mapper{state: s, cfg: cfg} + capVer := tailcfg.CurrentCapabilityVersion + + resp, err := m.fullMapResponse(n1.ID, capVer) + require.NoError(t, err, "n1's map must build despite a peer with an invalid name") + require.NotNil(t, resp) + require.NotNil(t, resp.Node, "n1 must receive its own self node") + + peers := map[tailcfg.NodeID]bool{} + for _, p := range resp.Peers { + peers[p.ID] = true + } + + assert.False(t, peers[bad.ID.NodeID()], "the peer with an invalid name must be dropped") + assert.True(t, peers[good.ID.NodeID()], "valid peers must remain in the map") + }) + } +} + // 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() From 4e4512c4b730ddc5f2e71a3f8f3f8dc10a7c8872 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 26 Jun 2026 14:19:33 +0000 Subject: [PATCH 118/127] poll: return an HTTP error on long-poll setup failure A bare return sent an empty 200 the client read as "unexpected EOF" and retried forever; emit a real error instead. Updates #3346 --- hscontrol/poll.go | 10 ++++ hscontrol/poll_test.go | 127 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/hscontrol/poll.go b/hscontrol/poll.go index 96f782d44..35657026c 100644 --- a/hscontrol/poll.go +++ b/hscontrol/poll.go @@ -230,6 +230,11 @@ func (m *mapSession) serveLongPoll() { mapReqChange, err := m.h.state.UpdateNodeFromMapRequest(m.node.ID, m.req) if err != nil { m.log.Error().Caller().Err(err).Msg("failed to update node from initial MapRequest") + // Write an explicit error rather than returning silently: a bare + // return leaves net/http to send an empty 200, which the client + // reads as "unexpected EOF" and retries forever (issue #3346). + httpError(m.w, err) + return } @@ -252,6 +257,11 @@ func (m *mapSession) serveLongPoll() { // time between the node connecting and the batcher being ready. if err := m.h.mapBatcher.AddNode(m.node.ID, m.ch, m.capVer, m.stopFromBatcher); err != nil { //nolint:noinlineerr m.log.Error().Caller().Err(err).Msg("failed to add node to batcher") + // Write an explicit error rather than returning silently: a bare + // return leaves net/http to send an empty 200, which the client + // reads as "unexpected EOF" and retries forever (issue #3346). + httpError(m.w, err) + return } diff --git a/hscontrol/poll_test.go b/hscontrol/poll_test.go index e294d5e71..71ce08d9a 100644 --- a/hscontrol/poll_test.go +++ b/hscontrol/poll_test.go @@ -7,8 +7,10 @@ import ( "testing" "time" + "github.com/juanfont/headscale/hscontrol/db" "github.com/juanfont/headscale/hscontrol/mapper" "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" @@ -89,6 +91,131 @@ func (w *delayedSuccessResponseWriter) WriteCount() int { return w.writeCount } +// recordingResponseWriter records the status code and whether anything was +// written, so a test can tell an explicit error response apart from a handler +// that returned without writing (which net/http turns into an empty 200 the +// client reads as "unexpected EOF"). +type recordingResponseWriter struct { + mu sync.Mutex + header http.Header + status int + writes int +} + +func (w *recordingResponseWriter) Header() http.Header { + if w.header == nil { + w.header = make(http.Header) + } + + return w.header +} + +func (w *recordingResponseWriter) WriteHeader(code int) { + w.mu.Lock() + defer w.mu.Unlock() + + if w.status == 0 { + w.status = code + } +} + +func (w *recordingResponseWriter) Write(data []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + + if w.status == 0 { + w.status = http.StatusOK + } + + w.writes++ + + return len(data), nil +} + +func (w *recordingResponseWriter) Flush() {} + +func (w *recordingResponseWriter) statusCode() int { + w.mu.Lock() + defer w.mu.Unlock() + + return w.status +} + +// TestServeLongPollWritesErrorWhenInitialMapFails proves that when the initial +// map cannot be generated (here: the node's own GivenName is invalid, so +// WithSelfNode fails and AddNode errors), serveLongPoll writes an explicit HTTP +// error instead of returning with no body. Returning empty leaves net/http to +// send an empty 200, which the Tailscale client reports as +// "PollNetMap: ... unexpected EOF" and retries forever (issue #3346). +func TestServeLongPollWritesErrorWhenInitialMapFails(t *testing.T) { + app := createTestApp(t) + user := app.state.CreateUserForTest("self-bad-name-user") + createdNode := app.state.CreateRegisteredNodeForTest(user, "self-bad-name-node") + + // Corrupt the node's stored name to empty so GetFQDN fails for itself, + // then reload state so the bad row enters the NodeStore verbatim. + app.mapBatcher.Close() + require.NoError(t, app.state.Close()) + + database, err := db.NewHeadscaleDatabase(app.cfg) + require.NoError(t, err) + require.NoError(t, database.DB. + Model(&types.Node{}). + Where("id = ?", createdNode.ID). + Update("given_name", "").Error) + require.NoError(t, database.Close()) + + app.state, err = state.NewState(app.cfg) + require.NoError(t, err) + + app.mapBatcher = mapper.NewBatcherAndMapper(app.cfg, app.state) + app.mapBatcher.Start() + + t.Cleanup(func() { + app.mapBatcher.Close() + require.NoError(t, app.state.Close()) + }) + + nodeView, ok := app.state.GetNodeByID(createdNode.ID) + require.True(t, ok) + + node := nodeView.AsStruct() + + ctx, cancel := context.WithCancel(context.Background()) + writer := &recordingResponseWriter{} + session := app.newMapSession(ctx, tailcfg.MapRequest{ + Stream: true, + Version: tailcfg.CapabilityVersion(100), + }, writer, node) + + serveDone := make(chan struct{}) + + go func() { + session.serveLongPoll() + close(serveDone) + }() + + t.Cleanup(func() { + // Break the post-disconnect reconnect wait so the goroutine exits. + dummyCh := make(chan *tailcfg.MapResponse, 1) + _ = app.mapBatcher.AddNode(node.ID, dummyCh, tailcfg.CapabilityVersion(100), nil) + + cancel() + + select { + case <-serveDone: + case <-time.After(2 * time.Second): + } + + _ = app.mapBatcher.RemoveNode(node.ID, dummyCh) + }) + + assert.Eventually(t, func() bool { + return writer.statusCode() >= http.StatusInternalServerError + }, 2*time.Second, 10*time.Millisecond, + "serveLongPoll must write an HTTP error response when the initial map cannot be built, not an empty 200") +} + // TestGitHubIssue3129_TransientlyBlockedWriteDoesNotLeaveLiveStaleSession // tests the scenario reported in // https://github.com/juanfont/headscale/issues/3129. From c497612c999597e0ed5fd59fa748f50b00a44a80 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 26 Jun 2026 14:19:42 +0000 Subject: [PATCH 119/127] state: reject renames whose FQDN exceeds the hostname limit A valid label can still overflow 255 chars under a long base_domain; gate RenameNode with the new types.ValidateGivenName. Updates #3346 --- hscontrol/state/rename_test.go | 40 ++++++++++++++++++++++++++++++++++ hscontrol/state/state.go | 5 ++++- hscontrol/types/node.go | 23 +++++++++++++++++++ hscontrol/types/node_test.go | 27 +++++++++++++++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 hscontrol/state/rename_test.go diff --git a/hscontrol/state/rename_test.go b/hscontrol/state/rename_test.go new file mode 100644 index 000000000..b141147be --- /dev/null +++ b/hscontrol/state/rename_test.go @@ -0,0 +1,40 @@ +package state + +import ( + "strings" + "testing" + + "github.com/juanfont/headscale/hscontrol/db" + "github.com/stretchr/testify/require" +) + +// TestRenameNodeRejectsNameExceedingFQDNLimit proves RenameNode rejects a name +// that is a valid DNS label but whose FQDN, under the configured base_domain, +// exceeds MaxHostnameLength. Without the FQDN-length gate such a name persists +// and then breaks map generation for the node and its peers (issue #3346): +// admin-facing writes must not be able to introduce an unmappable name. +func TestRenameNodeRejectsNameExceedingFQDNLimit(t *testing.T) { + dbPath := t.TempDir() + "/headscale.db" + cfg := persistTestConfig(dbPath) + // A long base domain so a 63-char label overflows the 255-char FQDN bound. + cfg.BaseDomain = strings.Repeat("b", 200) + ".example.com" + + database, err := db.NewHeadscaleDatabase(cfg) + require.NoError(t, err) + + user := database.CreateUserForTest("rename-user") + node := database.CreateRegisteredNodeForTest(user, "rename-node") + require.NoError(t, database.Close()) + + s, err := NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + // Valid 63-char DNS label, but the resulting FQDN exceeds 255 chars. + _, _, err = s.RenameNode(node.ID, strings.Repeat("a", 63)) + require.Error(t, err, "rename to a name whose FQDN exceeds the limit must be rejected") + + // A short, valid name is still accepted. + _, _, err = s.RenameNode(node.ID, "short") + require.NoError(t, err) +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index 7432e2bbb..18cc00e87 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -1032,7 +1032,10 @@ func (s *State) SetApprovedRoutes(nodeID types.NodeID, routes []netip.Prefix) (t // auto-sanitisation) and collisions error out rather than silently // bumping a user-facing label. See HOSTNAME.md for the CLI contract. func (s *State) RenameNode(nodeID types.NodeID, newName string) (types.NodeView, change.Change, error) { - err := dnsname.ValidLabel(newName) + // Validate the label AND that the resulting FQDN fits MaxHostnameLength: + // a valid 63-char label can still overflow under a long base_domain, and + // an unmappable name would break this node and its peers (issue #3346). + err := types.ValidateGivenName(newName, s.cfg.BaseDomain) if err != nil { return types.NodeView{}, change.Change{}, fmt.Errorf("%w: %w", ErrGivenNameInvalid, err) } diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index dd3dc92d1..ef9467373 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -19,6 +19,7 @@ import ( "tailscale.com/tailcfg" "tailscale.com/types/key" "tailscale.com/types/views" + "tailscale.com/util/dnsname" ) var ( @@ -517,6 +518,28 @@ func (node *Node) GetFQDN(baseDomain string) (string, error) { return hostname, nil } +// ValidateGivenName reports whether givenName is usable as a node's DNS label: +// a valid DNS label that, combined with baseDomain, yields an FQDN within +// MaxHostnameLength. Admin-facing write paths (e.g. node rename) reject names +// that fail this, since the mapper cannot build a map for a node — or any of +// its peers — whose GetFQDN fails. Derived paths sanitise/coerce instead. +func ValidateGivenName(givenName, baseDomain string) error { + err := dnsname.ValidLabel(givenName) + if err != nil { + return fmt.Errorf("%q is not a valid DNS label: %w", givenName, err) + } + + // Reuse GetFQDN so the length bound stays identical to what the mapper + // enforces; a valid 63-char label can still overflow under a long + // base_domain. + _, err = (&Node{GivenName: givenName}).GetFQDN(baseDomain) + if err != nil { + return err + } + + return nil +} + // AnnouncedRoutes returns the list of routes the node announces, as // reported by the client in [tailcfg.Hostinfo.RoutableIPs]. Announcement alone // does not grant visibility — see [Node.SubnetRoutes] for approval-gated diff --git a/hscontrol/types/node_test.go b/hscontrol/types/node_test.go index 3c8a5ba8b..6a23fc9fb 100644 --- a/hscontrol/types/node_test.go +++ b/hscontrol/types/node_test.go @@ -417,6 +417,33 @@ func TestNodeFQDN(t *testing.T) { } } +func TestValidateGivenName(t *testing.T) { + tests := []struct { + name string + givenName string + baseDomain string + wantErr bool + }{ + {"valid", "test", "example.com", false}, + {"empty", "", "example.com", true}, + {"invalid label chars", "not valid", "example.com", true}, + {"label too long", strings.Repeat("a", 64), "example.com", true}, + // A valid 63-char label whose FQDN overflows only because the base + // domain is long: ValidLabel passes, the FQDN-length bound rejects it. + {"fqdn too long under long base domain", strings.Repeat("a", 63), strings.Repeat("b", 200) + ".example.com", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ValidateGivenName(tc.givenName, tc.baseDomain) + if (err != nil) != tc.wantErr { + t.Errorf("ValidateGivenName(%q, %q) error = %v, wantErr %v", + tc.givenName, tc.baseDomain, err, tc.wantErr) + } + }) + } +} + func TestPeerChangeFromMapRequest(t *testing.T) { nKeys := []key.NodePublic{ key.NewNode().Public(), From 4946d1c88d25aa9c3d426437aca4520b5376a86c Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 26 Jun 2026 14:19:55 +0000 Subject: [PATCH 120/127] state: log nodes with map-breaking data at startup Scan a node-health check registry at boot and log each node whose name can't form a valid FQDN, with the rename fix. Log-only, no mutation. Updates #3346 --- hscontrol/state/node_health.go | 92 +++++++++++++++++++++++++++++ hscontrol/state/node_health_test.go | 67 +++++++++++++++++++++ hscontrol/state/state.go | 11 +++- 3 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 hscontrol/state/node_health.go create mode 100644 hscontrol/state/node_health_test.go diff --git a/hscontrol/state/node_health.go b/hscontrol/state/node_health.go new file mode 100644 index 000000000..4edaf3986 --- /dev/null +++ b/hscontrol/state/node_health.go @@ -0,0 +1,92 @@ +package state + +import ( + "fmt" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util/zlog/zf" + "github.com/rs/zerolog/log" +) + +// nodeHealthCheck names a class of stored-node-data defect that breaks normal +// operation and explains how to fix it. ok == true means the node passes the +// check. This is the extension point for node-data validation: add a check +// here as new corrupt-data classes surface (nil hostinfo, invalid IPs, +// tags-XOR-user violations, ...) and both the boot scan and any future caller +// run the whole set. +type nodeHealthCheck struct { + name string + check func(nv types.NodeView, cfg *types.Config) (problem, fixHint string, ok bool) +} + +// nodeHealthChecks is the registry of node-data health checks. Today it carries +// the one issue #3346 needs; append to it rather than reshaping callers. +var nodeHealthChecks = []nodeHealthCheck{givenNameMapsToValidFQDN} + +// givenNameMapsToValidFQDN flags a node whose stored GivenName cannot produce a +// valid FQDN (empty, or longer than MaxHostnameLength once base_domain is +// applied). Such a node cannot be rendered into a netmap — neither its own nor +// any peer's — so it must be renamed to recover. +var givenNameMapsToValidFQDN = nodeHealthCheck{ + name: "given-name-maps-to-valid-fqdn", + check: func(nv types.NodeView, cfg *types.Config) (string, string, bool) { + err := types.ValidateGivenName(nv.GivenName(), cfg.BaseDomain) + if err != nil { + return err.Error(), fmt.Sprintf("headscale nodes rename %d ", nv.ID()), false + } + + return "", "", true + }, +} + +// nodeHealthFinding is a single failed check for a single node. +type nodeHealthFinding struct { + nodeID types.NodeID + hostname string + check string + problem string + fixHint string +} + +// scanNodeHealth runs every registered check against every node in the store +// and returns one finding per failure. It only reports — it never mutates a +// node — so an operator can repair the underlying data without the server +// silently rewriting a user-visible name. +func (s *State) scanNodeHealth() []nodeHealthFinding { + var findings []nodeHealthFinding + + for _, nv := range s.nodeStore.ListNodes().All() { + for _, c := range nodeHealthChecks { + problem, fixHint, ok := c.check(nv, s.cfg) + if ok { + continue + } + + findings = append(findings, nodeHealthFinding{ + nodeID: nv.ID(), + hostname: nv.Hostname(), + check: c.name, + problem: problem, + fixHint: fixHint, + }) + } + } + + return findings +} + +// logNodeHealth scans the store once and logs an actionable warning per +// finding. Called at startup so an operator learns — by node id and fix +// command — about stored data that will break map generation, without the +// server changing anything itself. +func (s *State) logNodeHealth() { + for _, f := range s.scanNodeHealth() { + log.Warn(). + Uint64(zf.NodeID, f.nodeID.Uint64()). + Str(zf.NodeHostname, f.hostname). + Str("check", f.check). + Str("problem", f.problem). + Str("fix", f.fixHint). + Msg("node has invalid data that breaks map generation; rename it to restore connectivity") + } +} diff --git a/hscontrol/state/node_health_test.go b/hscontrol/state/node_health_test.go new file mode 100644 index 000000000..cd61a25b8 --- /dev/null +++ b/hscontrol/state/node_health_test.go @@ -0,0 +1,67 @@ +package state + +import ( + "testing" + + "github.com/juanfont/headscale/hscontrol/db" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/require" +) + +func TestGivenNameMapsToValidFQDNCheck(t *testing.T) { + cfg := &types.Config{BaseDomain: "example.com"} + + _, _, ok := givenNameMapsToValidFQDN.check((&types.Node{ID: 1, GivenName: "valid"}).View(), cfg) + require.True(t, ok, "a valid given name must pass the check") + + problem, fixHint, ok := givenNameMapsToValidFQDN.check((&types.Node{ID: 7, GivenName: ""}).View(), cfg) + require.False(t, ok, "an empty given name must fail the check") + require.NotEmpty(t, problem) + require.Contains(t, fixHint, "rename 7", "fix hint must name the offending node") +} + +// TestScanNodeHealthReportsInvalidNameWithoutMutating proves the boot scan +// reports a node whose stored name would break map generation (issue #3346) +// with an actionable fix, and that it never rewrites the stored name — the +// maintainer's decision is log-only, no silent mutation. +func TestScanNodeHealthReportsInvalidNameWithoutMutating(t *testing.T) { + dbPath := t.TempDir() + "/headscale.db" + cfg := persistTestConfig(dbPath) + + database, err := db.NewHeadscaleDatabase(cfg) + require.NoError(t, err) + + user := database.CreateUserForTest("scan-user") + bad := database.CreateRegisteredNodeForTest(user, "scan-bad") + good := database.CreateRegisteredNodeForTest(user, "scan-good") + + require.NoError(t, database.DB. + Model(&types.Node{}). + Where("id = ?", bad.ID). + Update("given_name", "").Error) + require.NoError(t, database.Close()) + + s, err := NewState(cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + findings := s.scanNodeHealth() + + var badFinding *nodeHealthFinding + + for i := range findings { + require.NotEqual(t, good.ID, findings[i].nodeID, "a valid node must not be reported") + + if findings[i].nodeID == bad.ID { + badFinding = &findings[i] + } + } + + require.NotNil(t, badFinding, "a node with an invalid name must be reported") + require.Contains(t, badFinding.fixHint, "rename", "finding must carry an actionable fix") + + // Log-only: neither the scan nor boot may rewrite the stored name. + nv, ok := s.GetNodeByID(bad.ID) + require.True(t, ok) + require.Empty(t, nv.GivenName(), "boot scan must not mutate the stored name") +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index 18cc00e87..ddb410eb7 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -277,7 +277,7 @@ func NewState(cfg *types.Config) (*State, error) { ) nodeStore.Start() - return &State{ + s := &State{ cfg: cfg, db: db, @@ -289,7 +289,14 @@ func NewState(cfg *types.Config) (*State, error) { sshCheckAuth: make(map[sshCheckPair]time.Time), registerLocks: xsync.NewMap[key.MachinePublic, *sync.Mutex](), - }, nil + } + + // Surface nodes whose stored data would break map generation (e.g. an + // invalid given name from a legacy row) so an operator can fix them. This + // only logs; it never mutates a node's stored name at boot. + s.logNodeHealth() + + return s, nil } // Close gracefully shuts down the [State] instance and releases all resources. From de9db9c811523a2667183d1e50a88329d5d2d013 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Fri, 26 Jun 2026 14:20:26 +0000 Subject: [PATCH 121/127] CHANGELOG: note 0.29.2 invalid-name map fix Updates #3346 --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd5da831..f841378d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,19 @@ keys remain all-access. - Fix map generation serializing on the policy lock, so a mass reconnect on `autogroup:self`, via or relay policies no longer stalls clients into `unexpected EOF` retry loops [#3358](https://github.com/juanfont/headscale/pull/3358) +A node whose stored name could not be turned into a valid FQDN — empty, or long +enough that the full hostname exceeded 255 characters once the base domain was +applied — broke map delivery for itself and for every peer that could see it. +Affected clients looped on `PollNetMap: unexpected EOF` and reported being +unable to reach the coordination server, while other clients were unaffected. +The mapper now drops such a node from its peers' maps instead of failing the +whole response, the long-poll handler returns an explicit error rather than an +empty response, node renames that would exceed the hostname limit are rejected, +and startup logs each node whose stored name needs fixing together with the +command to fix it. + +[#3349](https://github.com/juanfont/headscale/pull/3349) + ## 0.29.1 (2026-06-18) **Minimum supported Tailscale client version: v1.80.0** From fc6f216b616778b6347a91148e9763cd22c6de94 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 1 Jul 2026 08:38:57 +0000 Subject: [PATCH 122/127] hscontrol: register /ts2021 for WebSocket GET The chi migration dropped GET; JS/WASM control clients open /ts2021 as a WebSocket GET and were rejected with 405 before reaching NoiseUpgradeHandler. Fixes #3357 --- hscontrol/app.go | 5 ++++ hscontrol/noise_test.go | 65 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/hscontrol/app.go b/hscontrol/app.go index 8feef1065..fbbd6683e 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -460,6 +460,11 @@ func (h *Headscale) createRouter(apiV1Mux, apiV2Mux http.Handler) *chi.Mux { r.Use(middleware.Recoverer) r.Use(securityHeaders) + // TS2021 accepts both the native client's HTTP POST upgrade and the + // browser/WASM client's WebSocket GET upgrade; NoiseUpgradeHandler + // dispatches on the Upgrade header, not the method. Registering GET as + // well keeps the router from rejecting the WebSocket handshake with 405. + r.Get(ts2021UpgradePath, h.NoiseUpgradeHandler) r.Post(ts2021UpgradePath, h.NoiseUpgradeHandler) r.Get("/robots.txt", h.RobotsHandler) diff --git a/hscontrol/noise_test.go b/hscontrol/noise_test.go index 922ef5051..99b3ec4bf 100644 --- a/hscontrol/noise_test.go +++ b/hscontrol/noise_test.go @@ -459,6 +459,71 @@ func TestSSHActionHandler_RejectsMissingSessionWithoutCheck(t *testing.T) { "a bogus auth_id with no active check must be rejected, body=%s", rec.Body.String()) } +// TestTS2021Route_AcceptsGETAndPOST reproduces a regression where the +// browser/WASM control client could not connect. Tailscale's JS/WASM control +// client opens /ts2021 as a WebSocket, which is an HTTP GET upgrade; the native +// Go client uses an HTTP POST upgrade. The gorilla->chi router migration +// registered /ts2021 for POST only, so the GET WebSocket handshake was rejected +// with 405 Method Not Allowed by the router before it could reach +// NoiseUpgradeHandler. Both methods must route to the handler. +// +// NoiseUpgradeHandler dispatches on the Upgrade header, not the HTTP method, so +// once the route is reachable the handler handles both upgrade styles. The +// httptest recorder is not an http.Hijacker, so the upgrade itself fails past +// the router (501 for the WebSocket path, 400 for the native path) — the point +// is only that neither is 405, i.e. the router no longer rejects GET early. +func TestTS2021Route_AcceptsGETAndPOST(t *testing.T) { + t.Parallel() + + handler := createTestApp(t).HTTPHandler() + + tests := []struct { + name string + method string + headers map[string]string + }{ + { + name: "websocket_get_from_wasm_client", + method: http.MethodGet, + headers: map[string]string{ + "Connection": "Upgrade", + "Upgrade": "websocket", + "Sec-WebSocket-Version": "13", + "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Protocol": "tailscale-control-protocol", + }, + }, + { + name: "native_post_upgrade", + method: http.MethodPost, + headers: map[string]string{ + "Connection": "upgrade", + "Upgrade": "tailscale-control-protocol", + "X-Tailscale-Handshake": "AAAA", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext(context.Background(), tt.method, + "/ts2021?X-Tailscale-Handshake=AAAA", nil) + for k, v := range tt.headers { + req.Header.Set(k, v) + } + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.NotEqual(t, http.StatusMethodNotAllowed, rec.Code, + "%s /ts2021 must reach NoiseUpgradeHandler, not be rejected by the router with 405", + tt.method) + }) + } +} + // newSSHActionFollowUpRequest is like newSSHActionRequest but carries the // auth_id query parameter that marks a follow-up poll. func newSSHActionFollowUpRequest(t *testing.T, src, dst types.NodeID, authID types.AuthID) *http.Request { From f4fba32dc6cf6ab83cb8a7b1c307973fa01f664c Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 1 Jul 2026 08:39:12 +0000 Subject: [PATCH 123/127] integration: test /ts2021 WebSocket GET with a real WASM client A raw coder/websocket dial and the real tailscale.com js/wasm control client under Node, both against headscale alongside normal Tailscale clients. Updates #3357 --- Dockerfile.wasmclient | 30 +++ integration/ts2021_websocket_test.go | 315 ++++++++++++++++++++++++++ integration/wasmic/wasmclient/main.go | 103 +++++++++ integration/wasmic/wasmclient/stub.go | 8 + 4 files changed, 456 insertions(+) create mode 100644 Dockerfile.wasmclient create mode 100644 integration/ts2021_websocket_test.go create mode 100644 integration/wasmic/wasmclient/main.go create mode 100644 integration/wasmic/wasmclient/stub.go diff --git a/Dockerfile.wasmclient b/Dockerfile.wasmclient new file mode 100644 index 000000000..cc939a09f --- /dev/null +++ b/Dockerfile.wasmclient @@ -0,0 +1,30 @@ +# For integration testing only. +# +# Builds the Tailscale control client (integration/wasmic/wasmclient) for +# GOOS=js/GOARCH=wasm and packages it with Go's wasm_exec Node runner. The +# container idles; the integration test execs +# node /app/wasm_exec_node.js /app/client.wasm +# to drive a real browser-style WebSocket GET against headscale's /ts2021, +# guarding the regression in issue #3357. + +FROM golang:1.26.4-alpine AS build + +WORKDIR /src + +# Only the module metadata and the wasm client package are needed to build the +# js/wasm binary; its imports (tailscale.com/control/controlhttp, ...) resolve +# from the module proxy. +COPY go.mod go.sum ./ +COPY integration/wasmic/wasmclient ./integration/wasmic/wasmclient + +RUN GOOS=js GOARCH=wasm go build -o /out/client.wasm ./integration/wasmic/wasmclient \ + && cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" /out/wasm_exec.js \ + && cp "$(go env GOROOT)/lib/wasm/wasm_exec_node.js" /out/wasm_exec_node.js + +FROM node:24-alpine + +WORKDIR /app +COPY --from=build /out/ /app/ + +# Idle; the test execs the client on demand with the headscale control URL. +ENTRYPOINT ["tail", "-f", "/dev/null"] diff --git a/integration/ts2021_websocket_test.go b/integration/ts2021_websocket_test.go new file mode 100644 index 000000000..fc7f425e3 --- /dev/null +++ b/integration/ts2021_websocket_test.go @@ -0,0 +1,315 @@ +package integration + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/netip" + "net/url" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/juanfont/headscale/integration/dockertestutil" + "github.com/juanfont/headscale/integration/hsic" + "github.com/juanfont/headscale/integration/tsic" + "github.com/ory/dockertest/v3" + "github.com/samber/lo" + "github.com/stretchr/testify/require" + "tailscale.com/control/controlbase" + "tailscale.com/control/controlhttp/controlhttpcommon" + "tailscale.com/net/wsconn" + "tailscale.com/tailcfg" + "tailscale.com/types/key" + "tailscale.com/util/rands" +) + +// Tailscale's JS/WASM control client opens /ts2021 as a browser WebSocket — an +// HTTP GET upgrade — rather than the native client's HTTP POST upgrade. A router +// that registers /ts2021 for POST only rejects that GET with 405 before the +// Noise handshake starts, which breaks every WASM client (issue #3357). +// +// These two tests guard that path against real headscale: +// +// - TestTS2021WebSocketGET dials the WebSocket GET directly from the test +// process using the same coder/websocket + controlbase primitives the WASM +// client uses. It is fast and always on. +// - TestTS2021WASMClientUnderNode runs the *actual* tailscale.com js/wasm +// control dial (integration/wasmic/wasmclient, built for GOOS=js) inside a +// Node container, alongside normal Tailscale clients, and asserts it +// completes the Noise handshake with headscale over the WebSocket. +// +// The server cannot tell the two apart: both send GET /ts2021 with +// Sec-WebSocket-Protocol: tailscale-control-protocol. Before the fix both fail +// with 405; after it, both complete the handshake. + +// TestTS2021WebSocketGET connects to /ts2021 over a WebSocket GET from the test +// process and completes the Noise handshake, exactly as a browser/WASM client +// would. +func TestTS2021WebSocketGET(t *testing.T) { + IntegrationSkip(t) + t.Parallel() + + spec := ScenarioSpec{ + NodesPerUser: 1, + Users: []string{"user1"}, + } + + scenario, err := NewScenario(spec) + + require.NoErrorf(t, err, "failed to create scenario: %s", err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("ts2021ws")) + requireNoErrHeadscaleEnv(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + conn, err := dialTS2021WebSocket(t, headscale.GetEndpoint(), headscale.GetCert()) + require.NoError(t, err, + "WebSocket GET to /ts2021 must reach NoiseUpgradeHandler, not be rejected by the router with 405") + require.NotNil(t, conn) + t.Cleanup(func() { _ = conn.Close() }) + + t.Logf("noise established over websocket, protocol version %d", conn.ProtocolVersion()) +} + +// TestTS2021WASMClientUnderNode runs the real tailscale.com js/wasm control dial +// inside a Node container against real headscale, next to normal Tailscale +// clients, and asserts the WASM client completes the /ts2021 handshake. +func TestTS2021WASMClientUnderNode(t *testing.T) { + IntegrationSkip(t) + t.Parallel() + + spec := ScenarioSpec{ + NodesPerUser: 2, + Users: []string{"user1"}, + Networks: map[string]NetworkSpec{ + "wasmnet": {Users: []string{"user1"}}, + }, + ExtraService: map[string][]extraServiceFunc{ + "wasmnet": {wasmClientService}, + }, + // The wasm client image builds from this module; pair it with the + // head Tailscale clients so the whole environment is current. + Versions: []string{"head"}, + } + + scenario, err := NewScenario(spec) + + require.NoErrorf(t, err, "failed to create scenario: %s", err) + defer scenario.ShutdownAssertNoPanics(t) + + // The Tailscale JS/WASM client dials the control server as a WebSocket. + // client_js.go only honours a custom port for ws:// (plain HTTP); over + // wss:// it always targets :443, so it cannot reach a TLS control server on + // :8080. Run headscale without TLS, matching the http:// setup in the issue. + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithTestName("ts2021wasm"), + hsic.WithoutTLS(), + ) + requireNoErrHeadscaleEnv(t, err) + + allClients, err := scenario.ListTailscaleClients() + requireNoErrListClients(t, err) + + allIPs, err := scenario.ListTailscaleClientsIPs() + requireNoErrListClientIPs(t, err) + + // Normal Tailscale clients come up and form a working tailnet alongside the + // WASM control client. + err = scenario.WaitForTailscaleSync() + requireNoErrSync(t, err) + + headscale, err := scenario.Headscale() + requireNoErrGetHeadscale(t, err) + + // Sanity-check the tailnet the WASM client is joining: the normal clients + // must be able to reach each other. + allAddrs := lo.Map(allIPs, func(x netip.Addr, _ int) string { return x.String() }) + assertPingAll(t, allClients, allAddrs) + + services, err := scenario.Services("wasmnet") + require.NoError(t, err) + require.Len(t, services, 1, "expected the wasm client container") + + wasm := services[0] + controlURL := headscale.GetEndpoint() + + // Fetch the server's Noise key here and pass it to the WASM client: Go's + // net/http DNS resolver is unavailable under GOOS=js, so the client can only + // use the JS WebSocket transport, not an HTTP GET to /key. + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + controlKey, err := fetchServerNoiseKey(ctx, &http.Client{Timeout: 15 * time.Second}, controlURL) + require.NoError(t, err) + controlKeyText, err := controlKey.MarshalText() + require.NoError(t, err) + + // Run the real js/wasm control client under Node: it dials /ts2021 as a + // WebSocket GET; success means the Noise handshake completed. + stdout, stderr, err := dockertestutil.ExecuteCommand( + wasm, + []string{"node", "/app/wasm_exec_node.js", "/app/client.wasm", controlURL, string(controlKeyText)}, + []string{}, + dockertestutil.ExecuteCommandTimeout(60*time.Second), + ) + t.Logf("wasm client stdout:\n%s", stdout) + t.Logf("wasm client stderr:\n%s", stderr) + + require.NoError(t, err, + "wasm control client must connect to /ts2021 over websocket (405 means the router rejected the GET)") + require.Contains(t, stdout, "WASM_TS2021_OK", + "wasm control client should report a completed Noise handshake") +} + +// wasmClientService builds and starts the Node + js/wasm control-client +// container (Dockerfile.wasmclient) on the given network so it can reach +// headscale by hostname. It idles; the test execs the client on demand. +func wasmClientService(s *Scenario, networkName string) (*dockertest.Resource, error) { + hash := rands.HexString(hsicOIDCMockHashLength) + hostname := "hs-wasmclient-" + hash + + network, ok := s.networks[s.prefixedNetworkName(networkName)] + if !ok { + return nil, fmt.Errorf("network does not exist: %s", networkName) //nolint:err113 + } + + runOpts := &dockertest.RunOptions{ + Name: hostname, + Networks: []*dockertest.Network{network}, + Env: []string{}, + } + dockertestutil.DockerAddIntegrationLabels(runOpts, "wasmclient") + + buildOpts := &dockertest.BuildOptions{ + Dockerfile: "Dockerfile.wasmclient", + ContextDir: dockerContextPath, + } + + resource, err := s.pool.BuildAndRunWithBuildOptions( + buildOpts, + runOpts, + dockertestutil.DockerRestartPolicy, + ) + if err != nil { + return nil, fmt.Errorf("building wasm client container: %w", err) + } + + return resource, nil +} + +// dialTS2021WebSocket opens /ts2021 as a WebSocket GET (subprotocol +// tailscale-control-protocol) and completes the Noise handshake, mirroring what +// tailscale.com/control/controlhttp/client_js.go does in a browser. It returns +// the established Noise connection, or an error (a router that only allows POST +// returns 405 here). +func dialTS2021WebSocket(t *testing.T, endpoint string, caCert []byte) (*controlbase.Conn, error) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + u, err := url.Parse(endpoint) + require.NoError(t, err) + + httpClient := &http.Client{Timeout: 15 * time.Second} + + if u.Scheme == "https" { + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(caCert) + httpClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + } + } + + controlKey, err := fetchServerNoiseKey(ctx, httpClient, endpoint) + require.NoError(t, err) + + init, cont, err := controlbase.ClientDeferred( + key.NewMachine(), + controlKey, + uint16(tailcfg.CurrentCapabilityVersion), + ) + require.NoError(t, err) + + wsScheme := "ws" + if u.Scheme == "https" { + wsScheme = "wss" + } + + wsURL := &url.URL{ + Scheme: wsScheme, + Host: u.Host, + Path: "/ts2021", + RawQuery: url.Values{ + controlhttpcommon.HandshakeHeaderName: []string{base64.StdEncoding.EncodeToString(init)}, + }.Encode(), + } + + wsConn, resp, err := websocket.Dial(ctx, wsURL.String(), &websocket.DialOptions{ + Subprotocols: []string{controlhttpcommon.UpgradeHeaderValue}, + HTTPClient: httpClient, + }) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + + if err != nil { + return nil, err + } + + netConn := wsconn.NetConn(ctx, wsConn, websocket.MessageBinary, wsURL.String()) + + cbConn, err := cont(ctx, netConn) + if err != nil { + _ = netConn.Close() + return nil, fmt.Errorf("noise handshake over websocket: %w", err) + } + + return cbConn, nil +} + +// fetchServerNoiseKey retrieves headscale's Noise public key from /key, the same +// endpoint a real client consults before dialing /ts2021. +func fetchServerNoiseKey( + ctx context.Context, + client *http.Client, + endpoint string, +) (key.MachinePublic, error) { + var zero key.MachinePublic + + keyURL := fmt.Sprintf("%s/key?v=%d", endpoint, tailcfg.CurrentCapabilityVersion) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, keyURL, nil) + if err != nil { + return zero, err + } + + resp, err := client.Do(req) + if err != nil { + return zero, err + } + defer resp.Body.Close() + + var k tailcfg.OverTLSPublicKeyResponse + + err = json.NewDecoder(resp.Body).Decode(&k) + if err != nil { + return zero, fmt.Errorf("decoding /key response: %w", err) + } + + if k.PublicKey.IsZero() { + return zero, errors.New("server returned zero Noise public key") //nolint:err113 + } + + return k.PublicKey, nil +} diff --git a/integration/wasmic/wasmclient/main.go b/integration/wasmic/wasmclient/main.go new file mode 100644 index 000000000..dfc099251 --- /dev/null +++ b/integration/wasmic/wasmclient/main.go @@ -0,0 +1,103 @@ +//go:build js + +// Command wasmclient is a minimal Tailscale control client compiled to +// GOOS=js/GOARCH=wasm and run under Node. It exercises the real +// tailscale.com/control/controlhttp js/wasm dial path +// (control/controlhttp/client_js.go), which opens /ts2021 as a browser-style +// WebSocket GET — the exact transport a Tailscale JS/WASM client uses. +// +// It is the container-side half of the integration test guarding issue #3357: +// headscale must register /ts2021 for GET, not POST only, or the WebSocket +// upgrade is rejected with 405 before the Noise handshake can start. +// +// It is intentionally not the full tsconnect IPN — the regression is entirely +// in the control-connection upgrade, and this drives the real upgrade code with +// the smallest possible harness. On success it prints wasmSuccessMarker and +// exits 0; on any failure it prints wasmFailureMarker and exits non-zero. +package main + +import ( + "context" + "fmt" + "net/url" + "os" + "time" + + "tailscale.com/control/controlhttp" + "tailscale.com/tailcfg" + "tailscale.com/types/key" +) + +// These markers are matched by the integration test on the client's stdout. +const ( + wasmSuccessMarker = "WASM_TS2021_OK" + wasmFailureMarker = "WASM_TS2021_FAIL" +) + +func main() { + if len(os.Args) < 3 { + fmt.Printf("%s: usage: wasmclient \n", wasmFailureMarker) + os.Exit(2) + } + + if err := run(os.Args[1], os.Args[2]); err != nil { + fmt.Printf("%s: %v\n", wasmFailureMarker, err) + os.Exit(1) + } +} + +// run dials /ts2021 exactly as tailscale.com/control/controlhttp/client_js.go +// does in a browser: a WebSocket GET via the JS/undici WebSocket. The server's +// Noise key is passed in (the test fetches /key) rather than fetched here, +// because Go's net/http DNS resolver is unavailable under GOOS=js — only the +// WebSocket transport, which runs through the JS host, works. +func run(controlURL, noiseKeyText string) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + u, err := url.Parse(controlURL) + if err != nil { + return fmt.Errorf("parse control url %q: %w", controlURL, err) + } + + var controlKey key.MachinePublic + if err := controlKey.UnmarshalText([]byte(noiseKeyText)); err != nil { + return fmt.Errorf("parse noise key %q: %w", noiseKeyText, err) + } + + port := u.Port() + if port == "" { + if u.Scheme == "https" { + port = "443" + } else { + port = "80" + } + } + + // client_js.go selects ws:// (and appends the port) only when HTTPPort is a + // custom non-80 port and HTTPS is 443 or disabled; otherwise it dials wss:// + // on the default port. Set the fields to match the server's actual scheme. + d := &controlhttp.Dialer{ + Hostname: u.Hostname(), + MachineKey: key.NewMachine(), + ControlKey: controlKey, + ProtocolVersion: uint16(tailcfg.CurrentCapabilityVersion), + } + if u.Scheme == "https" { + d.HTTPSPort = port + } else { + d.HTTPPort = port + d.HTTPSPort = controlhttp.NoPort + } + + conn, err := d.Dial(ctx) + if err != nil { + return fmt.Errorf("ts2021 websocket dial: %w", err) + } + defer conn.Close() + + fmt.Printf("%s: noise established over websocket, protocol version %d\n", + wasmSuccessMarker, conn.ProtocolVersion()) + + return nil +} diff --git a/integration/wasmic/wasmclient/stub.go b/integration/wasmic/wasmclient/stub.go new file mode 100644 index 000000000..10ee01de5 --- /dev/null +++ b/integration/wasmic/wasmclient/stub.go @@ -0,0 +1,8 @@ +//go:build !js + +// This package only does something when built for GOOS=js (see main.go). The +// stub exists so `go build ./...` and `go vet ./...` on the host don't fail with +// "build constraints exclude all Go files" for this directory. +package main + +func main() {} From 01ef350d5f969c5a434df26beab96d71564a234c Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 1 Jul 2026 08:39:21 +0000 Subject: [PATCH 124/127] integration: add TS2021 WebSocket tests to CI matrix Generated by gh-action-integration-generator. Updates #3357 --- .github/workflows/test-integration.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index 974e55233..0e2a03377 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -379,6 +379,8 @@ jobs: - TestTagsAuthKeyWithoutUserInheritsTags - TestTagsAuthKeyWithoutUserRejectsAdvertisedTags - TestTagsAuthKeyConvertToUserViaCLIRegister + - TestTS2021WebSocketGET + - TestTS2021WASMClientUnderNode - TestTailscaleRustAxum uses: ./.github/workflows/integration-test-template.yml secrets: inherit From fef80f3eb08236ccba5f8681a5d486741fda6706 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 1 Jul 2026 08:40:47 +0000 Subject: [PATCH 125/127] CHANGELOG: note /ts2021 WebSocket GET fix Updates #3357 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f841378d2..8cfa2d02b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ keys remain all-access. ### Changes - Fix map generation serializing on the policy lock, so a mass reconnect on `autogroup:self`, via or relay policies no longer stalls clients into `unexpected EOF` retry loops [#3358](https://github.com/juanfont/headscale/pull/3358) +- Fix `/ts2021` rejecting the WebSocket `GET` upgrade with 405, which prevented Tailscale JS/WASM control clients from connecting [#3359](https://github.com/juanfont/headscale/pull/3359) A node whose stored name could not be turned into a valid FQDN — empty, or long enough that the full hostname exceeded 255 characters once the base domain was From a84945f134b5bebe850e7ac1f911d5f15da0a7ae Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 1 Jul 2026 13:35:10 +0000 Subject: [PATCH 126/127] CHANGELOG: shorten 0.29.2 invalid-name entry, set date Updates #3346 --- CHANGELOG.md | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cfa2d02b..785d7d855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,7 @@ keys remain all-access. - Expiring or deleting a non-existent pre-auth key now returns an error instead of silently succeeding [#3324](https://github.com/juanfont/headscale/pull/3324) - Improve systemd service file hardening [#3341](https://github.com/juanfont/headscale/pull/3341) -## 0.29.2 (202x-xx-xx) +## 0.29.2 (2026-07-01) **Minimum supported Tailscale client version: v1.80.0** @@ -53,19 +53,7 @@ keys remain all-access. - Fix map generation serializing on the policy lock, so a mass reconnect on `autogroup:self`, via or relay policies no longer stalls clients into `unexpected EOF` retry loops [#3358](https://github.com/juanfont/headscale/pull/3358) - Fix `/ts2021` rejecting the WebSocket `GET` upgrade with 405, which prevented Tailscale JS/WASM control clients from connecting [#3359](https://github.com/juanfont/headscale/pull/3359) - -A node whose stored name could not be turned into a valid FQDN — empty, or long -enough that the full hostname exceeded 255 characters once the base domain was -applied — broke map delivery for itself and for every peer that could see it. -Affected clients looped on `PollNetMap: unexpected EOF` and reported being -unable to reach the coordination server, while other clients were unaffected. -The mapper now drops such a node from its peers' maps instead of failing the -whole response, the long-poll handler returns an explicit error rather than an -empty response, node renames that would exceed the hostname limit are rejected, -and startup logs each node whose stored name needs fixing together with the -command to fix it. - -[#3349](https://github.com/juanfont/headscale/pull/3349) +- Gracefully handle nodes with an invalid FQDN (empty or too long) instead of failing map delivery; offending names are logged at startup with the fix command [#3349](https://github.com/juanfont/headscale/pull/3349) ## 0.29.1 (2026-06-18) From 048308511c72fa77da103e932f9b857a6e5247b9 Mon Sep 17 00:00:00 2001 From: alaningtrump Date: Fri, 3 Jul 2026 11:00:06 +0800 Subject: [PATCH 127/127] chore: fix function comment to match actual function name Signed-off-by: alaningtrump --- hscontrol/policy/v2/utils_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hscontrol/policy/v2/utils_test.go b/hscontrol/policy/v2/utils_test.go index 393a3b1fa..3a02bc660 100644 --- a/hscontrol/policy/v2/utils_test.go +++ b/hscontrol/policy/v2/utils_test.go @@ -8,7 +8,7 @@ import ( "tailscale.com/tailcfg" ) -// TestParseDestinationAndPort tests the splitDestinationAndPort function using table-driven tests. +// TestSplitDestinationAndPort tests the splitDestinationAndPort function using table-driven tests. func TestSplitDestinationAndPort(t *testing.T) { testCases := []struct { input string