From b5e3b4ee274bddc88c505777d24e4860c7ed56f7 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 17 Jun 2026 17:58:01 +0000 Subject: [PATCH] hscontrol/api/v1: build responses from state views, not proto Convert directly from NodeView/UserView/PreAuthKeyView (no AsStruct copies, no proto bridge), preserving the view types on the read path. Add UserView.Username and drop the two state.go uses of PreAuthKey.Proto(). --- hscontrol/api/v1/apikeys.go | 2 +- hscontrol/api/v1/convert.go | 250 +++++++++++++++++++++----------- hscontrol/api/v1/nodes.go | 29 ++-- hscontrol/api/v1/preauthkeys.go | 4 +- hscontrol/api/v1/users.go | 6 +- hscontrol/state/state.go | 4 +- hscontrol/types/users.go | 10 ++ 7 files changed, 197 insertions(+), 108 deletions(-) diff --git a/hscontrol/api/v1/apikeys.go b/hscontrol/api/v1/apikeys.go index 5b52f864..81bb4f13 100644 --- a/hscontrol/api/v1/apikeys.go +++ b/hscontrol/api/v1/apikeys.go @@ -40,7 +40,7 @@ func (s *Server) ListApiKeys(_ context.Context) (*oas.ListApiKeysOK, error) { out := make([]oas.ApiKey, len(keys)) for i := range keys { - out[i] = oasAPIKey(keys[i].Proto()) + out[i] = oasAPIKey(&keys[i]) } return &oas.ListApiKeysOK{ApiKeys: out}, nil diff --git a/hscontrol/api/v1/convert.go b/hscontrol/api/v1/convert.go index ea074a89..a28cf2f8 100644 --- a/hscontrol/api/v1/convert.go +++ b/hscontrol/api/v1/convert.go @@ -1,24 +1,24 @@ package apiv1 import ( + "time" + oas "github.com/juanfont/headscale/gen/api/v1" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" - "google.golang.org/protobuf/types/known/timestamppb" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" + "tailscale.com/types/views" ) -// This file bridges the existing proto response builders (the Proto() methods -// on the state types) to the ogen API types. Reusing Proto() guarantees the -// new HTTP API surfaces exactly the same data the gRPC/gateway stack did -// (username fallback, masked key prefixes, online computation, the -// TaggedDevices substitution, …) without reimplementing it. +// This file converts the state-layer types into the ogen API types. It reads +// the copy-on-write view types (NodeView, UserView, PreAuthKeyView) directly so +// no node/user is deep-copied on the read path, and reproduces exactly what the +// previous proto builders emitted — username fallback, masked key prefixes, +// online computation, the register-method enum. // // Unlike grpc-gateway (which marshalled with EmitUnpopulated), these converters // omit zero-value and absent fields — empty strings, false booleans, zero // numbers, empty arrays, nil timestamps/objects, and the unspecified register -// method. See docs/v1-ogen/CHANGES.md. When the proto stack is removed, these -// converters are rewritten to read the state types directly. -// -// Converters are added here as each resource group is migrated. +// method. See docs/v1-ogen/CHANGES.md. func optString(s string) oas.OptString { if s == "" { @@ -36,14 +36,6 @@ func optUint64(v uint64) oas.OptUint64 { return oas.NewOptUint64(v) } -func optTime(ts *timestamppb.Timestamp) oas.OptDateTime { - if ts == nil { - return oas.OptDateTime{} - } - - return oas.NewOptDateTime(ts.AsTime()) -} - func optBool(b bool) oas.OptBool { if !b { return oas.OptBool{} @@ -52,6 +44,31 @@ func optBool(b bool) oas.OptBool { return oas.NewOptBool(b) } +// optTimeVal sets a timestamp from a value; the previous proto builders always +// emitted these fields (timestamppb.New is never nil), so they are always set. +func optTimeVal(t time.Time) oas.OptDateTime { + return oas.NewOptDateTime(t) +} + +// optTimePtr sets a timestamp only when present, matching the proto builders' +// nil checks. +func optTimePtr(t *time.Time) oas.OptDateTime { + if t == nil { + return oas.OptDateTime{} + } + + return oas.NewOptDateTime(*t) +} + +// optTimeVP sets a timestamp from a view's optional pointer. +func optTimeVP(p views.ValuePointer[time.Time]) oas.OptDateTime { + if !p.Valid() { + return oas.OptDateTime{} + } + + return oas.NewOptDateTime(p.Get()) +} + // strs normalises an empty slice to nil so it is omitted from the response // rather than emitted as an empty array. func strs(s []string) []string { @@ -62,86 +79,147 @@ func strs(s []string) []string { return s } -func optUser(u *v1.User) oas.OptUser { - if u == nil { +func oasRegisterMethod(rm string) oas.OptRegisterMethod { + var v oas.RegisterMethod + + switch rm { + case "authkey": + v = "REGISTER_METHOD_AUTH_KEY" + case "oidc": + v = "REGISTER_METHOD_OIDC" + case "cli": + v = "REGISTER_METHOD_CLI" + default: + return oas.OptRegisterMethod{} + } + + return oas.NewOptRegisterMethod(v) +} + +func oasUser(u types.UserView) oas.User { + // Use Name if set, otherwise the display-friendly Username() fallback. + name := u.Name() + if name == "" { + name = u.Username() + } + + return oas.User{ + ID: optUint64(uint64(u.Model().ID)), + Name: optString(name), + CreatedAt: optTimeVal(u.Model().CreatedAt), + DisplayName: optString(u.DisplayName()), + Email: optString(u.Email()), + ProviderId: optString(u.ProviderIdentifier().String), + Provider: optString(u.Provider()), + ProfilePicUrl: optString(u.ProfilePicURL()), + } +} + +func optUser(u types.UserView) oas.OptUser { + if !u.Valid() { return oas.OptUser{} } return oas.NewOptUser(oasUser(u)) } -func oasPreAuthKey(k *v1.PreAuthKey) oas.PreAuthKey { - return oas.PreAuthKey{ - User: optUser(k.GetUser()), - ID: optUint64(k.GetId()), - Key: optString(k.GetKey()), - Reusable: optBool(k.GetReusable()), - Ephemeral: optBool(k.GetEphemeral()), - Used: optBool(k.GetUsed()), - Expiration: optTime(k.GetExpiration()), - CreatedAt: optTime(k.GetCreatedAt()), - AclTags: strs(k.GetAclTags()), - } -} - -func optPreAuthKey(k *v1.PreAuthKey) oas.OptPreAuthKey { - if k == nil { - return oas.OptPreAuthKey{} +func apiKeyMaskedPrefix(prefix string) string { + if len(prefix) == types.NewAPIKeyPrefixLength { + return "hskey-api-" + prefix + "-***" } - return oas.NewOptPreAuthKey(oasPreAuthKey(k)) + return prefix + "***" } -func optRegisterMethod(rm v1.RegisterMethod) oas.OptRegisterMethod { - if rm == v1.RegisterMethod_REGISTER_METHOD_UNSPECIFIED { - return oas.OptRegisterMethod{} - } - - return oas.NewOptRegisterMethod(oas.RegisterMethod(rm.String())) -} - -func oasNode(n *v1.Node) oas.Node { - return oas.Node{ - ID: optUint64(n.GetId()), - MachineKey: optString(n.GetMachineKey()), - NodeKey: optString(n.GetNodeKey()), - DiscoKey: optString(n.GetDiscoKey()), - IpAddresses: strs(n.GetIpAddresses()), - Name: optString(n.GetName()), - User: optUser(n.GetUser()), - LastSeen: optTime(n.GetLastSeen()), - Expiry: optTime(n.GetExpiry()), - PreAuthKey: optPreAuthKey(n.GetPreAuthKey()), - CreatedAt: optTime(n.GetCreatedAt()), - RegisterMethod: optRegisterMethod(n.GetRegisterMethod()), - GivenName: optString(n.GetGivenName()), - Online: optBool(n.GetOnline()), - ApprovedRoutes: strs(n.GetApprovedRoutes()), - AvailableRoutes: strs(n.GetAvailableRoutes()), - SubnetRoutes: strs(n.GetSubnetRoutes()), - Tags: strs(n.GetTags()), - } -} - -func oasAPIKey(k *v1.ApiKey) oas.ApiKey { +func oasAPIKey(k *types.APIKey) oas.ApiKey { return oas.ApiKey{ - ID: optUint64(k.GetId()), - Prefix: optString(k.GetPrefix()), - Expiration: optTime(k.GetExpiration()), - CreatedAt: optTime(k.GetCreatedAt()), - LastSeen: optTime(k.GetLastSeen()), + ID: optUint64(k.ID), + Prefix: optString(apiKeyMaskedPrefix(k.Prefix)), + Expiration: optTimePtr(k.Expiration), + CreatedAt: optTimePtr(k.CreatedAt), + LastSeen: optTimePtr(k.LastSeen), } } -func oasUser(u *v1.User) oas.User { - return oas.User{ - ID: optUint64(u.GetId()), - Name: optString(u.GetName()), - CreatedAt: optTime(u.GetCreatedAt()), - DisplayName: optString(u.GetDisplayName()), - Email: optString(u.GetEmail()), - ProviderId: optString(u.GetProviderId()), - Provider: optString(u.GetProvider()), - ProfilePicUrl: optString(u.GetProfilePicUrl()), +func preAuthKeyMaskedPrefix(prefix string) string { + if prefix != "" { + return "hskey-auth-" + prefix + "-***" } + + return "" +} + +func oasPreAuthKey(k types.PreAuthKeyView) oas.PreAuthKey { + out := oas.PreAuthKey{ + User: optUser(k.User()), + ID: optUint64(k.ID()), + Reusable: optBool(k.Reusable()), + Ephemeral: optBool(k.Ephemeral()), + Used: optBool(k.Used()), + Expiration: optTimeVP(k.Expiration()), + CreatedAt: optTimeVP(k.CreatedAt()), + AclTags: strs(k.Tags().AsSlice()), + } + + // New keys show the masked prefix; legacy keys (with a plaintext key) show + // the full key for backwards compatibility. + if masked := preAuthKeyMaskedPrefix(k.Prefix()); masked != "" { + out.Key = optString(masked) + } else if k.Key() != "" { + out.Key = optString(k.Key()) + } + + return out +} + +// oasPreAuthKeyNew converts a freshly created pre-auth key, which exposes the +// full secret key exactly once and has no view type. +func oasPreAuthKeyNew(k *types.PreAuthKeyNew) oas.PreAuthKey { + out := oas.PreAuthKey{ + ID: optUint64(k.ID), + Key: optString(k.Key), + Reusable: optBool(k.Reusable), + Ephemeral: optBool(k.Ephemeral), + Expiration: optTimePtr(k.Expiration), + CreatedAt: optTimePtr(k.CreatedAt), + AclTags: strs(k.Tags), + } + + if k.User != nil { + out.User = oas.NewOptUser(oasUser(k.User.View())) + } + + return out +} + +// oasNode converts a node view. user is the user to present (the node's own +// user for most callers, or TaggedDevices for tagged nodes); subnetRoutes +// carries the routes actively served from the node (primary + exit), which only +// some callers populate. +func oasNode(nv types.NodeView, user types.UserView, subnetRoutes []string) oas.Node { + out := oas.Node{ + ID: optUint64(uint64(nv.ID())), + MachineKey: optString(nv.MachineKey().String()), + NodeKey: optString(nv.NodeKey().String()), + DiscoKey: optString(nv.DiscoKey().String()), + IpAddresses: strs(nv.IPsAsString()), + Name: optString(nv.Hostname()), + User: optUser(user), + CreatedAt: optTimeVal(nv.CreatedAt()), + RegisterMethod: oasRegisterMethod(nv.RegisterMethod()), + GivenName: optString(nv.GivenName()), + Online: optBool(nv.IsOnline().GetOr(false)), + ApprovedRoutes: strs(util.PrefixesToString(nv.ApprovedRoutes().AsSlice())), + AvailableRoutes: strs(util.PrefixesToString(nv.AnnouncedRoutes())), + SubnetRoutes: strs(subnetRoutes), + Tags: strs(nv.Tags().AsSlice()), + LastSeen: optTimeVP(nv.LastSeen()), + Expiry: optTimeVP(nv.Expiry()), + } + + if nv.AuthKey().Valid() { + out.PreAuthKey = oas.NewOptPreAuthKey(oasPreAuthKey(nv.AuthKey())) + } + + return out } diff --git a/hscontrol/api/v1/nodes.go b/hscontrol/api/v1/nodes.go index 55ab0b7e..ceb87163 100644 --- a/hscontrol/api/v1/nodes.go +++ b/hscontrol/api/v1/nodes.go @@ -49,7 +49,7 @@ func (s *Server) RegisterNode( s.change(nodeChange, routeChange) - return &oas.RegisterNodeOK{Node: oas.NewOptNode(oasNode(node.Proto()))}, nil + return &oas.RegisterNodeOK{Node: oas.NewOptNode(oasNode(node, node.User(), nil))}, nil } // GetNode returns a node by id. @@ -59,7 +59,7 @@ func (s *Server) GetNode(_ context.Context, params oas.GetNodeParams) (*oas.GetN return nil, notFound("node not found") } - return &oas.GetNodeOK{Node: oas.NewOptNode(oasNode(node.Proto()))}, nil + return &oas.GetNodeOK{Node: oas.NewOptNode(oasNode(node, node.User(), nil))}, nil } // SetTags sets the ACL tags of a node, converting it to a tagged node. @@ -93,7 +93,7 @@ func (s *Server) SetTags( s.change(nodeChange) - return &oas.SetTagsOK{Node: oas.NewOptNode(oasNode(node.Proto()))}, nil + return &oas.SetTagsOK{Node: oas.NewOptNode(oasNode(node, node.User(), nil))}, nil } // SetApprovedRoutes sets the approved subnet routes of a node, expanding exit @@ -129,11 +129,12 @@ func (s *Server) SetApprovedRoutes( s.change(nodeChange) - proto := node.Proto() // SubnetRoutes carries only the routes actively served from the node. - proto.SubnetRoutes = util.PrefixesToString(s.state.GetNodePrimaryRoutes(node.ID())) + subnetRoutes := util.PrefixesToString(s.state.GetNodePrimaryRoutes(node.ID())) - return &oas.SetApprovedRoutesOK{Node: oas.NewOptNode(oasNode(proto))}, nil + return &oas.SetApprovedRoutesOK{ + Node: oas.NewOptNode(oasNode(node, node.User(), subnetRoutes)), + }, nil } // DeleteNode deletes a node. @@ -181,7 +182,7 @@ func (s *Server) ExpireNode( s.change(nodeChange) - return &oas.ExpireNodeOK{Node: oas.NewOptNode(oasNode(node.Proto()))}, nil + return &oas.ExpireNodeOK{Node: oas.NewOptNode(oasNode(node, node.User(), nil))}, nil } // RenameNode renames a node. @@ -196,7 +197,7 @@ func (s *Server) RenameNode( s.change(nodeChange) - return &oas.RenameNodeOK{Node: oas.NewOptNode(oasNode(node.Proto()))}, nil + return &oas.RenameNodeOK{Node: oas.NewOptNode(oasNode(node, node.User(), nil))}, nil } // ListNodes lists nodes, optionally filtered by user, sorted by id. @@ -227,17 +228,17 @@ func (s *Server) nodesToOAS(nodes views.Slice[types.NodeView]) []oas.Node { out := make([]oas.Node, nodes.Len()) for index, node := range nodes.All() { - proto := node.Proto() - + // Tagged nodes are presented as the TaggedDevices user. + user := node.User() if node.IsTagged() { - proto.User = types.TaggedDevices.Proto() + user = types.TaggedDevices.View() } - proto.SubnetRoutes = util.PrefixesToString( + subnetRoutes := util.PrefixesToString( append(s.state.GetNodePrimaryRoutes(node.ID()), node.ExitRoutes()...), ) - out[index] = oasNode(proto) + out[index] = oasNode(node, user, subnetRoutes) } slices.SortFunc(out, func(a, b oas.Node) int { return cmp.Compare(a.ID.Or(0), b.ID.Or(0)) }) @@ -307,5 +308,5 @@ func (s *Server) DebugCreateNode( }, } - return &oas.DebugCreateNodeOK{Node: oas.NewOptNode(oasNode(echoNode.Proto()))}, nil + return &oas.DebugCreateNodeOK{Node: oas.NewOptNode(oasNode(echoNode.View(), echoNode.View().User(), nil))}, nil } diff --git a/hscontrol/api/v1/preauthkeys.go b/hscontrol/api/v1/preauthkeys.go index 1d6e5189..6b2eded3 100644 --- a/hscontrol/api/v1/preauthkeys.go +++ b/hscontrol/api/v1/preauthkeys.go @@ -52,7 +52,7 @@ func (s *Server) CreatePreAuthKey( } return &oas.CreatePreAuthKeyOK{ - PreAuthKey: oas.NewOptPreAuthKey(oasPreAuthKey(preAuthKey.Proto())), + PreAuthKey: oas.NewOptPreAuthKey(oasPreAuthKeyNew(preAuthKey)), }, nil } @@ -67,7 +67,7 @@ func (s *Server) ListPreAuthKeys(_ context.Context) (*oas.ListPreAuthKeysOK, err out := make([]oas.PreAuthKey, len(keys)) for i := range keys { - out[i] = oasPreAuthKey(keys[i].Proto()) + out[i] = oasPreAuthKey(keys[i].View()) } return &oas.ListPreAuthKeysOK{PreAuthKeys: out}, nil diff --git a/hscontrol/api/v1/users.go b/hscontrol/api/v1/users.go index fd9021da..017f8715 100644 --- a/hscontrol/api/v1/users.go +++ b/hscontrol/api/v1/users.go @@ -29,7 +29,7 @@ func (s *Server) CreateUser( s.change(policyChanged) - return &oas.CreateUserOK{User: oas.NewOptUser(oasUser(user.Proto()))}, nil + return &oas.CreateUserOK{User: oas.NewOptUser(oasUser(user.View()))}, nil } // ListUsers lists users, optionally filtered by id, name, or email, sorted by id. @@ -63,7 +63,7 @@ func (s *Server) ListUsers( out := make([]oas.User, len(users)) for i := range users { - out[i] = oasUser(users[i].Proto()) + out[i] = oasUser(users[i].View()) } return &oas.ListUsersOK{Users: out}, nil @@ -91,7 +91,7 @@ func (s *Server) RenameUser( return nil, mapStateError(err) } - return &oas.RenameUserOK{User: oas.NewOptUser(oasUser(newUser.Proto()))}, nil + return &oas.RenameUserOK{User: oas.NewOptUser(oasUser(newUser.View()))}, nil } // DeleteUser deletes a user and distributes the resulting policy change. diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index fc78beb3..1069d8de 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/users.go b/hscontrol/types/users.go index 546f5d06..5fe45c6f 100644 --- a/hscontrol/types/users.go +++ b/hscontrol/types/users.go @@ -129,6 +129,16 @@ func (u *User) Username() string { ) } +// Username returns the display-friendly identifier for the user view, +// mirroring [User.Username]. +func (v UserView) Username() string { + if !v.Valid() { + return "" + } + + return v.ж.Username() +} + // Display returns the [User.DisplayName] if it exists, otherwise // it will return the [User.Username]. func (u *User) Display() string {