diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go index b9e97a33..8585c6c0 100644 --- a/cmd/headscale/cli/nodes.go +++ b/cmd/headscale/cli/nodes.go @@ -48,6 +48,13 @@ func init() { } nodeCmd.AddCommand(registerNodeCmd) + approveNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)") + err = approveNodeCmd.MarkFlagRequired("identifier") + if err != nil { + log.Fatalf(err.Error()) + } + nodeCmd.AddCommand(approveNodeCmd) + expireNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)") err = expireNodeCmd.MarkFlagRequired("identifier") if err != nil { @@ -206,6 +213,43 @@ var listNodesCmd = &cobra.Command{ }, } +var approveNodeCmd = &cobra.Command{ + Use: "approve", + Short: "Approve a node in your network", + Aliases: []string{"a"}, + Run: func(cmd *cobra.Command, args []string) { + output, _ := cmd.Flags().GetString("output") + identifier, err := cmd.Flags().GetUint64("identifier") + if err != nil { + ErrorOutput( + err, + fmt.Sprintf("Error converting ID to integer: %s", err), + output, + ) + return + } + ctx, client, conn, cancel := newHeadscaleCLIWithConfig() + defer cancel() + defer conn.Close() + request := &v1.ApproveNodeRequest{ + NodeId: identifier, + } + response, err := client.ApproveNode(ctx, request) + if err != nil { + ErrorOutput( + err, + fmt.Sprintf( + "Cannot expire node: %s\n", + status.Convert(err).Message(), + ), + output, + ) + return + } + SuccessOutput(response.GetNode(), "Node approved", output) + }, +} + var expireNodeCmd = &cobra.Command{ Use: "expire", Short: "Expire (log out) a node in your network", diff --git a/cmd/headscale/cli/preauthkeys.go b/cmd/headscale/cli/preauthkeys.go index 0074e029..5a427ca7 100644 --- a/cmd/headscale/cli/preauthkeys.go +++ b/cmd/headscale/cli/preauthkeys.go @@ -36,6 +36,8 @@ func init() { preauthkeysCmd.AddCommand(expirePreAuthKeyCmd) createPreAuthKeyCmd.PersistentFlags(). Bool("reusable", false, "Make the preauthkey reusable") + createPreAuthKeyCmd.PersistentFlags(). + Bool("pre-approved", false, "Make the preauthkey with node pre-approval") createPreAuthKeyCmd.PersistentFlags(). Bool("ephemeral", false, "Preauthkey for ephemeral nodes") createPreAuthKeyCmd.Flags(). @@ -90,6 +92,7 @@ var listPreAuthKeys = &cobra.Command{ "ID", "Key", "Reusable", + "Pre-Approved", "Ephemeral", "Used", "Expiration", @@ -115,6 +118,7 @@ var listPreAuthKeys = &cobra.Command{ key.GetId(), key.GetKey(), strconv.FormatBool(key.GetReusable()), + strconv.FormatBool(key.GetPreApproved()), strconv.FormatBool(key.GetEphemeral()), strconv.FormatBool(key.GetUsed()), expiration, @@ -147,14 +151,16 @@ var createPreAuthKeyCmd = &cobra.Command{ } reusable, _ := cmd.Flags().GetBool("reusable") + preApproved, _ := cmd.Flags().GetBool("pre-approved") ephemeral, _ := cmd.Flags().GetBool("ephemeral") tags, _ := cmd.Flags().GetStringSlice("tags") request := &v1.CreatePreAuthKeyRequest{ - User: user, - Reusable: reusable, - Ephemeral: ephemeral, - AclTags: tags, + User: user, + Reusable: reusable, + PreApproved: preApproved, + Ephemeral: ephemeral, + AclTags: tags, } durationStr, _ := cmd.Flags().GetString("expiration") diff --git a/config-example.yaml b/config-example.yaml index 93204398..c266d9e8 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -395,3 +395,9 @@ logtail: # default static port 41641. This option is intended as a workaround for some buggy # firewall devices. See https://tailscale.com/kb/1181/firewalls/ for more information. randomize_client_port: false + +# Node management +# See https://tailscale.com/kb/1099/device-approval for more information. +node_management: + # Require new nodes to be approved by admins before they can access the network. + manual_approve_new_node: false \ No newline at end of file diff --git a/hscontrol/app.go b/hscontrol/app.go index 1651b8f2..7090874e 100644 --- a/hscontrol/app.go +++ b/hscontrol/app.go @@ -137,6 +137,7 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) { app.db, err = db.NewHeadscaleDatabase( cfg.Database, cfg.BaseDomain, + cfg.NodeManagement, registrationCache, ) if err != nil { diff --git a/hscontrol/auth.go b/hscontrol/auth.go index 2b23aad3..961b8029 100644 --- a/hscontrol/auth.go +++ b/hscontrol/auth.go @@ -292,6 +292,11 @@ func (h *Headscale) handleAuthKey( nodeKey := registerRequest.NodeKey + nodeApproved := true + if h.cfg.NodeManagement.ManualApproveNewNode { + nodeApproved = pak.PreApproved + } + // retrieve node information if it exist // The error is not important, because if it does not // exist, then this is a new node and we will move @@ -308,6 +313,10 @@ func (h *Headscale) handleAuthKey( node.AuthKeyID = ptr.To(pak.ID) } + if node.Approved == false { + node.Approved = nodeApproved + } + node.Expiry = ®isterRequest.Expiry node.User = pak.User node.UserID = pak.UserID @@ -349,6 +358,7 @@ func (h *Headscale) handleAuthKey( User: pak.User, MachineKey: machineKey, RegisterMethod: util.RegisterMethodAuthKey, + Approved: nodeApproved, Expiry: ®isterRequest.Expiry, NodeKey: nodeKey, LastSeen: &now, @@ -406,7 +416,7 @@ func (h *Headscale) handleAuthKey( return } - resp.MachineAuthorized = true + resp.MachineAuthorized = node.IsApproved() resp.User = *pak.User.TailscaleUser() // Provide LoginName when registering with pre-auth key // Otherwise it will need to exec `tailscale up` twice to fetch the *LoginName* @@ -569,7 +579,7 @@ func (h *Headscale) handleNodeWithValidRegistration( Msg("Client is registered and we have the current NodeKey. All clear to /map") resp.AuthURL = "" - resp.MachineAuthorized = true + resp.MachineAuthorized = node.IsApproved() resp.User = *node.User.TailscaleUser() resp.Login = *node.User.TailscaleLogin() diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index 0d9120c2..148544a5 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -43,7 +43,8 @@ type HSDatabase struct { cfg *types.DatabaseConfig regCache *zcache.Cache[string, types.Node] - baseDomain string + baseDomain string + nodeManagement *types.NodeManagement } // TODO(kradalby): assemble this struct from toptions or something typed @@ -51,6 +52,7 @@ type HSDatabase struct { func NewHeadscaleDatabase( cfg types.DatabaseConfig, baseDomain string, + nodeManagement types.NodeManagement, regCache *zcache.Cache[string, types.Node], ) (*HSDatabase, error) { dbConn, err := openDB(cfg) @@ -521,6 +523,48 @@ func NewHeadscaleDatabase( }, Rollback: func(db *gorm.DB) error { return nil }, }, + { + ID: "202410071005", + Migrate: func(tx *gorm.DB) error { + err = tx.AutoMigrate(&types.PreAuthKey{}) + if err != nil { + return err + } + + err = tx.AutoMigrate(&types.Node{}) + if err != nil { + return err + } + + if tx.Migrator().HasColumn(&types.Node{}, "approved") { + nodes := types.Nodes{} + if err := tx.Find(&nodes).Error; err != nil { + log.Error().Err(err).Msg("Error accessing db") + } + + for item, node := range nodes { + if node.IsApproved() == false { + err = tx.Model(nodes[item]).Updates(types.Node{ + Approved: true, + }).Error + if err != nil { + log.Error(). + Caller(). + Str("hostname", node.Hostname). + Bool("approved", node.IsApproved()). + Err(err). + Msg("Failed to add approval option to existing nodes during database migration") + } + } + } + + return nil + } + + return fmt.Errorf("no node approved column in DB") + }, + Rollback: func(db *gorm.DB) error { return nil }, + }, }, ) @@ -533,7 +577,8 @@ func NewHeadscaleDatabase( cfg: &cfg, regCache: regCache, - baseDomain: baseDomain, + baseDomain: baseDomain, + nodeManagement: &nodeManagement, } return &db, err diff --git a/hscontrol/db/ip.go b/hscontrol/db/ip.go index 3525795a..b1289ab8 100644 --- a/hscontrol/db/ip.go +++ b/hscontrol/db/ip.go @@ -265,10 +265,10 @@ func isTailscaleReservedIP(ip netip.Addr) bool { // it will be added. // If a prefix type has been removed (IPv4 or IPv6), it // will remove the IPs in that family from the node. -func (db *HSDatabase) BackfillNodeIPs(i *IPAllocator) ([]string, error) { +func (hsdb *HSDatabase) BackfillNodeIPs(i *IPAllocator) ([]string, error) { var err error var ret []string - err = db.Write(func(tx *gorm.DB) error { + err = hsdb.Write(func(tx *gorm.DB) error { if i == nil { return errors.New("backfilling IPs: ip allocator was nil") } diff --git a/hscontrol/db/node.go b/hscontrol/db/node.go index 1c2a165c..a067a64a 100644 --- a/hscontrol/db/node.go +++ b/hscontrol/db/node.go @@ -50,8 +50,9 @@ func ListPeers(tx *gorm.DB, nodeID types.NodeID) (types.Nodes, error) { Preload("AuthKey.User"). Preload("User"). Preload("Routes"). - Where("id <> ?", - nodeID).Find(&nodes).Error; err != nil { + Where("id <> ?", nodeID). + Where("approved = ?", true). + Find(&nodes).Error; err != nil { return types.Nodes{}, err } @@ -261,6 +262,19 @@ func RenameNode(tx *gorm.DB, return nil } +func (hsdb *HSDatabase) NodeSetApprove(nodeID types.NodeID, approved bool) error { + return hsdb.Write(func(tx *gorm.DB) error { + return NodeSetApprove(tx, nodeID, approved) + }) +} + +// NodeSetApprove takes a Node struct and a set approval option +func NodeSetApprove(tx *gorm.DB, + nodeID types.NodeID, approved bool, +) error { + return tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("approved", approved).Error +} + func (hsdb *HSDatabase) NodeSetExpiry(nodeID types.NodeID, expiry time.Time) error { return hsdb.Write(func(tx *gorm.DB) error { return NodeSetExpiry(tx, nodeID, expiry) @@ -328,6 +342,8 @@ func (hsdb *HSDatabase) RegisterNodeFromAuthCallback( ipv6 *netip.Addr, ) (*types.Node, error) { return Write(hsdb.DB, func(tx *gorm.DB) (*types.Node, error) { + manualApprovedNode := hsdb.nodeManagement.ManualApproveNewNode + if node, ok := hsdb.regCache.Get(mkey.String()); ok { user, err := GetUserByID(tx, userID) if err != nil { @@ -341,6 +357,7 @@ func (hsdb *HSDatabase) RegisterNodeFromAuthCallback( Str("machine_key", mkey.ShortString()). Str("username", user.Username()). Str("registrationMethod", registrationMethod). + Bool("manualApprovedNode", manualApprovedNode). Str("expiresAt", fmt.Sprintf("%v", nodeExpiry)). Msg("Registering node from API/CLI or auth callback") @@ -354,6 +371,10 @@ func (hsdb *HSDatabase) RegisterNodeFromAuthCallback( node.User = *user node.RegisterMethod = registrationMethod + if node.IsApproved() == false && manualApprovedNode == false { + node.Approved = true + } + if nodeExpiry != nil { node.Expiry = nodeExpiry } @@ -388,6 +409,7 @@ func RegisterNode(tx *gorm.DB, node types.Node, ipv4 *netip.Addr, ipv6 *netip.Ad Str("machine_key", node.MachineKey.ShortString()). Str("node_key", node.NodeKey.ShortString()). Str("user", node.User.Username()). + Bool("approved", node.IsApproved()). Msg("Registering node") // If the node exists and it already has IP(s), we just save it @@ -404,6 +426,7 @@ func RegisterNode(tx *gorm.DB, node types.Node, ipv4 *netip.Addr, ipv6 *netip.Ad Str("machine_key", node.MachineKey.ShortString()). Str("node_key", node.NodeKey.ShortString()). Str("user", node.User.Username()). + Bool("approved", node.IsApproved()). Msg("Node authorized again") return &node, nil @@ -428,6 +451,7 @@ func RegisterNode(tx *gorm.DB, node types.Node, ipv4 *netip.Addr, ipv6 *netip.Ad log.Trace(). Caller(). Str("node", node.Hostname). + Bool("approved", node.IsApproved()). Msg("Node registered with the database") return &node, nil diff --git a/hscontrol/db/preauth_keys.go b/hscontrol/db/preauth_keys.go index aeee5b52..e24bd86e 100644 --- a/hscontrol/db/preauth_keys.go +++ b/hscontrol/db/preauth_keys.go @@ -25,12 +25,13 @@ var ( func (hsdb *HSDatabase) CreatePreAuthKey( uid types.UserID, reusable bool, + preApproved bool, ephemeral bool, expiration *time.Time, aclTags []string, ) (*types.PreAuthKey, error) { return Write(hsdb.DB, func(tx *gorm.DB) (*types.PreAuthKey, error) { - return CreatePreAuthKey(tx, uid, reusable, ephemeral, expiration, aclTags) + return CreatePreAuthKey(tx, uid, reusable, preApproved, ephemeral, expiration, aclTags) }) } @@ -39,6 +40,7 @@ func CreatePreAuthKey( tx *gorm.DB, uid types.UserID, reusable bool, + preApproved bool, ephemeral bool, expiration *time.Time, aclTags []string, @@ -70,14 +72,15 @@ func CreatePreAuthKey( } key := types.PreAuthKey{ - Key: kstr, - UserID: user.ID, - User: *user, - Reusable: reusable, - Ephemeral: ephemeral, - CreatedAt: &now, - Expiration: expiration, - Tags: aclTags, + Key: kstr, + UserID: user.ID, + User: *user, + Reusable: reusable, + PreApproved: preApproved, + Ephemeral: ephemeral, + CreatedAt: &now, + Expiration: expiration, + Tags: aclTags, } if err := tx.Save(&key).Error; err != nil { diff --git a/hscontrol/grpcv1.go b/hscontrol/grpcv1.go index 3e9fcb5e..5df9791b 100644 --- a/hscontrol/grpcv1.go +++ b/hscontrol/grpcv1.go @@ -158,6 +158,7 @@ func (api headscaleV1APIServer) CreatePreAuthKey( preAuthKey, err := api.h.db.CreatePreAuthKey( types.UserID(user.ID), request.GetReusable(), + request.GetPreApproved(), request.GetEphemeral(), &expiration, request.AclTags, @@ -362,6 +363,54 @@ func (api headscaleV1APIServer) DeleteNode( return &v1.DeleteNodeResponse{}, nil } +func (api headscaleV1APIServer) ApproveNode( + ctx context.Context, + request *v1.ApproveNodeRequest, +) (*v1.ApproveNodeResponse, error) { + node, err := db.Write(api.h.db.DB, func(tx *gorm.DB) (*types.Node, error) { + if err := db.NodeSetApprove( + tx, + types.NodeID(request.GetNodeId()), + true, + ); err != nil { + return nil, err + } + + return db.GetNodeByID(tx, types.NodeID(request.GetNodeId())) + }) + if err != nil { + return nil, err + } + + ctx = types.NotifyCtx(ctx, "cli-approved-node-self", node.Hostname) + api.h.nodeNotifier.NotifyByNodeID( + ctx, + types.StateUpdate{ + Type: types.StateSelfUpdate, + ChangeNodes: []types.NodeID{node.ID}, + }, + node.ID) + + ctx = types.NotifyCtx(ctx, "cli-approved-node-peers", node.Hostname) + api.h.nodeNotifier.NotifyWithIgnore( + ctx, + types.StateUpdate{ + Type: types.StatePeerChangedPatch, + ChangePatches: []*tailcfg.PeerChange{ + { + NodeID: node.ID.NodeID(), + }, + }, + }, + node.ID) + + log.Trace(). + Str("node", node.Hostname). + Msg("node approved") + + return &v1.ApproveNodeResponse{Node: node.Proto()}, nil +} + func (api headscaleV1APIServer) ExpireNode( ctx context.Context, request *v1.ExpireNodeRequest, diff --git a/hscontrol/mapper/tail.go b/hscontrol/mapper/tail.go index 4082df2b..cbe05aa7 100644 --- a/hscontrol/mapper/tail.go +++ b/hscontrol/mapper/tail.go @@ -110,7 +110,7 @@ func tailNode( PrimaryRoutes: primaryPrefixes, - MachineAuthorized: !node.IsExpired(), + MachineAuthorized: node.IsApproved(), Expired: node.IsExpired(), } diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go index 3dc822ba..665efe15 100644 --- a/hscontrol/types/config.go +++ b/hscontrol/types/config.go @@ -87,6 +87,8 @@ type Config struct { Policy PolicyConfig Tuning Tuning + + NodeManagement NodeManagement } type DNSConfig struct { @@ -214,6 +216,10 @@ type Tuning struct { NodeMapSessionBufferedChanSize int } +type NodeManagement struct { + ManualApproveNewNode bool +} + // LoadConfig prepares and loads the Headscale configuration into Viper. // This means it sets the default values, reads the configuration file and // environment variables, and handles deprecated configuration options. @@ -292,6 +298,8 @@ func LoadConfig(path string, isFile bool) error { viper.SetDefault("tuning.batch_change_delay", "800ms") viper.SetDefault("tuning.node_mapsession_buffered_chan_size", 30) + viper.SetDefault("node_management.manual_approve_new_node", false) + viper.SetDefault("prefixes.allocation", string(IPAllocationStrategySequential)) if err := viper.ReadInConfig(); err != nil { @@ -749,6 +757,14 @@ func prefixV6() (*netip.Prefix, error) { return &prefixV6, nil } +func nodeManagementConfig() NodeManagement { + manualApproveNewNode := viper.GetBool("node_management.manual_approve_new_node") + + return NodeManagement{ + ManualApproveNewNode: manualApproveNewNode, + } +} + // LoadCLIConfig returns the needed configuration for the CLI client // of Headscale to connect to a Headscale server. func LoadCLIConfig() (*Config, error) { @@ -845,6 +861,8 @@ func LoadServerConfig() (*Config, error) { } } + nodeManagement := nodeManagementConfig() + return &Config{ ServerURL: serverURL, Addr: viper.GetString("listen_addr"), @@ -936,6 +954,8 @@ func LoadServerConfig() (*Config, error) { "tuning.node_mapsession_buffered_chan_size", ), }, + + NodeManagement: nodeManagement, }, nil } diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go index 36a65062..d9342f66 100644 --- a/hscontrol/types/node.go +++ b/hscontrol/types/node.go @@ -83,6 +83,7 @@ type Node struct { LastSeen *time.Time Expiry *time.Time + Approved bool `sql:"DEFAULT:false"` Routes []Route `gorm:"constraint:OnDelete:CASCADE;"` @@ -114,6 +115,11 @@ func (node Node) IsExpired() bool { return time.Since(*node.Expiry) > 0 } +// IsApproved returns whether the node is approved. +func (node Node) IsApproved() bool { + return node.Approved == true +} + // IsEphemeral returns if the node is registered as an Ephemeral node. // https://tailscale.com/kb/1111/ephemeral-nodes/ func (node *Node) IsEphemeral() bool { @@ -249,6 +255,7 @@ func (node *Node) Proto() *v1.Node { ForcedTags: node.ForcedTags, RegisterMethod: node.RegisterMethodToV1Enum(), + Approved: node.Approved, CreatedAt: timestamppb.New(node.CreatedAt), } diff --git a/hscontrol/types/preauth_key.go b/hscontrol/types/preauth_key.go index 0174c9e8..c23f3455 100644 --- a/hscontrol/types/preauth_key.go +++ b/hscontrol/types/preauth_key.go @@ -11,14 +11,15 @@ import ( // PreAuthKey describes a pre-authorization key usable in a particular user. type PreAuthKey struct { - ID uint64 `gorm:"primary_key"` - Key string - UserID uint - User User `gorm:"constraint:OnDelete:CASCADE;"` - Reusable bool - Ephemeral bool `gorm:"default:false"` - Used bool `gorm:"default:false"` - Tags []string `gorm:"serializer:json"` + ID uint64 `gorm:"primary_key"` + Key string + UserID uint + User User `gorm:"constraint:OnDelete:CASCADE;"` + Reusable bool + PreApproved bool `gorm:"default:false"` + Ephemeral bool `gorm:"default:false"` + Used bool `gorm:"default:false"` + Tags []string `gorm:"serializer:json"` CreatedAt *time.Time Expiration *time.Time @@ -26,13 +27,14 @@ type PreAuthKey struct { func (key *PreAuthKey) Proto() *v1.PreAuthKey { protoKey := v1.PreAuthKey{ - User: key.User.Username(), - Id: strconv.FormatUint(key.ID, util.Base10), - Key: key.Key, - Ephemeral: key.Ephemeral, - Reusable: key.Reusable, - Used: key.Used, - AclTags: key.Tags, + User: key.User.Username(), + Id: strconv.FormatUint(key.ID, util.Base10), + Key: key.Key, + Ephemeral: key.Ephemeral, + PreApproved: key.PreApproved, + Reusable: key.Reusable, + Used: key.Used, + AclTags: key.Tags, } if key.Expiration != nil {