Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -528,3 +528,4 @@ assert.EventuallyWithT(t, func(c *assert.CollectT) {
- **Integration Tests**: Require Docker and can consume significant disk space - use headscale-integration-tester agent
- **Performance**: NodeStore optimizations are critical for scale - be careful with changes to state management
- **Quality Assurance**: Always use appropriate specialized agents for testing and validation tasks
- **NEVER create gists in the user's name**: Do not use the `create_gist` tool - present information directly in the response instead
20 changes: 14 additions & 6 deletions hscontrol/auth.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package hscontrol

import (
"cmp"
"context"
"errors"
"fmt"
Expand Down Expand Up @@ -283,19 +284,23 @@ func (h *Headscale) reqToNewRegisterResponse(
return nil, NewHTTPError(http.StatusInternalServerError, "failed to generate registration ID", err)
}

// Ensure we have valid hostinfo and hostname
validHostinfo, hostname := util.EnsureValidHostinfo(
// Ensure we have a valid hostname
hostname := util.EnsureHostname(
req.Hostinfo,
machineKey.String(),
req.NodeKey.String(),
)

// Ensure we have valid hostinfo
hostinfo := cmp.Or(req.Hostinfo, &tailcfg.Hostinfo{})
hostinfo.Hostname = hostname

nodeToRegister := types.NewRegisterNode(
types.Node{
Hostname: hostname,
MachineKey: machineKey,
NodeKey: req.NodeKey,
Hostinfo: validHostinfo,
Hostinfo: hostinfo,
LastSeen: ptr.To(time.Now()),
},
)
Expand Down Expand Up @@ -396,13 +401,15 @@ func (h *Headscale) handleRegisterInteractive(
return nil, fmt.Errorf("generating registration ID: %w", err)
}

// Ensure we have valid hostinfo and hostname
validHostinfo, hostname := util.EnsureValidHostinfo(
// Ensure we have a valid hostname
hostname := util.EnsureHostname(
req.Hostinfo,
machineKey.String(),
req.NodeKey.String(),
)

// Ensure we have valid hostinfo
hostinfo := cmp.Or(req.Hostinfo, &tailcfg.Hostinfo{})
if req.Hostinfo == nil {
log.Warn().
Str("machine.key", machineKey.ShortString()).
Expand All @@ -416,13 +423,14 @@ func (h *Headscale) handleRegisterInteractive(
Str("generated.hostname", hostname).
Msg("Received registration request with empty hostname, generated default")
}
hostinfo.Hostname = hostname

nodeToRegister := types.NewRegisterNode(
types.Node{
Hostname: hostname,
MachineKey: machineKey,
NodeKey: req.NodeKey,
Hostinfo: validHostinfo,
Hostinfo: hostinfo,
LastSeen: ptr.To(time.Now()),
},
)
Expand Down
23 changes: 22 additions & 1 deletion hscontrol/db/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import (
"errors"
"fmt"
"net/netip"
"regexp"
"slices"
"sort"
"strconv"
"strings"
"sync"
"testing"
"time"
Expand All @@ -25,6 +27,10 @@ const (
NodeGivenNameTrimSize = 2
)

var (
invalidDNSRegex = regexp.MustCompile("[^a-z0-9-.]+")
)

var (
ErrNodeNotFound = errors.New("node not found")
ErrNodeRouteIsNotAvailable = errors.New("route is not available on node")
Expand Down Expand Up @@ -259,6 +265,10 @@ func SetLastSeen(tx *gorm.DB, nodeID types.NodeID, lastSeen time.Time) error {
func RenameNode(tx *gorm.DB,
nodeID types.NodeID, newName string,
) error {
if err := util.ValidateHostname(newName); err != nil {
return fmt.Errorf("renaming node: %w", err)
}

// Check if the new name is unique
var count int64
if err := tx.Model(&types.Node{}).Where("given_name = ? AND id != ?", newName, nodeID).Count(&count).Error; err != nil {
Expand Down Expand Up @@ -376,6 +386,14 @@ func RegisterNodeForTest(tx *gorm.DB, node types.Node, ipv4 *netip.Addr, ipv6 *n
node.IPv4 = ipv4
node.IPv6 = ipv6

var err error
node.Hostname, err = util.NormaliseHostname(node.Hostname)
if err != nil {
newHostname := util.InvalidString()
log.Info().Err(err).Str("invalid-hostname", node.Hostname).Str("new-hostname", newHostname).Msgf("Invalid hostname, replacing")
node.Hostname = newHostname
}

if node.GivenName == "" {
givenName, err := EnsureUniqueGivenName(tx, node.Hostname)
if err != nil {
Expand Down Expand Up @@ -432,7 +450,10 @@ func NodeSave(tx *gorm.DB, node *types.Node) error {
}

func generateGivenName(suppliedName string, randomSuffix bool) (string, error) {
suppliedName = util.ConvertWithFQDNRules(suppliedName)
// Strip invalid DNS characters for givenName
suppliedName = strings.ToLower(suppliedName)
suppliedName = invalidDNSRegex.ReplaceAllString(suppliedName, "")

if len(suppliedName) > util.LabelHostnameLength {
return "", types.ErrHostnameTooLong
}
Expand Down
183 changes: 178 additions & 5 deletions hscontrol/db/node_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,7 @@ func TestListEphemeralNodes(t *testing.T) {
assert.Equal(t, nodeEph.Hostname, ephemeralNodes[0].Hostname)
}

func TestRenameNode(t *testing.T) {
func TestNodeNaming(t *testing.T) {
db, err := newSQLiteTestDB()
if err != nil {
t.Fatalf("creating db: %s", err)
Expand Down Expand Up @@ -672,6 +672,26 @@ func TestRenameNode(t *testing.T) {
Hostinfo: &tailcfg.Hostinfo{},
}

// Using non-ASCII characters in the hostname can
// break your network, so they should be replaced when registering
// a node.
// https://github.com/juanfont/headscale/issues/2343
nodeInvalidHostname := types.Node{
MachineKey: key.NewMachine().Public(),
NodeKey: key.NewNode().Public(),
Hostname: "我的电脑",
UserID: user2.ID,
RegisterMethod: util.RegisterMethodAuthKey,
}

nodeShortHostname := types.Node{
MachineKey: key.NewMachine().Public(),
NodeKey: key.NewNode().Public(),
Hostname: "a",
UserID: user2.ID,
RegisterMethod: util.RegisterMethodAuthKey,
}

err = db.DB.Save(&node).Error
require.NoError(t, err)

Expand All @@ -684,18 +704,24 @@ func TestRenameNode(t *testing.T) {
return err
}
_, err = RegisterNodeForTest(tx, node2, nil, nil)

if err != nil {
return err
}
_, err = RegisterNodeForTest(tx, nodeInvalidHostname, ptr.To(mpp("100.64.0.66/32").Addr()), nil)
_, err = RegisterNodeForTest(tx, nodeShortHostname, ptr.To(mpp("100.64.0.67/32").Addr()), nil)
return err
})
require.NoError(t, err)

nodes, err := db.ListNodes()
require.NoError(t, err)

assert.Len(t, nodes, 2)
assert.Len(t, nodes, 4)

t.Logf("node1 %s %s", nodes[0].Hostname, nodes[0].GivenName)
t.Logf("node2 %s %s", nodes[1].Hostname, nodes[1].GivenName)
t.Logf("node3 %s %s", nodes[2].Hostname, nodes[2].GivenName)
t.Logf("node4 %s %s", nodes[3].Hostname, nodes[3].GivenName)

assert.Equal(t, nodes[0].Hostname, nodes[0].GivenName)
assert.NotEqual(t, nodes[1].Hostname, nodes[1].GivenName)
Expand All @@ -707,6 +733,10 @@ func TestRenameNode(t *testing.T) {
assert.Len(t, nodes[1].Hostname, 4)
assert.Len(t, nodes[0].GivenName, 4)
assert.Len(t, nodes[1].GivenName, 13)
assert.Contains(t, nodes[2].Hostname, "invalid-") // invalid chars
assert.Contains(t, nodes[2].GivenName, "invalid-")
assert.Contains(t, nodes[3].Hostname, "invalid-") // too short
assert.Contains(t, nodes[3].GivenName, "invalid-")

// Nodes can be renamed to a unique name
err = db.Write(func(tx *gorm.DB) error {
Expand All @@ -716,7 +746,7 @@ func TestRenameNode(t *testing.T) {

nodes, err = db.ListNodes()
require.NoError(t, err)
assert.Len(t, nodes, 2)
assert.Len(t, nodes, 4)
assert.Equal(t, "test", nodes[0].Hostname)
assert.Equal(t, "newname", nodes[0].GivenName)

Expand All @@ -728,7 +758,7 @@ func TestRenameNode(t *testing.T) {

nodes, err = db.ListNodes()
require.NoError(t, err)
assert.Len(t, nodes, 2)
assert.Len(t, nodes, 4)
assert.Equal(t, "test", nodes[0].Hostname)
assert.Equal(t, "newname", nodes[0].GivenName)
assert.Equal(t, "test", nodes[1].GivenName)
Expand All @@ -738,6 +768,149 @@ func TestRenameNode(t *testing.T) {
return RenameNode(tx, nodes[0].ID, "test")
})
assert.ErrorContains(t, err, "name is not unique")

// Rename invalid chars
err = db.Write(func(tx *gorm.DB) error {
return RenameNode(tx, nodes[2].ID, "我的电脑")
})
assert.ErrorContains(t, err, "invalid characters")

// Rename too short
err = db.Write(func(tx *gorm.DB) error {
return RenameNode(tx, nodes[3].ID, "a")
})
assert.ErrorContains(t, err, "at least 2 characters")

// Rename with emoji
err = db.Write(func(tx *gorm.DB) error {
return RenameNode(tx, nodes[0].ID, "hostname-with-💩")
})
assert.ErrorContains(t, err, "invalid characters")

// Rename with only emoji
err = db.Write(func(tx *gorm.DB) error {
return RenameNode(tx, nodes[0].ID, "🚀")
})
assert.ErrorContains(t, err, "invalid characters")
}

func TestRenameNodeComprehensive(t *testing.T) {
db, err := newSQLiteTestDB()
if err != nil {
t.Fatalf("creating db: %s", err)
}

user, err := db.CreateUser(types.User{Name: "test"})
require.NoError(t, err)

node := types.Node{
ID: 0,
MachineKey: key.NewMachine().Public(),
NodeKey: key.NewNode().Public(),
Hostname: "testnode",
UserID: user.ID,
RegisterMethod: util.RegisterMethodAuthKey,
Hostinfo: &tailcfg.Hostinfo{},
}

err = db.DB.Save(&node).Error
require.NoError(t, err)

err = db.DB.Transaction(func(tx *gorm.DB) error {
_, err := RegisterNodeForTest(tx, node, nil, nil)
return err
})
require.NoError(t, err)

nodes, err := db.ListNodes()
require.NoError(t, err)
assert.Len(t, nodes, 1)

tests := []struct {
name string
newName string
wantErr string
}{
{
name: "uppercase_rejected",
newName: "User2-Host",
wantErr: "must be lowercase",
},
{
name: "underscore_rejected",
newName: "test_node",
wantErr: "invalid characters",
},
{
name: "at_sign_uppercase_rejected",
newName: "Test@Host",
wantErr: "must be lowercase",
},
{
name: "at_sign_rejected",
newName: "test@host",
wantErr: "invalid characters",
},
{
name: "chinese_chars_with_dash_rejected",
newName: "server-北京-01",
wantErr: "invalid characters",
},
{
name: "chinese_only_rejected",
newName: "我的电脑",
wantErr: "invalid characters",
},
{
name: "emoji_with_text_rejected",
newName: "laptop-🚀",
wantErr: "invalid characters",
},
{
name: "mixed_chinese_emoji_rejected",
newName: "测试💻机器",
wantErr: "invalid characters",
},
{
name: "only_emojis_rejected",
newName: "🎉🎊",
wantErr: "invalid characters",
},
{
name: "only_at_signs_rejected",
newName: "@@@",
wantErr: "invalid characters",
},
{
name: "starts_with_dash_rejected",
newName: "-test",
wantErr: "cannot start or end with a hyphen",
},
{
name: "ends_with_dash_rejected",
newName: "test-",
wantErr: "cannot start or end with a hyphen",
},
{
name: "too_long_hostname_rejected",
newName: "this-is-a-very-long-hostname-that-exceeds-sixty-three-characters-limit",
wantErr: "must not exceed 63 characters",
},
{
name: "too_short_hostname_rejected",
newName: "a",
wantErr: "at least 2 characters",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := db.Write(func(tx *gorm.DB) error {
return RenameNode(tx, nodes[0].ID, tt.newName)
})
assert.ErrorContains(t, err, tt.wantErr)
})
}
}

func TestListPeers(t *testing.T) {
Expand Down
6 changes: 2 additions & 4 deletions hscontrol/db/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ func (hsdb *HSDatabase) CreateUser(user types.User) (*types.User, error) {
// CreateUser creates a new User. Returns error if could not be created
// or another user already exists.
func CreateUser(tx *gorm.DB, user types.User) (*types.User, error) {
err := util.ValidateUsername(user.Name)
if err != nil {
if err := util.ValidateHostname(user.Name); err != nil {
return nil, err
}
if err := tx.Create(&user).Error; err != nil {
Expand Down Expand Up @@ -93,8 +92,7 @@ func RenameUser(tx *gorm.DB, uid types.UserID, newName string) error {
if err != nil {
return err
}
err = util.ValidateUsername(newName)
if err != nil {
if err = util.ValidateHostname(newName); err != nil {
return err
}

Expand Down
Loading
Loading