mirror of
https://github.com/juanfont/headscale.git
synced 2026-08-08 00:18:46 +09:00
hscontrol: remove the gRPC service and server
Delete grpcv1.go, the TCP gRPC listener, the gRPC auth interceptor, and the now-unused HTTP bearer middleware. The v1 API is served only over HTTP (TCP + unix socket) by the ogen stack.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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() {}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user