diff --git a/docs/v1-ogen/CHANGES.md b/docs/v1-ogen/CHANGES.md index 90da17e8..b2b12f84 100644 --- a/docs/v1-ogen/CHANGES.md +++ b/docs/v1-ogen/CHANGES.md @@ -43,17 +43,19 @@ status code itself is unchanged for equivalent conditions (e.g. unknown user → ## Behaviour -### Unknown resources return 404 consistently +### Client errors return 4xx consistently -**What:** operations that look up a resource by id return `404` when it does not -exist. Several gRPC handlers (e.g. `RenameUser`, `DeleteUser`) returned a plain -Go error, which grpc-gateway rendered as `500`; only a few (e.g. `GetNode`) used -an explicit not-found status. +**What:** client mistakes now map to the appropriate 4xx status instead of 500. +Missing resources are `404` (e.g. `RenameUser`, `DeleteUser`, `GetNode`, +`DeleteNode`); invalid input is `400` (e.g. an unparseable route in +`SetApprovedRoutes`, a malformed registration key in `RegisterNode`, an invalid +tag in `SetTags`, an unconfirmed `BackfillNodeIPs`). Many of these gRPC handlers +returned a plain Go error, which grpc-gateway rendered as `500`. -**Why:** a missing resource is a client error, not a server error; 404 is the -correct, consistent status. +**Why:** a missing resource or bad input is a client error, not a server error; +4xx is the correct, consistent status. -**Client impact:** clients that treated these as 500 should treat them as 404. +**Client impact:** clients that treated these as 500 should treat them as 400/404. ### Health on database failure diff --git a/hscontrol/api/v1/convert.go b/hscontrol/api/v1/convert.go index 0aa378ea..ea074a89 100644 --- a/hscontrol/api/v1/convert.go +++ b/hscontrol/api/v1/convert.go @@ -84,6 +84,45 @@ func oasPreAuthKey(k *v1.PreAuthKey) oas.PreAuthKey { } } +func optPreAuthKey(k *v1.PreAuthKey) oas.OptPreAuthKey { + if k == nil { + return oas.OptPreAuthKey{} + } + + return oas.NewOptPreAuthKey(oasPreAuthKey(k)) +} + +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 { return oas.ApiKey{ ID: optUint64(k.GetId()), diff --git a/hscontrol/api/v1/nodes.go b/hscontrol/api/v1/nodes.go new file mode 100644 index 00000000..55ab0b7e --- /dev/null +++ b/hscontrol/api/v1/nodes.go @@ -0,0 +1,311 @@ +package apiv1 + +import ( + "cmp" + "context" + "net/netip" + "slices" + "time" + + oas "github.com/juanfont/headscale/gen/api/v1" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" + "tailscale.com/net/tsaddr" + "tailscale.com/tailcfg" + "tailscale.com/types/key" + "tailscale.com/types/views" +) + +// RegisterNode registers a node to a user using a registration id, then +// auto-approves its routes. +func (s *Server) RegisterNode( + _ context.Context, + params oas.RegisterNodeParams, +) (*oas.RegisterNodeOK, error) { + registrationID, err := types.AuthIDFromString(params.Key.Or("")) + if err != nil { + return nil, badRequest(err.Error()) + } + + user, err := s.state.GetUserByName(params.User.Or("")) + if err != nil { + return nil, mapStateError(err) + } + + node, nodeChange, err := s.state.HandleNodeFromAuthPath( + registrationID, + types.UserID(user.ID), + nil, + util.RegisterMethodCLI, + ) + if err != nil { + return nil, mapStateError(err) + } + + routeChange, err := s.state.AutoApproveRoutes(node) + if err != nil { + return nil, internalError("auto approving routes: " + err.Error()) + } + + s.change(nodeChange, routeChange) + + return &oas.RegisterNodeOK{Node: oas.NewOptNode(oasNode(node.Proto()))}, nil +} + +// GetNode returns a node by id. +func (s *Server) GetNode(_ context.Context, params oas.GetNodeParams) (*oas.GetNodeOK, error) { + node, ok := s.state.GetNodeByID(types.NodeID(params.NodeID)) + if !ok { + return nil, notFound("node not found") + } + + return &oas.GetNodeOK{Node: oas.NewOptNode(oasNode(node.Proto()))}, nil +} + +// SetTags sets the ACL tags of a node, converting it to a tagged node. +func (s *Server) SetTags( + _ context.Context, + req *oas.SetTagsReq, + params oas.SetTagsParams, +) (*oas.SetTagsOK, error) { + if len(req.Tags) == 0 { + return nil, badRequest( + "cannot remove all tags from a node - tagged nodes must have at least one tag", + ) + } + + for _, tag := range req.Tags { + err := validateTag(tag) + if err != nil { + return nil, badRequest(err.Error()) + } + } + + _, found := s.state.GetNodeByID(types.NodeID(params.NodeID)) + if !found { + return nil, notFound("node not found") + } + + node, nodeChange, err := s.state.SetNodeTags(types.NodeID(params.NodeID), req.Tags) + if err != nil { + return nil, badRequest(err.Error()) + } + + s.change(nodeChange) + + return &oas.SetTagsOK{Node: oas.NewOptNode(oasNode(node.Proto()))}, nil +} + +// SetApprovedRoutes sets the approved subnet routes of a node, expanding exit +// routes to cover both address families. +func (s *Server) SetApprovedRoutes( + _ context.Context, + req *oas.SetApprovedRoutesReq, + params oas.SetApprovedRoutesParams, +) (*oas.SetApprovedRoutesOK, error) { + var newApproved []netip.Prefix + + for _, route := range req.Routes { + prefix, err := netip.ParsePrefix(route) + if err != nil { + return nil, badRequest("parsing route: " + err.Error()) + } + + // An exit route is annotated by both v4 and v6 default routes. + 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 := s.state.SetApprovedRoutes(types.NodeID(params.NodeID), newApproved) + if err != nil { + return nil, badRequest(err.Error()) + } + + 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())) + + return &oas.SetApprovedRoutesOK{Node: oas.NewOptNode(oasNode(proto))}, nil +} + +// DeleteNode deletes a node. +func (s *Server) DeleteNode(_ context.Context, params oas.DeleteNodeParams) error { + node, ok := s.state.GetNodeByID(types.NodeID(params.NodeID)) + if !ok { + return notFound("node not found") + } + + nodeChange, err := s.state.DeleteNode(node) + if err != nil { + return mapStateError(err) + } + + s.change(nodeChange) + + return nil +} + +// ExpireNode expires a node, or disables its expiry. +func (s *Server) ExpireNode( + _ context.Context, + params oas.ExpireNodeParams, +) (*oas.ExpireNodeOK, error) { + _, hasExpiry := params.Expiry.Get() + if params.DisableExpiry.Or(false) && hasExpiry { + return nil, badRequest("cannot set both disable_expiry and expiry") + } + + var expiry *time.Time + + if !params.DisableExpiry.Or(false) { + t := time.Now() + if v, ok := params.Expiry.Get(); ok { + t = v + } + + expiry = &t + } + + node, nodeChange, err := s.state.SetNodeExpiry(types.NodeID(params.NodeID), expiry) + if err != nil { + return nil, mapStateError(err) + } + + s.change(nodeChange) + + return &oas.ExpireNodeOK{Node: oas.NewOptNode(oasNode(node.Proto()))}, nil +} + +// RenameNode renames a node. +func (s *Server) RenameNode( + _ context.Context, + params oas.RenameNodeParams, +) (*oas.RenameNodeOK, error) { + node, nodeChange, err := s.state.RenameNode(types.NodeID(params.NodeID), params.NewName) + if err != nil { + return nil, mapStateError(err) + } + + s.change(nodeChange) + + return &oas.RenameNodeOK{Node: oas.NewOptNode(oasNode(node.Proto()))}, nil +} + +// ListNodes lists nodes, optionally filtered by user, sorted by id. +func (s *Server) ListNodes( + _ context.Context, + params oas.ListNodesParams, +) (*oas.ListNodesOK, error) { + var nodes views.Slice[types.NodeView] + + if params.User.Or("") != "" { + user, err := s.state.GetUserByName(params.User.Or("")) + if err != nil { + return nil, mapStateError(err) + } + + nodes = s.state.ListNodesByUser(types.UserID(user.ID)) + } else { + nodes = s.state.ListNodes() + } + + return &oas.ListNodesOK{Nodes: s.nodesToOAS(nodes)}, nil +} + +// nodesToOAS converts a slice of node views to API nodes, presenting tagged +// nodes as the TaggedDevices user and populating SubnetRoutes with the routes +// actively served from each node. +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() + + if node.IsTagged() { + proto.User = types.TaggedDevices.Proto() + } + + proto.SubnetRoutes = util.PrefixesToString( + append(s.state.GetNodePrimaryRoutes(node.ID()), node.ExitRoutes()...), + ) + + out[index] = oasNode(proto) + } + + slices.SortFunc(out, func(a, b oas.Node) int { return cmp.Compare(a.ID.Or(0), b.ID.Or(0)) }) + + return out +} + +// BackfillNodeIPs backfills missing IP addresses for all nodes. It must be +// explicitly confirmed. +func (s *Server) BackfillNodeIPs( + _ context.Context, + params oas.BackfillNodeIPsParams, +) (*oas.BackfillNodeIPsOK, error) { + if !params.Confirmed.Or(false) { + return nil, badRequest("not confirmed, aborting") + } + + changes, err := s.state.BackfillNodeIPs() + if err != nil { + return nil, mapStateError(err) + } + + return &oas.BackfillNodeIPsOK{Changes: changes}, nil +} + +// DebugCreateNode caches a synthetic node registration for testing and echoes +// back a node describing it. The real node is created later via AuthApprove. +func (s *Server) DebugCreateNode( + _ context.Context, + req *oas.DebugCreateNodeReq, +) (*oas.DebugCreateNodeOK, error) { + user, err := s.state.GetUserByName(req.User.Or("")) + if err != nil { + return nil, mapStateError(err) + } + + routes, err := util.StringToIPPrefix(req.Routes) + if err != nil { + return nil, badRequest(err.Error()) + } + + registrationID, err := types.AuthIDFromString(req.Key.Or("")) + if err != nil { + return nil, badRequest(err.Error()) + } + + regData := &types.RegistrationData{ + NodeKey: key.NewNode().Public(), + MachineKey: key.NewMachine().Public(), + Hostname: req.Name.Or(""), + Expiry: &time.Time{}, + } + + s.state.SetAuthCacheEntry(registrationID, types.NewRegisterAuthRequest(regData)) + + echoNode := types.Node{ + NodeKey: regData.NodeKey, + MachineKey: regData.MachineKey, + Hostname: regData.Hostname, + User: user, + Expiry: &time.Time{}, + LastSeen: &time.Time{}, + Hostinfo: &tailcfg.Hostinfo{ + Hostname: req.Name.Or(""), + OS: "TestOS", + RoutableIPs: routes, + }, + } + + return &oas.DebugCreateNodeOK{Node: oas.NewOptNode(oasNode(echoNode.Proto()))}, nil +} diff --git a/hscontrol/servertest/apiv1.go b/hscontrol/servertest/apiv1.go index 80dafa01..749b1b75 100644 --- a/hscontrol/servertest/apiv1.go +++ b/hscontrol/servertest/apiv1.go @@ -8,6 +8,7 @@ import ( "time" apiv1 "github.com/juanfont/headscale/gen/api/v1" + "github.com/juanfont/headscale/hscontrol/types" ) // APIClient returns an ogen-generated v1 API client wired to this server's @@ -38,6 +39,24 @@ func (s *TestServer) APIClient(tb testing.TB, apiKey string) *apiv1.Client { return client } +// CreateNode creates a registered test node present in both the database and +// the in-memory NodeStore, so it can be read and mutated through the API. +func (s *TestServer) CreateNode( + tb testing.TB, + user *types.User, + hostname string, +) *types.Node { + tb.Helper() + + node := s.st.CreateRegisteredNodeForTest(user, hostname) + // Ensure the User association is present in the NodeStore snapshot; the + // database read path preloads it, but the test helper does not. + node.User = user + s.st.PutNodeInStoreForTest(*node) + + return node +} + // CreateAPIKey mints a non-expiring API key and returns the secret token. func (s *TestServer) CreateAPIKey(tb testing.TB) string { tb.Helper() diff --git a/hscontrol/servertest/apiv1_nodes_test.go b/hscontrol/servertest/apiv1_nodes_test.go new file mode 100644 index 00000000..4d0c2c97 --- /dev/null +++ b/hscontrol/servertest/apiv1_nodes_test.go @@ -0,0 +1,207 @@ +package servertest_test + +import ( + "context" + "net/http" + "testing" + "time" + + apiv1 "github.com/juanfont/headscale/gen/api/v1" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIv1_GetNode(t *testing.T) { + srv, client := apiClient(t) + ctx := context.Background() + user := srv.CreateUser(t, "alice") + node := srv.CreateNode(t, user, "node1") + + resp, err := client.GetNode(ctx, apiv1.GetNodeParams{NodeID: uint64(node.ID)}) + require.NoError(t, err) + assert.Equal(t, uint64(node.ID), resp.Node.Value.ID.Value) + assert.Equal(t, "alice", resp.Node.Value.User.Value.Name.Value) + + _, err = client.GetNode(ctx, apiv1.GetNodeParams{NodeID: 99999}) + requireProblem(t, err, http.StatusNotFound) +} + +func TestAPIv1_ListNodes(t *testing.T) { + srv, client := apiClient(t) + ctx := context.Background() + alice := srv.CreateUser(t, "alice") + bob := srv.CreateUser(t, "bob") + srv.CreateNode(t, alice, "alice1") + srv.CreateNode(t, alice, "alice2") + srv.CreateNode(t, bob, "bob1") + + all, err := client.ListNodes(ctx, apiv1.ListNodesParams{}) + require.NoError(t, err) + require.Len(t, all.Nodes, 3) + + for i := 1; i < len(all.Nodes); i++ { + assert.Less(t, all.Nodes[i-1].ID.Value, all.Nodes[i].ID.Value) + } + + byUser, err := client.ListNodes(ctx, apiv1.ListNodesParams{User: apiv1.NewOptString("alice")}) + require.NoError(t, err) + assert.Len(t, byUser.Nodes, 2) +} + +func TestAPIv1_DeleteNode(t *testing.T) { + srv, client := apiClient(t) + ctx := context.Background() + user := srv.CreateUser(t, "alice") + node := srv.CreateNode(t, user, "node1") + + require.NoError(t, client.DeleteNode(ctx, apiv1.DeleteNodeParams{NodeID: uint64(node.ID)})) + + _, err := client.GetNode(ctx, apiv1.GetNodeParams{NodeID: uint64(node.ID)}) + requireProblem(t, err, http.StatusNotFound) + + err = client.DeleteNode(ctx, apiv1.DeleteNodeParams{NodeID: 99999}) + requireProblem(t, err, http.StatusNotFound) +} + +func TestAPIv1_RenameNode(t *testing.T) { + srv, client := apiClient(t) + ctx := context.Background() + user := srv.CreateUser(t, "alice") + node := srv.CreateNode(t, user, "node1") + + resp, err := client.RenameNode(ctx, apiv1.RenameNodeParams{ + NodeID: uint64(node.ID), + NewName: "renamed", + }) + require.NoError(t, err) + assert.Equal(t, "renamed", resp.Node.Value.GivenName.Value) +} + +func TestAPIv1_ExpireNode(t *testing.T) { + srv, client := apiClient(t) + ctx := context.Background() + user := srv.CreateUser(t, "alice") + node := srv.CreateNode(t, user, "node1") + + resp, err := client.ExpireNode(ctx, apiv1.ExpireNodeParams{NodeID: uint64(node.ID)}) + require.NoError(t, err) + assert.True(t, resp.Node.Value.Expiry.Set, "expiry should be set") + + // Disabling expiry clears it. + resp, err = client.ExpireNode(ctx, apiv1.ExpireNodeParams{ + NodeID: uint64(node.ID), + DisableExpiry: apiv1.NewOptBool(true), + }) + require.NoError(t, err) + assert.False(t, resp.Node.Value.Expiry.Set, "expiry should be cleared") + + // Setting both expiry and disable_expiry is a 400. + _, err = client.ExpireNode(ctx, apiv1.ExpireNodeParams{ + NodeID: uint64(node.ID), + Expiry: apiv1.NewOptDateTime(time.Now()), + DisableExpiry: apiv1.NewOptBool(true), + }) + requireProblem(t, err, http.StatusBadRequest) +} + +func TestAPIv1_SetApprovedRoutes_ExitNodeExpansion(t *testing.T) { + srv, client := apiClient(t) + ctx := context.Background() + user := srv.CreateUser(t, "alice") + node := srv.CreateNode(t, user, "node1") + + resp, err := client.SetApprovedRoutes( + ctx, + &apiv1.SetApprovedRoutesReq{Routes: []string{"0.0.0.0/0"}}, + apiv1.SetApprovedRoutesParams{NodeID: uint64(node.ID)}, + ) + require.NoError(t, err) + // An exit route is expanded to both default routes. + assert.Contains(t, resp.Node.Value.ApprovedRoutes, "0.0.0.0/0") + assert.Contains(t, resp.Node.Value.ApprovedRoutes, "::/0") +} + +func TestAPIv1_SetTags_Validation(t *testing.T) { + srv, client := apiClient(t) + ctx := context.Background() + user := srv.CreateUser(t, "alice") + node := srv.CreateNode(t, user, "node1") + + // Empty tag list is a 400. + _, err := client.SetTags( + ctx, + &apiv1.SetTagsReq{Tags: []string{}}, + apiv1.SetTagsParams{NodeID: uint64(node.ID)}, + ) + requireProblem(t, err, http.StatusBadRequest) + + // Malformed tag is a 400. + _, err = client.SetTags( + ctx, + &apiv1.SetTagsReq{Tags: []string{"notatag"}}, + apiv1.SetTagsParams{NodeID: uint64(node.ID)}, + ) + requireProblem(t, err, http.StatusBadRequest) + + // Unknown node is a 404. + _, err = client.SetTags( + ctx, + &apiv1.SetTagsReq{Tags: []string{"tag:test"}}, + apiv1.SetTagsParams{NodeID: 99999}, + ) + requireProblem(t, err, http.StatusNotFound) + + // Unauthorized tag (no tagOwners policy) is a 400. + _, err = client.SetTags( + ctx, + &apiv1.SetTagsReq{Tags: []string{"tag:test"}}, + apiv1.SetTagsParams{NodeID: uint64(node.ID)}, + ) + requireProblem(t, err, http.StatusBadRequest) +} + +func TestAPIv1_BackfillNodeIPs(t *testing.T) { + _, client := apiClient(t) + ctx := context.Background() + + // Without confirmation it is a 400. + _, err := client.BackfillNodeIPs(ctx, apiv1.BackfillNodeIPsParams{}) + requireProblem(t, err, http.StatusBadRequest) + + // Confirmed succeeds. + _, err = client.BackfillNodeIPs(ctx, apiv1.BackfillNodeIPsParams{ + Confirmed: apiv1.NewOptBool(true), + }) + require.NoError(t, err) +} + +func TestAPIv1_RegisterNode_Errors(t *testing.T) { + srv, client := apiClient(t) + ctx := context.Background() + + srv.CreateUser(t, "alice") + + // Malformed registration key is a 400. + _, err := client.RegisterNode(ctx, apiv1.RegisterNodeParams{ + User: apiv1.NewOptString("alice"), + Key: apiv1.NewOptString("not-a-valid-key"), + }) + requireProblem(t, err, http.StatusBadRequest) +} + +func TestAPIv1_DebugCreateNode(t *testing.T) { + srv, client := apiClient(t) + ctx := context.Background() + + srv.CreateUser(t, "alice") + + resp, err := client.DebugCreateNode(ctx, &apiv1.DebugCreateNodeReq{ + User: apiv1.NewOptString("alice"), + Key: apiv1.NewOptString(types.MustAuthID().String()), + Name: apiv1.NewOptString("debug-node"), + }) + require.NoError(t, err) + assert.Equal(t, "debug-node", resp.Node.Value.Name.Value) + assert.Equal(t, "alice", resp.Node.Value.User.Value.Name.Value) +}