1
0
mirror of https://github.com/juanfont/headscale.git synced 2025-08-24 13:46:53 +02:00

Added manual approval of nodes in the network

This commit is contained in:
hopleus 2024-10-16 17:21:48 +03:00
parent 89f70595d1
commit 122d86f2fa
14 changed files with 254 additions and 37 deletions

View File

@ -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",

View File

@ -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")

View File

@ -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

View File

@ -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 {

View File

@ -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 = &registerRequest.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: &registerRequest.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()

View File

@ -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

View File

@ -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")
}

View File

@ -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

View File

@ -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 {

View File

@ -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,

View File

@ -110,7 +110,7 @@ func tailNode(
PrimaryRoutes: primaryPrefixes,
MachineAuthorized: !node.IsExpired(),
MachineAuthorized: node.IsApproved(),
Expired: node.IsExpired(),
}

View File

@ -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
}

View File

@ -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),
}

View File

@ -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 {