diff --git a/hscontrol/app.go b/hscontrol/app.go index d9fe5357..35c196fa 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -25,7 +25,6 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/metrics" "github.com/juanfont/headscale" - v1 "github.com/juanfont/headscale/gen/go/headscale/v1" apiv1 "github.com/juanfont/headscale/hscontrol/api/v1" "github.com/juanfont/headscale/hscontrol/capver" "github.com/juanfont/headscale/hscontrol/db" @@ -45,12 +44,6 @@ import ( "golang.org/x/crypto/acme" "golang.org/x/crypto/acme/autocert" "golang.org/x/sync/errgroup" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/credentials" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/peer" - "google.golang.org/grpc/status" "tailscale.com/envknob" "tailscale.com/tailcfg" "tailscale.com/types/dnstype" @@ -383,131 +376,6 @@ func (h *Headscale) scheduledTasks(ctx context.Context) { } } -// checkBearerToken validates an "Authorization" header value. It reports -// whether the API key is valid, whether the header carried the "Bearer " -// prefix, and any validation error. Callers translate these outcomes into -// their transport-specific status. -func (h *Headscale) checkBearerToken(authHeader string) (bool, bool, error) { - if !strings.HasPrefix(authHeader, AuthPrefix) { - return false, false, nil - } - - valid, err := h.state.ValidateAPIKey(strings.TrimPrefix(authHeader, AuthPrefix)) - - return valid, true, err -} - -func (h *Headscale) grpcAuthenticationInterceptor(ctx context.Context, - req any, - info *grpc.UnaryServerInfo, - handler grpc.UnaryHandler, -) (any, error) { - // Check if the request is coming from the on-server client. - // This is not secure, but it is to maintain maintainability - // with the "legacy" database-based client - // It is also needed for grpc-gateway to be able to connect to - // the server - client, _ := peer.FromContext(ctx) - - log.Trace(). - Caller(). - Str("client_address", client.Addr.String()). - Msg("Client is trying to authenticate") - - meta, ok := metadata.FromIncomingContext(ctx) - if !ok { - return ctx, status.Errorf( - codes.InvalidArgument, - "retrieving metadata", - ) - } - - authHeader, ok := meta["authorization"] - if !ok { - return ctx, status.Errorf( - codes.Unauthenticated, - "authorization token not supplied", - ) - } - - valid, hasPrefix, err := h.checkBearerToken(authHeader[0]) - if !hasPrefix { - return ctx, status.Error( - codes.Unauthenticated, - `missing "Bearer " prefix in "Authorization" header`, - ) - } - - if err != nil { - return ctx, status.Error(codes.Internal, "validating token") - } - - if !valid { - log.Info(). - Str("client_address", client.Addr.String()). - Msg("invalid token") - - return ctx, status.Error(codes.Unauthenticated, "invalid token") - } - - return handler(ctx, req) -} - -func (h *Headscale) httpAuthenticationMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func( - writer http.ResponseWriter, - req *http.Request, - ) { - log.Trace(). - Caller(). - Str("client_address", req.RemoteAddr). - Msg("HTTP authentication invoked") - - authHeader := req.Header.Get("Authorization") - - writeUnauthorized := func(statusCode int) { - writer.WriteHeader(statusCode) - - if _, err := writer.Write([]byte("Unauthorized")); err != nil { //nolint:noinlineerr - log.Error().Err(err).Msg("writing HTTP response failed") - } - } - - valid, hasPrefix, err := h.checkBearerToken(authHeader) - if !hasPrefix { - log.Error(). - Caller(). - Str("client_address", req.RemoteAddr). - Msg(`missing "Bearer " prefix in "Authorization" header`) - writeUnauthorized(http.StatusUnauthorized) - - return - } - - if err != nil { - log.Info(). - Caller(). - Err(err). - Str("client_address", req.RemoteAddr). - Msg("failed to validate token") - writeUnauthorized(http.StatusUnauthorized) - - return - } - - if !valid { - log.Info(). - Str("client_address", req.RemoteAddr). - Msg("invalid token") - writeUnauthorized(http.StatusUnauthorized) - - return - } - - next.ServeHTTP(writer, req) - }) -} - // ensureUnixSocketIsAbsent will check if the given path for headscales unix socket is clear // and will remove it if it is not. func (h *Headscale) ensureUnixSocketIsAbsent() error { @@ -774,58 +642,6 @@ func (h *Headscale) Serve() error { return fmt.Errorf("configuring TLS settings: %w", err) } - // - // - // gRPC setup - // - - // We are sadly not able to run gRPC and HTTPS (2.0) on the same - // port because the connection mux does not support matching them - // since they are so similar. There is multiple issues open and we - // can revisit this if changes: - // https://github.com/soheilhy/cmux/issues/68 - // https://github.com/soheilhy/cmux/issues/91 - - var ( - grpcServer *grpc.Server - grpcListener net.Listener - ) - - if tlsConfig != nil || h.cfg.GRPCAllowInsecure { - log.Info().Msgf("enabling remote gRPC at %s", h.cfg.GRPCAddr) - - grpcOptions := []grpc.ServerOption{ - grpc.ChainUnaryInterceptor( - h.grpcAuthenticationInterceptor, - // Uncomment to debug grpc communication. - // zerolog.NewUnaryServerInterceptor(), - ), - } - - if tlsConfig != nil { - grpcOptions = append( - grpcOptions, - grpc.Creds(credentials.NewTLS(tlsConfig)), - ) - } else { - log.Warn().Msg("gRPC is running without security") - } - - grpcServer = grpc.NewServer(grpcOptions...) - - v1.RegisterHeadscaleServiceServer(grpcServer, newHeadscaleV1APIServer(h)) - - grpcListener, err = new(net.ListenConfig).Listen(context.Background(), "tcp", h.cfg.GRPCAddr) - if err != nil { - return fmt.Errorf("binding to TCP address: %w", err) - } - - errorGroup.Go(func() error { return grpcServer.Serve(grpcListener) }) - - log.Info(). - Msgf("listening and serving gRPC on: %s", h.cfg.GRPCAddr) - } - // // // HTTP setup @@ -978,12 +794,6 @@ func (h *Headscale) Serve() error { log.Error().Err(socketErr).Msg("failed to shutdown socket http") } - if grpcServer != nil { - info("shutting down grpc server (external)") - grpcServer.GracefulStop() - grpcListener.Close() - } - if tailsqlContext != nil { info("shutting down tailsql") tailsqlContext.Done() diff --git a/hscontrol/grpcv1.go b/hscontrol/grpcv1.go deleted file mode 100644 index fbb4f591..00000000 --- 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 1bb4cbe2..00000000 --- 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") -}