From 47cc4d0f9c048b8be4fa6fc55ddb4e837968afac Mon Sep 17 00:00:00 2001 From: Juanjo Presa Date: Sat, 18 Oct 2025 11:55:16 +0200 Subject: [PATCH] wip --- gen/go/headscale/v1/headscale_grpc.pb.go | 90 +++++++++++++++++------- gen/go/headscale/v1/pending.pb.go | 26 +++++++ hscontrol/grpcv1.go | 22 ++++++ hscontrol/state/state.go | 41 +++++++++++ hscontrol/types/pending_registration.go | 48 +++++++++++++ proto/headscale/v1/headscale.proto | 23 ++++++ 6 files changed, 225 insertions(+), 25 deletions(-) create mode 100644 gen/go/headscale/v1/pending.pb.go create mode 100644 hscontrol/types/pending_registration.go diff --git a/gen/go/headscale/v1/headscale_grpc.pb.go b/gen/go/headscale/v1/headscale_grpc.pb.go index bd8428c2..9e155eba 100644 --- a/gen/go/headscale/v1/headscale_grpc.pb.go +++ b/gen/go/headscale/v1/headscale_grpc.pb.go @@ -19,31 +19,32 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - HeadscaleService_CreateUser_FullMethodName = "/headscale.v1.HeadscaleService/CreateUser" - HeadscaleService_RenameUser_FullMethodName = "/headscale.v1.HeadscaleService/RenameUser" - HeadscaleService_DeleteUser_FullMethodName = "/headscale.v1.HeadscaleService/DeleteUser" - HeadscaleService_ListUsers_FullMethodName = "/headscale.v1.HeadscaleService/ListUsers" - HeadscaleService_CreatePreAuthKey_FullMethodName = "/headscale.v1.HeadscaleService/CreatePreAuthKey" - HeadscaleService_ExpirePreAuthKey_FullMethodName = "/headscale.v1.HeadscaleService/ExpirePreAuthKey" - HeadscaleService_ListPreAuthKeys_FullMethodName = "/headscale.v1.HeadscaleService/ListPreAuthKeys" - HeadscaleService_DebugCreateNode_FullMethodName = "/headscale.v1.HeadscaleService/DebugCreateNode" - HeadscaleService_GetNode_FullMethodName = "/headscale.v1.HeadscaleService/GetNode" - HeadscaleService_SetTags_FullMethodName = "/headscale.v1.HeadscaleService/SetTags" - HeadscaleService_SetApprovedRoutes_FullMethodName = "/headscale.v1.HeadscaleService/SetApprovedRoutes" - HeadscaleService_RegisterNode_FullMethodName = "/headscale.v1.HeadscaleService/RegisterNode" - HeadscaleService_DeleteNode_FullMethodName = "/headscale.v1.HeadscaleService/DeleteNode" - HeadscaleService_ExpireNode_FullMethodName = "/headscale.v1.HeadscaleService/ExpireNode" - HeadscaleService_RenameNode_FullMethodName = "/headscale.v1.HeadscaleService/RenameNode" - HeadscaleService_ListNodes_FullMethodName = "/headscale.v1.HeadscaleService/ListNodes" - HeadscaleService_MoveNode_FullMethodName = "/headscale.v1.HeadscaleService/MoveNode" - HeadscaleService_BackfillNodeIPs_FullMethodName = "/headscale.v1.HeadscaleService/BackfillNodeIPs" - HeadscaleService_CreateApiKey_FullMethodName = "/headscale.v1.HeadscaleService/CreateApiKey" - HeadscaleService_ExpireApiKey_FullMethodName = "/headscale.v1.HeadscaleService/ExpireApiKey" - HeadscaleService_ListApiKeys_FullMethodName = "/headscale.v1.HeadscaleService/ListApiKeys" - HeadscaleService_DeleteApiKey_FullMethodName = "/headscale.v1.HeadscaleService/DeleteApiKey" - HeadscaleService_GetPolicy_FullMethodName = "/headscale.v1.HeadscaleService/GetPolicy" - HeadscaleService_SetPolicy_FullMethodName = "/headscale.v1.HeadscaleService/SetPolicy" - HeadscaleService_Health_FullMethodName = "/headscale.v1.HeadscaleService/Health" + HeadscaleService_CreateUser_FullMethodName = "/headscale.v1.HeadscaleService/CreateUser" + HeadscaleService_RenameUser_FullMethodName = "/headscale.v1.HeadscaleService/RenameUser" + HeadscaleService_DeleteUser_FullMethodName = "/headscale.v1.HeadscaleService/DeleteUser" + HeadscaleService_ListUsers_FullMethodName = "/headscale.v1.HeadscaleService/ListUsers" + HeadscaleService_CreatePreAuthKey_FullMethodName = "/headscale.v1.HeadscaleService/CreatePreAuthKey" + HeadscaleService_ExpirePreAuthKey_FullMethodName = "/headscale.v1.HeadscaleService/ExpirePreAuthKey" + HeadscaleService_ListPreAuthKeys_FullMethodName = "/headscale.v1.HeadscaleService/ListPreAuthKeys" + HeadscaleService_DebugCreateNode_FullMethodName = "/headscale.v1.HeadscaleService/DebugCreateNode" + HeadscaleService_GetNode_FullMethodName = "/headscale.v1.HeadscaleService/GetNode" + HeadscaleService_SetTags_FullMethodName = "/headscale.v1.HeadscaleService/SetTags" + HeadscaleService_SetApprovedRoutes_FullMethodName = "/headscale.v1.HeadscaleService/SetApprovedRoutes" + HeadscaleService_RegisterNode_FullMethodName = "/headscale.v1.HeadscaleService/RegisterNode" + HeadscaleService_DeleteNode_FullMethodName = "/headscale.v1.HeadscaleService/DeleteNode" + HeadscaleService_ExpireNode_FullMethodName = "/headscale.v1.HeadscaleService/ExpireNode" + HeadscaleService_RenameNode_FullMethodName = "/headscale.v1.HeadscaleService/RenameNode" + HeadscaleService_ListNodes_FullMethodName = "/headscale.v1.HeadscaleService/ListNodes" + HeadscaleService_MoveNode_FullMethodName = "/headscale.v1.HeadscaleService/MoveNode" + HeadscaleService_BackfillNodeIPs_FullMethodName = "/headscale.v1.HeadscaleService/BackfillNodeIPs" + HeadscaleService_CreateApiKey_FullMethodName = "/headscale.v1.HeadscaleService/CreateApiKey" + HeadscaleService_ExpireApiKey_FullMethodName = "/headscale.v1.HeadscaleService/ExpireApiKey" + HeadscaleService_ListApiKeys_FullMethodName = "/headscale.v1.HeadscaleService/ListApiKeys" + HeadscaleService_DeleteApiKey_FullMethodName = "/headscale.v1.HeadscaleService/DeleteApiKey" + HeadscaleService_GetPolicy_FullMethodName = "/headscale.v1.HeadscaleService/GetPolicy" + HeadscaleService_SetPolicy_FullMethodName = "/headscale.v1.HeadscaleService/SetPolicy" + HeadscaleService_Health_FullMethodName = "/headscale.v1.HeadscaleService/Health" + HeadscaleService_ListPendingRegistrations_FullMethodName = "/headscale.v1.HeadscaleService/ListPendingRegistrations" ) // HeadscaleServiceClient is the client API for HeadscaleService service. @@ -81,6 +82,8 @@ type HeadscaleServiceClient interface { SetPolicy(ctx context.Context, in *SetPolicyRequest, opts ...grpc.CallOption) (*SetPolicyResponse, error) // --- Health start --- Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) + // --- Pending registrations --- + ListPendingRegistrations(ctx context.Context, in *ListPendingRegistrationsRequest, opts ...grpc.CallOption) (*ListPendingRegistrationsResponse, error) } type headscaleServiceClient struct { @@ -341,6 +344,16 @@ func (c *headscaleServiceClient) Health(ctx context.Context, in *HealthRequest, return out, nil } +func (c *headscaleServiceClient) ListPendingRegistrations(ctx context.Context, in *ListPendingRegistrationsRequest, opts ...grpc.CallOption) (*ListPendingRegistrationsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListPendingRegistrationsResponse) + err := c.cc.Invoke(ctx, HeadscaleService_ListPendingRegistrations_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // HeadscaleServiceServer is the server API for HeadscaleService service. // All implementations must embed UnimplementedHeadscaleServiceServer // for forward compatibility. @@ -376,6 +389,8 @@ type HeadscaleServiceServer interface { SetPolicy(context.Context, *SetPolicyRequest) (*SetPolicyResponse, error) // --- Health start --- Health(context.Context, *HealthRequest) (*HealthResponse, error) + // --- Pending registrations --- + ListPendingRegistrations(context.Context, *ListPendingRegistrationsRequest) (*ListPendingRegistrationsResponse, error) mustEmbedUnimplementedHeadscaleServiceServer() } @@ -461,6 +476,9 @@ func (UnimplementedHeadscaleServiceServer) SetPolicy(context.Context, *SetPolicy func (UnimplementedHeadscaleServiceServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Health not implemented") } +func (UnimplementedHeadscaleServiceServer) ListPendingRegistrations(context.Context, *ListPendingRegistrationsRequest) (*ListPendingRegistrationsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListPendingRegistrations not implemented") +} func (UnimplementedHeadscaleServiceServer) mustEmbedUnimplementedHeadscaleServiceServer() {} func (UnimplementedHeadscaleServiceServer) testEmbeddedByValue() {} @@ -932,6 +950,24 @@ func _HeadscaleService_Health_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _HeadscaleService_ListPendingRegistrations_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListPendingRegistrationsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HeadscaleServiceServer).ListPendingRegistrations(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: HeadscaleService_ListPendingRegistrations_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HeadscaleServiceServer).ListPendingRegistrations(ctx, req.(*ListPendingRegistrationsRequest)) + } + return interceptor(ctx, in, info, handler) +} + // HeadscaleService_ServiceDesc is the grpc.ServiceDesc for HeadscaleService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -1039,6 +1075,10 @@ var HeadscaleService_ServiceDesc = grpc.ServiceDesc{ MethodName: "Health", Handler: _HeadscaleService_Health_Handler, }, + { + MethodName: "ListPendingRegistrations", + Handler: _HeadscaleService_ListPendingRegistrations_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "headscale/v1/headscale.proto", diff --git a/gen/go/headscale/v1/pending.pb.go b/gen/go/headscale/v1/pending.pb.go new file mode 100644 index 00000000..4d0a3681 --- /dev/null +++ b/gen/go/headscale/v1/pending.pb.go @@ -0,0 +1,26 @@ +// Code generated manually to provide types for PendingRegistrations RPC until buf generate is run. +// This file defines protobuf-compatible Go structs without full reflection metadata. +// It is sufficient for compiling server code that references these types. + +package v1 + +import ( + timestamppb "google.golang.org/protobuf/types/known/timestamppb" +) + +// PendingRegistration is a lightweight representation of a pending registration. +type PendingRegistration struct { + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Hostname string `protobuf:"bytes,2,opt,name=hostname,proto3" json:"hostname,omitempty"` + MachineKey string `protobuf:"bytes,3,opt,name=machine_key,json=machineKey,proto3" json:"machine_key,omitempty"` + NodeKey string `protobuf:"bytes,4,opt,name=node_key,json=nodeKey,proto3" json:"node_key,omitempty"` + Expiry *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=expiry,proto3" json:"expiry,omitempty"` +} + +// ListPendingRegistrationsRequest is the empty request message. +type ListPendingRegistrationsRequest struct{} + +// ListPendingRegistrationsResponse contains the current pending registrations. +type ListPendingRegistrationsResponse struct { + Registrations []*PendingRegistration `protobuf:"bytes,1,rep,name=registrations,proto3" json:"registrations,omitempty"` +} diff --git a/hscontrol/grpcv1.go b/hscontrol/grpcv1.go index 1d620ba6..c4094bd7 100644 --- a/hscontrol/grpcv1.go +++ b/hscontrol/grpcv1.go @@ -793,4 +793,26 @@ func (api headscaleV1APIServer) Health( return response, healthErr } +func (api headscaleV1APIServer) ListPendingRegistrations( + ctx context.Context, + _ *v1.ListPendingRegistrationsRequest, +) (*v1.ListPendingRegistrationsResponse, error) { + regs := api.h.state.ListPendingRegistrations() + resp := &v1.ListPendingRegistrationsResponse{} + for _, r := range regs { + var ts *timestamppb.Timestamp + if r.Expiry != nil { + ts = timestamppb.New(*r.Expiry) + } + resp.Registrations = append(resp.Registrations, &v1.PendingRegistration{ + Id: r.ID.String(), + Hostname: r.Hostname, + MachineKey: r.MachineKey, + NodeKey: r.NodeKey, + Expiry: ts, + }) + } + return resp, nil +} + func (api headscaleV1APIServer) mustEmbedUnimplementedHeadscaleServiceServer() {} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index 1e138ea0..ef2bded5 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -69,6 +69,17 @@ type State struct { primaryRoutes *routes.PrimaryRoutes } +// PendingRegistration represents a pending node registration entry in memory. +// It is populated from the registrationCache and used by API/gRPC layers. +// Note: This is an in-memory view only; entries expire automatically from the cache. +type PendingRegistration struct { + ID types.RegistrationID + Hostname string + MachineKey string + NodeKey string + Expiry *time.Time +} + // NewState creates and initializes a new State instance, setting up the database, // IP allocator, DERP map, policy manager, and loading existing users and nodes. func NewState(cfg *types.Config) (*State, error) { @@ -968,6 +979,36 @@ func (s *State) SetRegistrationCacheEntry(id types.RegistrationID, entry types.R s.registrationCache.Set(id, entry) } +// ListPendingRegistrations returns a snapshot of current pending registrations. +// It iterates the registrationCache and extracts minimal identifying details. +func (s *State) ListPendingRegistrations() []PendingRegistration { + if s.registrationCache == nil { + return nil + } + + var regs []PendingRegistration + + // zcache/v2 supports Keys iteration; use Range if available for efficiency. + // We use Keys here and then look up to read values. + for _, id := range s.registrationCache.Keys() { + if rn, ok := s.registrationCache.Get(id); ok { + var exp *time.Time + if rn.Node.Expiry != nil { + exp = rn.Node.Expiry + } + regs = append(regs, PendingRegistration{ + ID: id, + Hostname: rn.Node.Hostname, + MachineKey: rn.Node.MachineKey.String(), + NodeKey: rn.Node.NodeKey.String(), + Expiry: exp, + }) + } + } + + return regs +} + // logHostinfoValidation logs warnings when hostinfo is nil or has empty hostname. func logHostinfoValidation(machineKey, nodeKey, username, hostname string, hostinfo *tailcfg.Hostinfo) { if hostinfo == nil { diff --git a/hscontrol/types/pending_registration.go b/hscontrol/types/pending_registration.go new file mode 100644 index 00000000..8b2f9241 --- /dev/null +++ b/hscontrol/types/pending_registration.go @@ -0,0 +1,48 @@ +package types + +import ( + v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// PendingRegistrationProto converts a state.PendingRegistration-like struct to protobuf. +// Kept in types to avoid circular imports from hscontrol/state. +// Input is the plain fields to serialize. +type PendingRegistrationProto struct { + ID string + Hostname string + MachineKey string + NodeKey string + // Expiry may be nil + ExpiryUnixMilli int64 // not used; we will pass a pointer time via Timestamppb in helpers if needed +} + +// BuildPendingRegistration constructs a v1.PendingRegistration. +func BuildPendingRegistration(id, hostname, machineKey, nodeKey string, expiry *int64) *v1.PendingRegistration { + pr := &v1.PendingRegistration{ + Id: id, + Hostname: hostname, + MachineKey: machineKey, + NodeKey: nodeKey, + } + if expiry != nil { + // We cannot convert int64 millis without time. Keep the field for potential future. + // Callers should prefer using BuildPendingRegistrationWithTimestamp. + _ = expiry + } + return pr +} + +// BuildPendingRegistrationWithTimestamp sets a proper protobuf timestamp if provided. +func BuildPendingRegistrationWithTimestamp(id, hostname, machineKey, nodeKey string, ts *timestamppb.Timestamp) *v1.PendingRegistration { + pr := &v1.PendingRegistration{ + Id: id, + Hostname: hostname, + MachineKey: machineKey, + NodeKey: nodeKey, + } + if ts != nil { + pr.Expiry = ts + } + return pr +} diff --git a/proto/headscale/v1/headscale.proto b/proto/headscale/v1/headscale.proto index 3b42a3f3..426dde2c 100644 --- a/proto/headscale/v1/headscale.proto +++ b/proto/headscale/v1/headscale.proto @@ -3,6 +3,7 @@ package headscale.v1; option go_package = "github.com/juanfont/headscale/gen/go/v1"; import "google/api/annotations.proto"; +import "google/protobuf/timestamp.proto"; import "headscale/v1/user.proto"; import "headscale/v1/preauthkey.proto"; @@ -190,6 +191,14 @@ service HeadscaleService { } // --- Health end --- + // --- Pending registrations --- + rpc ListPendingRegistrations(ListPendingRegistrationsRequest) returns (ListPendingRegistrationsResponse) { + option (google.api.http) = { + get : "/api/v1/pending-registrations" + }; + } + // --- Pending registrations end --- + // Implement Tailscale API // rpc GetDevice(GetDeviceRequest) returns(GetDeviceResponse) { // option(google.api.http) = { @@ -223,3 +232,17 @@ message HealthRequest {} message HealthResponse { bool database_connectivity = 1; } + +message PendingRegistration { + string id = 1; + string hostname = 2; + string machine_key = 3; + string node_key = 4; + google.protobuf.Timestamp expiry = 5; +} + +message ListPendingRegistrationsRequest {} + +message ListPendingRegistrationsResponse { + repeated PendingRegistration registrations = 1; +}