Repository: juanfont/headscale

Issue:
[Bug] If a computer set an invalid hostname, the entire network will be unavailable (re-emerging)
### Is this a support request?

- [x] This is not a support request

### Is there an existing issue for this?

- [x] I have searched the existing issues

### Current Behavior

I have already learned that this issue was fixed in v0.25.0 #2343 #2393 , but it has reappeared in the current v0.26.1 version.

### Expected Behavior

Automatically replace node name with the correct string format.

### Steps To Reproduce

Just need to register a pure CJK character name Tailnet node on Headscale.

### Environment

```markdown
- OS: AlmaLinux 9
- Headscale version: v0.26.1
- Tailscale version: v1.86.2
```

### Runtime environment

- [x] Headscale is behind a (reverse) proxy
- [x] Headscale runs in a container

### Debug information

Headscale Version:

<img width="475" height="42" alt="Image" src="https://github.com/user-attachments/assets/c590954e-e5af-4db7-826a-2b3303cb4715" />

Error Logs:

<img width="930" height="303" alt="Image" src="https://github.com/user-attachments/assets/dae9de08-9687-48fd-ab0c-079e37c75fc3" />

My Nodes:

<img width="934" height="317" alt="Image" src="https://github.com/user-attachments/assets/010db2c8-9d63-48ec-aa74-d2f915b94811" />

After I executed the following command, all nodes immediately returned to normal:

`podman exec -it headscale headscale nodes rename -i 8 abc`

Discussion:
@spymobilfon: I have the same issue with another symbol

![Image](https://github.com/user-attachments/assets/d3e6f05f-a97d-4631-a850-eb0f4a3388a6)@nblock: The [proposed fix was not merged](https://github.com/juanfont/headscale/pull/2383).

> Just need to register a pure CJK character name Tailnet node on Headscale.

Can you provide more details, please? I could not reproduce this on a Debian 13 host:

-  `tailscale up --hostname='aa的aa'` is refused by the client
- `hostnamectl hostname 的` sets the transient hostname to `localhost` and the client uses `localhost` as hostname during registration.
- Setting `/etc/hostname` to `aa的aa` sets the hostname to `aaaa` which the client also uses during registration




@spymobilfon: Environment:
- Headscale 0.26.1, Ubuntu 22.04
- Tailscale 1.84.2, Windows 11 Pro 23H2

Step to reproduce:
1. Set emoji in hostname
2. Reboot laptop
3. Check hostname (see screenshots)

<img width="252" height="179" alt="Image" src="https://github.com/user-attachments/assets/5a7ecbbd-9687-4c01-81d7-78c21cbdeb26" />

<img width="340" height="89" alt="Image" src="https://github.com/user-attachments/assets/70e7266e-1e1f-49d3-85f0-a7292fb941d7" />

4. Login in Tailscale
5. VPN doesn't work, you can't open sites which are routing via VPN

I can't reproduce in Headscale with PostgreSQL, as far as I understand this issue affects only sqlite.

What I found in the sqlite database
```
sqlite> select * from nodes where id = '2424';
2424|mkey:c73d7352c0164b44070e690bc9acf3c1c24c24399742e9e06c9e5982ac524f16|nodekey:9d1f2eb55a7c9114beb76844111b507dbba038a8cabbc7e9381ef00a7541bf6d|discokey:0000000000000000000000000000000000000000000000000000000000000000||{"IPNVersion":"1.84.2-t5d271bebf-gfb9977414","BackendLogID":"ca903e15f2328a2d67ed26341bdf4ee075f795e09c5984fa26385b29baf32621","OS":"windows","OSVersion":"10.0.22631.5909","Package":"msi","Hostname":"𓂀","NoLogsNoSupport":true,"GoArch":"amd64","GoArchVar":"v1","GoVersion":"go1.24.2","Services":[{"Proto":"peerapi4","Port":57166},{"Proto":"peerapi6","Port":53902},{"Proto":"peerapi-dns-proxy","Port":1}],"Userspace":false,"UserspaceRouter":true,"AppConnector":false}|XXX|XXX|𓂀||1393|oidc|||2025-10-13 12:19:46.388546217+00:00|2025-10-14 12:17:45.708340613+00:00||2025-10-13 12:17:45.737119618+00:00|2025-10-13 12:19:46.38858028+00:00|
sqlite> select hostname, given_name from nodes where id = '2424';
𓂀|
sqlite> select given_name from nodes where id = '2424';

```

What I found in the Headscale logs
```
Oct 13 12:17:45 headscale headscale[3348249]: 2025-10-13T12:17:45.741Z INF github.com/juanfont/headscale/hscontrol/db/node.go:525 > Node registered with the database machine_key=[xz1zU] node="𓂀" node_id=2424 node_key=[nR8ut] sumsub=true user=XXX
Oct 13 12:17:45 headscale headscale[3348249]: 2025-10-13T12:17:45.861Z TRC github.com/juanfont/headscale/hscontrol/poll.go:618 > a node sending a MapRequest with Noise protocol node="𓂀" node.id=2424 omitPeers=true readOnly=false stream=false
Oct 13 12:17:45 headscale headscale[3348249]: 2025-10-13T12:17:45.861Z TRC github.com/juanfont/headscale/hscontrol/poll.go:618 > received endpoint update node="𓂀" node.id=2424 omitPeers=true readOnly=false stream=false
Oct 13 12:17:45 headscale headscale[3348249]: 2025-10-13T12:17:45.861Z TRC github.com/juanfont/headscale/hscontrol/poll.go:573 > PeerChange received disco_key=d:cc0268511bbff4d9 endpoints=["XXX:20103","XXX:41641","XXX:41641","XXX:41641"] hostname="𓂀" last_seen=1760357865861 node.id=2424 online=false
Oct 13 12:17:45 headscale headscale[3348249]: 2025-10-13T12:17:45.864Z TRC github.com/juanfont/headscale/hscontrol/poll.go:618 > a node sending a MapRequest with Noise protocol node="𓂀" node.id=2424 omitPeers=false readOnly=false stream=true
Oct 13 12:17:45 headscale headscale[3348249]: 2025-10-13T12:17:45.865Z TRC github.com/juanfont/headscale/hscontrol/notifier/notifier.go:95 > added new channel node.id=2424 open_chans=3
Oct 13 12:17:45 headscale headscale[3348249]: 2025-10-13T12:17:45.865Z INF github.com/juanfont/headscale/hscontrol/poll.go:608 > node has connected, mapSession: 0xc0017cc000, chan: 0xc000244d20 node="𓂀" node.id=2424 omitPeers=false readOnly=false stream=true
Oct 13 12:17:45 headscale headscale[3348249]: 2025-10-13T12:17:45.865Z TRC github.com/juanfont/headscale/hscontrol/poll.go:618 > received stream update: StateFullUpdate  node="𓂀" node.id=2424 omitPeers=false readOnly=false stream=true
Oct 13 12:17:45 headscale headscale[3348249]: 2025-10-13T12:17:45.867Z TRC github.com/juanfont/headscale/hscontrol/poll.go:618 > Sending Full MapResponse node="𓂀" node.id=2424 omitPeers=false readOnly=false stream=true
Oct 13 12:17:46 headscale headscale[3348249]: 2025-10-13T12:17:46.101Z ERR github.com/juanfont/headscale/hscontrol/poll.go:629 > Could not get the create map update error="tailNode, failed to create FQDN: failed to create valid FQDN: node has no given name" node="𓂀" node.id=2424 omitPeers=false readOnly=false stream=true
Oct 13 12:17:46 headscale headscale[3348249]: 2025-10-13T12:17:46.101Z TRC github.com/juanfont/headscale/hscontrol/notifier/notifier.go:95 > removed channel node.id=2424 open_chans=1
Oct 13 12:17:46 headscale headscale[3348249]: 2025-10-13T12:17:46.102Z INF github.com/juanfont/headscale/hscontrol/poll.go:608 > node has disconnected, mapSession: 0xc0017cc000, chan: 0xc000244d20 node="𓂀" node.id=2424 omitPeers=false readOnly=false stream=true
```

and next many errors like this
```
Could not get the create map update error="tailNode, failed to create FQDN: failed to create valid FQDN: node has no given name" node="𓂀" node.id=2424 omitPeers=false readOnly=false stream=true
```

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bc0df872..0900c141 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -84,6 +84,20 @@ the code base over time and make it more correct and efficient.
   [#2692](https://github.com/juanfont/headscale/pull/2692)
 - Policy: Zero or empty destination port is no longer allowed
   [#2606](https://github.com/juanfont/headscale/pull/2606)
+- Stricter hostname validation [#2383](https://github.com/juanfont/headscale/pull/2383)
+  - Hostnames must be valid DNS labels (2-63 characters, alphanumeric and
+    hyphens only, cannot start/end with hyphen)
+  - **Client Registration (New Nodes)**: Invalid hostnames are automatically
+    renamed to `invalid-XXXXXX` format
+    - `my-laptop` → accepted as-is
+    - `My-Laptop` → `my-laptop` (lowercased)
+    - `my_laptop` → `invalid-a1b2c3` (underscore not allowed)
+    - `test@host` → `invalid-d4e5f6` (@ not allowed)
+    - `laptop-🚀` → `invalid-j1k2l3` (emoji not allowed)
+  - **Hostinfo Updates / CLI**: Invalid hostnames are rejected with an error
+    - Valid names are accepted or lowercased
+    - Names with invalid characters, too short (<2), too long (>63), or
+      starting/ending with hyphen are rejected
 
 ### Changes
 
@@ -192,7 +206,7 @@ new policy code passes all of our tests.
 - Error messages should be more descriptive and informative.
   - There is still work to be here, but it is already improved with "typing"
     (e.g. only Users can be put in Groups)
-- All users must contain an `@` character.
+- All users in the policy must contain an `@` character.
   - If your user naturally contains and `@`, like an email, this will just work.
   - If its based on usernames, or other identifiers not containing an `@`, an
     `@` should be appended at the end. For example, if your user is `john`, it
diff --git a/CLAUDE.md b/CLAUDE.md
index cf2242f8..d4034367 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/config-example.yaml b/config-example.yaml
index 3d5a6a4d..ec14dc03 100644
--- a/config-example.yaml
+++ b/config-example.yaml
@@ -393,11 +393,13 @@ unix_socket_permission: "0770"
 #     method: S256
 
 # Logtail configuration
-# Logtail is Tailscales logging and auditing infrastructure, it allows the control panel
-# to instruct tailscale nodes to log their activity to a remote server.
+# Logtail is Tailscales logging and auditing infrastructure, it allows the
+# control panel to instruct tailscale nodes to log their activity to a remote
+# server. To disable logging on the client side, please refer to:
+# https://tailscale.com/kb/1011/log-mesh-traffic#opting-out-of-client-logging
 logtail:
-  # Enable logtail for this headscales clients.
-  # As there is currently no support for overriding the log server in headscale, this is
+  # Enable logtail for tailscale nodes of this Headscale instance.
+  # As there is currently no support for overriding the log server in Headscale, this is
   # disabled by default. Enabling this will make your clients send logs to Tailscale Inc.
   enabled: false
 
diff --git a/docs/about/faq.md b/docs/about/faq.md
index e67a47d9..ecedf198 100644
--- a/docs/about/faq.md
+++ b/docs/about/faq.md
@@ -159,3 +159,19 @@ indicates which part of the policy is invalid. Follow these steps to fix your po
     The above commands to get/set the policy require a complete server configuration file including database settings. A
     minimal config to [control Headscale via remote CLI](../ref/remote-cli.md) is not sufficient. You may use `headscale
     -c /path/to/config.yaml` to specify the path to an alternative configuration file.
+
+## How can I avoid to send logs to Tailscale Inc?
+
+A Tailscale client [collects logs about its operation and connection attempts with other
+clients](https://tailscale.com/kb/1011/log-mesh-traffic#client-logs) and sends them to a central log service operated by
+Tailscale Inc.
+
+Headscale, by default, instructs clients to disable log submission to the central log service. This configuration is
+applied by a client once it successfully connected with Headscale. See the configuration option `logtail.enabled` in the
+[configuration file](../ref/configuration.md) for details.
+
+Alternatively, logging can also be disabled on the client side. This is independent of Headscale and opting out of
+client logging disables log submission early during client startup. The configuration is operating system specific and
+is usually achieved by setting the environment variable `TS_NO_LOGS_NO_SUPPORT=true` or by passing the flag
+`--no-logs-no-support` to `tailscaled`. See
+<https://tailscale.com/kb/1011/log-mesh-traffic#opting-out-of-client-logging> for details.
diff --git a/docs/ref/acls.md b/docs/ref/acls.md
index 94386a13..53ab24ac 100644
--- a/docs/ref/acls.md
+++ b/docs/ref/acls.md
@@ -210,7 +210,7 @@ Headscale supports several autogroups that automatically include users, destinat
 
 ### `autogroup:internet`
 
-Allows access to the internet through [exit nodes](routes.md#exit-node). Can only be used in ACL destinations.  
+Allows access to the internet through [exit nodes](routes.md#exit-node). Can only be used in ACL destinations.
 
 ```json
 {
@@ -244,10 +244,10 @@ Includes all devices that have at least one tag.
 }
 ```
 
-### `autogroup:self` 
+### `autogroup:self`
 **(EXPERIMENTAL)**
 
-!!! warning "The current implementation of `autogroup:self` is inefficient"  
+!!! warning "The current implementation of `autogroup:self` is inefficient"
 
 Includes devices where the same user is authenticated on both the source and destination. Does not include tagged devices. Can only be used in ACL destinations.
 
@@ -260,15 +260,16 @@ Includes devices where the same user is authenticated on both the source and des
 ```
 *Using `autogroup:self` may cause performance degradation on the Headscale coordinator server in large deployments, as filter rules must be compiled per-node rather than globally and the current implementation is not very efficient.*
 
-If you experience performance issues, consider using more specific ACL rules or limiting the use of `autogroup:self`.  
-```json    
+If you experience performance issues, consider using more specific ACL rules or limiting the use of `autogroup:self`.
+```json
 {
-// To allow internal users communications to their own nodes we can do following rules to allow access in case autogroup:self is causing performance issues.
-{ "action": "accept", "src": ["boss@"], "dst": ["boss@:"] },
-{ "action": "accept", "src": ["dev1@"], "dst": ["dev1@:*"] },
-{ "action": "accept", "src": ["dev2@"], "dst": ["dev2@:"] },
-{ "action": "accept", "src": ["admin1@"], "dst": ["admin1@:"] },
-{ "action": "accept", "src": ["intern1@"], "dst": ["intern1@:"] }
+  // The following rules allow internal users to communicate with their
+  // own nodes in case autogroup:self is causing performance issues.
+  { "action": "accept", "src": ["boss@"], "dst": ["boss@:*"] },
+  { "action": "accept", "src": ["dev1@"], "dst": ["dev1@:*"] },
+  { "action": "accept", "src": ["dev2@"], "dst": ["dev2@:*"] },
+  { "action": "accept", "src": ["admin1@"], "dst": ["admin1@:*"] },
+  { "action": "accept", "src": ["intern1@"], "dst": ["intern1@:*"] }
 }
 ```
 
diff --git a/docs/ref/remote-cli.md b/docs/ref/remote-cli.md
index 10c7534f..61df67fd 100644
--- a/docs/ref/remote-cli.md
+++ b/docs/ref/remote-cli.md
@@ -67,12 +67,6 @@ headscale apikeys expire --prefix "<PREFIX>"
         export HEADSCALE_CLI_API_KEY="<API_KEY_FROM_PREVIOUS_STEP>"
         ```
 
-        !!! bug
-
-            Headscale currently requires at least an empty configuration file when environment variables are used to
-            specify connection details. See [issue 2193](https://github.com/juanfont/headscale/issues/2193) for more
-            information.
-
     This instructs the `headscale` binary to connect to a remote instance at `<HEADSCALE_ADDRESS>:<PORT>`, instead of
     connecting to the local instance.
 
diff --git a/docs/setup/install/container.md b/docs/setup/install/container.md
index d8f6113b..98caa19c 100644
--- a/docs/setup/install/container.md
+++ b/docs/setup/install/container.md
@@ -39,6 +39,7 @@ Registry](https://github.com/juanfont/headscale/pkgs/container/headscale). The c
       --volume "$(pwd)/run:/var/run/headscale" \
       --publish 127.0.0.1:8080:8080 \
       --publish 127.0.0.1:9090:9090 \
+      --health-cmd "CMD headscale health" \
       docker.io/headscale/headscale:<VERSION> \
       serve
     ```
@@ -66,6 +67,8 @@ Registry](https://github.com/juanfont/headscale/pkgs/container/headscale). The c
           - <HEADSCALE_PATH>/lib:/var/lib/headscale
           - <HEADSCALE_PATH>/run:/var/run/headscale
         command: serve
+        healthcheck:
+            test: ["CMD", "headscale", "health"]
     ```
 
 1.  Verify headscale is running:
diff --git a/hscontrol/app.go b/hscontrol/app.go
index 6880c6be..cedd624d 100644
--- a/hscontrol/app.go
+++ b/hscontrol/app.go
@@ -380,53 +380,45 @@ func (h *Headscale) httpAuthenticationMiddleware(next http.Handler) http.Handler
 		writer http.ResponseWriter,
 		req *http.Request,
 	) {
-		if err := func() error {
-			log.Trace().
-				Caller().
-				Str("client_address", req.RemoteAddr).
-				Msg("HTTP authentication invoked")
-
-			authHeader := req.Header.Get("Authorization")
-
-			if !strings.HasPrefix(authHeader, AuthPrefix) {
-				log.Error().
-					Caller().
-					Str("client_address", req.RemoteAddr).
-					Msg(`missing "Bearer " prefix in "Authorization" header`)
-				writer.WriteHeader(http.StatusUnauthorized)
-				_, err := writer.Write([]byte("Unauthorized"))
-				return err
-			}
+		log.Trace().
+			Caller().
+			Str("client_address", req.RemoteAddr).
+			Msg("HTTP authentication invoked")
 
-			valid, err := h.state.ValidateAPIKey(strings.TrimPrefix(authHeader, AuthPrefix))
-			if err != nil {
-				log.Error().
-					Caller().
-					Err(err).
-					Str("client_address", req.RemoteAddr).
-					Msg("failed to validate token")
+		authHeader := req.Header.Get("Authorization")
 
-				writer.WriteHeader(http.StatusInternalServerError)
-				_, err := writer.Write([]byte("Unauthorized"))
-				return err
+		writeUnauthorized := func(statusCode int) {
+			writer.WriteHeader(statusCode)
+			if _, err := writer.Write([]byte("Unauthorized")); err != nil {
+				log.Error().Err(err).Msg("writing HTTP response failed")
 			}
+		}
 
-			if !valid {
-				log.Info().
-					Str("client_address", req.RemoteAddr).
-					Msg("invalid token")
-
-				writer.WriteHeader(http.StatusUnauthorized)
-				_, err := writer.Write([]byte("Unauthorized"))
-				return err
-			}
+		if !strings.HasPrefix(authHeader, AuthPrefix) {
+			log.Error().
+				Caller().
+				Str("client_address", req.RemoteAddr).
+				Msg(`missing "Bearer " prefix in "Authorization" header`)
+			writeUnauthorized(http.StatusUnauthorized)
+			return
+		}
 
-			return nil
-		}(); err != nil {
+		valid, err := h.state.ValidateAPIKey(strings.TrimPrefix(authHeader, AuthPrefix))
+		if err != nil {
 			log.Error().
 				Caller().
 				Err(err).
-				Msg("Failed to write HTTP response")
+				Str("client_address", req.RemoteAddr).
+				Msg("failed to validate token")
+			writeUnauthorized(http.StatusInternalServerError)
+			return
+		}
+
+		if !valid {
+			log.Info().
+				Str("client_address", req.RemoteAddr).
+				Msg("invalid token")
+			writeUnauthorized(http.StatusUnauthorized)
 			return
 		}
 
diff --git a/hscontrol/auth.go b/hscontrol/auth.go
index 22f8cd7c..e4a0d089 100644
--- a/hscontrol/auth.go
+++ b/hscontrol/auth.go
@@ -1,6 +1,7 @@
 package hscontrol
 
 import (
+	"cmp"
 	"context"
 	"errors"
 	"fmt"
@@ -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()),
 		},
 	)
@@ -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()).
@@ -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()),
 		},
 	)
diff --git a/hscontrol/db/node.go b/hscontrol/db/node.go
index e54011c5..5493a55c 100644
--- a/hscontrol/db/node.go
+++ b/hscontrol/db/node.go
@@ -5,9 +5,11 @@ import (
 	"errors"
 	"fmt"
 	"net/netip"
+	"regexp"
 	"slices"
 	"sort"
 	"strconv"
+	"strings"
 	"sync"
 	"testing"
 	"time"
@@ -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")
@@ -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 {
@@ -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 {
@@ -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
 	}
diff --git a/hscontrol/db/users.go b/hscontrol/db/users.go
index 26d10060..08ed048c 100644
--- a/hscontrol/db/users.go
+++ b/hscontrol/db/users.go
@@ -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 {
@@ -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
 	}
 
diff --git a/hscontrol/mapper/batcher_lockfree.go b/hscontrol/mapper/batcher_lockfree.go
index b403fd14..20daee6b 100644
--- a/hscontrol/mapper/batcher_lockfree.go
+++ b/hscontrol/mapper/batcher_lockfree.go
@@ -602,7 +602,7 @@ func (mc *multiChannelNodeConn) send(data *tailcfg.MapResponse) error {
 
 	mc.updateCount.Add(1)
 
-	log.Info().Uint64("node.id", mc.id.Uint64()).
+	log.Debug().Uint64("node.id", mc.id.Uint64()).
 		Int("successful_sends", successCount).
 		Int("failed_connections", len(failedConnections)).
 		Int("remaining_connections", len(mc.connections)).
diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go
index 1e138ea0..7585c4e3 100644
--- a/hscontrol/state/state.go
+++ b/hscontrol/state/state.go
@@ -662,8 +662,7 @@ func (s *State) SetApprovedRoutes(nodeID types.NodeID, routes []netip.Prefix) (t
 
 // RenameNode changes the display name of a node.
 func (s *State) RenameNode(nodeID types.NodeID, newName string) (types.NodeView, change.ChangeSet, error) {
-	// Validate the new name before making any changes
-	if err := util.CheckForFQDNRules(newName); err != nil {
+	if err := util.ValidateHostname(newName); err != nil {
 		return types.NodeView{}, change.EmptySet, fmt.Errorf("renaming node: %w", err)
 	}
 
@@ -1112,13 +1111,17 @@ func (s *State) HandleNodeFromAuthPath(
 		return types.NodeView{}, change.EmptySet, fmt.Errorf("failed to find user: %w", err)
 	}
 
-	// Ensure we have valid hostinfo and hostname from the registration cache entry
-	validHostinfo, hostname := util.EnsureValidHostinfo(
+	// Ensure we have a valid hostname from the registration cache entry
+	hostname := util.EnsureHostname(
 		regEntry.Node.Hostinfo,
 		regEntry.Node.MachineKey.String(),
 		regEntry.Node.NodeKey.String(),
 	)
 
+	// Ensure we have valid hostinfo
+	validHostinfo := cmp.Or(regEntry.Node.Hostinfo, &tailcfg.Hostinfo{})
+	validHostinfo.Hostname = hostname
+
 	logHostinfoValidation(
 		regEntry.Node.MachineKey.ShortString(),
 		regEntry.Node.NodeKey.String(),
@@ -1284,13 +1287,17 @@ func (s *State) HandleNodeFromPreAuthKey(
 		return types.NodeView{}, change.EmptySet, err
 	}
 
-	// Ensure we have valid hostinfo and hostname - handle nil/empty cases
-	validHostinfo, hostname := util.EnsureValidHostinfo(
+	// Ensure we have a valid hostname - handle nil/empty cases
+	hostname := util.EnsureHostname(
 		regReq.Hostinfo,
 		machineKey.String(),
 		regReq.NodeKey.String(),
 	)
 
+	// Ensure we have valid hostinfo
+	validHostinfo := cmp.Or(regReq.Hostinfo, &tailcfg.Hostinfo{})
+	validHostinfo.Hostname = hostname
+
 	logHostinfoValidation(
 		machineKey.ShortString(),
 		regReq.NodeKey.ShortString(),
diff --git a/hscontrol/types/config.go b/hscontrol/types/config.go
index d4a7d662..010e3410 100644
--- a/hscontrol/types/config.go
+++ b/hscontrol/types/config.go
@@ -340,11 +340,11 @@ func LoadConfig(path string, isFile bool) error {
 	viper.SetDefault("prefixes.allocation", string(IPAllocationStrategySequential))
 
 	if err := viper.ReadInConfig(); err != nil {
-		if errors.Is(err, fs.ErrNotExist) {
-			log.Warn().Msg("No config file found, using defaults")
-			return nil
-		}
-
+    	if _, ok := err.(viper.ConfigFileNotFoundError); ok {
+        	log.Warn().Msg("No config file found, using defaults")
+        	return nil
+    	}
+    	
 		return fmt.Errorf("fatal error reading config file: %w", err)
 	}
 
diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go
index 6b20091b..a70861ac 100644
--- a/hscontrol/types/node.go
+++ b/hscontrol/types/node.go
@@ -4,6 +4,7 @@ import (
 	"errors"
 	"fmt"
 	"net/netip"
+	"regexp"
 	"slices"
 	"sort"
 	"strconv"
@@ -27,6 +28,8 @@ var (
 	ErrHostnameTooLong      = errors.New("hostname too long, cannot except 255 ASCII chars")
 	ErrNodeHasNoGivenName   = errors.New("node has no given name")
 	ErrNodeUserHasNoName    = errors.New("node user has no name")
+
+	invalidDNSRegex = regexp.MustCompile("[^a-z0-9-.]+")
 )
 
 type (
@@ -144,7 +147,10 @@ func (ns Nodes) ViewSlice() views.Slice[NodeView] {
 
 // GivenNameHasBeenChanged returns whether the `givenName` can be automatically changed based on the `Hostname` of the node.
 func (node *Node) GivenNameHasBeenChanged() bool {
-	return node.GivenName == util.ConvertWithFQDNRules(node.Hostname)
+	// Strip invalid DNS characters for givenName comparison
+	normalised := strings.ToLower(node.Hostname)
+	normalised = invalidDNSRegex.ReplaceAllString(normalised, "")
+	return node.GivenName == normalised
 }
 
 // IsExpired returns whether the node registration has expired.
@@ -531,20 +537,34 @@ func (node *Node) ApplyHostnameFromHostInfo(hostInfo *tailcfg.Hostinfo) {
 		return
 	}
 
-	if node.Hostname != hostInfo.Hostname {
+	newHostname := strings.ToLower(hostInfo.Hostname)
+	if err := util.ValidateHostname(newHostname); err != nil {
+		log.Warn().
+			Str("node.id", node.ID.String()).
+			Str("current_hostname", node.Hostname).
+			Str("rejected_hostname", hostInfo.Hostname).
+			Err(err).
+			Msg("Rejecting invalid hostname update from hostinfo")
+		return
+	}
+
+	if node.Hostname != newHostname {
 		log.Trace().
 			Str("node.id", node.ID.String()).
 			Str("old_hostname", node.Hostname).
-			Str("new_hostname", hostInfo.Hostname).
+			Str("new_hostname", newHostname).
 			Str("old_given_name", node.GivenName).
 			Bool("given_name_changed", node.GivenNameHasBeenChanged()).
 			Msg("Updating hostname from hostinfo")
 
 		if node.GivenNameHasBeenChanged() {
-			node.GivenName = util.ConvertWithFQDNRules(hostInfo.Hostname)
+			// Strip invalid DNS characters for givenName display
+			givenName := strings.ToLower(newHostname)
+			givenName = invalidDNSRegex.ReplaceAllString(givenName, "")
+			node.GivenName = givenName
 		}
 
-		node.Hostname = hostInfo.Hostname
+		node.Hostname = newHostname
 
 		log.Trace().
 			Str("node.id", node.ID.String()).
diff --git a/hscontrol/util/dns.go b/hscontrol/util/dns.go
index 65194720..898f965d 100644
--- a/hscontrol/util/dns.go
+++ b/hscontrol/util/dns.go
@@ -27,7 +27,7 @@ var (
 	invalidCharsInUserRegex = regexp.MustCompile("[^a-z0-9-.]+")
 )
 
-var ErrInvalidUserName = errors.New("invalid user name")
+var ErrInvalidHostName = errors.New("invalid hostname")
 
 // ValidateUsername checks if a username is valid.
 // It must be at least 2 characters long, start with a letter, and contain
@@ -67,42 +67,86 @@ func ValidateUsername(username string) error {
 	return nil
 }
 
-func CheckForFQDNRules(name string) error {
-	// Ensure the username meets the minimum length requirement
+// ValidateHostname checks if a hostname meets DNS requirements.
+// This function does NOT modify the input - it only validates.
+// The hostname must already be lowercase and contain only valid characters.
+func ValidateHostname(name string) error {
 	if len(name) < 2 {
-		return errors.New("name must be at least 2 characters long")
+		return fmt.Errorf(
+			"hostname %q is too short, must be at least 2 characters",
+			name,
+		)
 	}
-
 	if len(name) > LabelHostnameLength {
 		return fmt.Errorf(
-			"DNS segment must not be over 63 chars. %v doesn't comply with this rule: %w",
+			"hostname %q is too long, must not exceed 63 characters",
 			name,
-			ErrInvalidUserName,
 		)
 	}
 	if strings.ToLower(name) != name {
 		return fmt.Errorf(
-			"DNS segment should be lowercase. %v doesn't comply with this rule: %w",
+			"hostname %q must be lowercase (try %q)",
+			name,
+			strings.ToLower(name),
+		)
+	}
+	if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") {
+		return fmt.Errorf(
+			"hostname %q cannot start or end with a hyphen",
+			name,
+		)
+	}
+	if strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
+		return fmt.Errorf(
+			"hostname %q cannot start or end with a dot",
 			name,
-			ErrInvalidUserName,
 		)
 	}
 	if invalidDNSRegex.MatchString(name) {
 		return fmt.Errorf(
-			"DNS segment should only be composed of lowercase ASCII letters numbers, hyphen and dots. %v doesn't comply with these rules: %w",
+			"hostname %q contains invalid characters, only lowercase letters, numbers, hyphens and dots are allowed",
 			name,
-			ErrInvalidUserName,
 		)
 	}
 
 	return nil
 }
 
-func ConvertWithFQDNRules(name string) string {
+// NormaliseHostname transforms a string into a valid DNS hostname.
+// Returns error if the transformation results in an invalid hostname.
+//
+// Transformations applied:
+// - Converts to lowercase
+// - Removes invalid DNS characters
+// - Truncates to 63 characters if needed
+//
+// After transformation, validates the result.
+func NormaliseHostname(name string) (string, error) {
+	// Early return if already valid
+	if err := ValidateHostname(name); err == nil {
+		return name, nil
+	}
+
+	// Transform to lowercase
 	name = strings.ToLower(name)
+
+	// Strip invalid DNS characters
 	name = invalidDNSRegex.ReplaceAllString(name, "")
 
-	return name
+	// Truncate to DNS label limit
+	if len(name) > LabelHostnameLength {
+		name = name[:LabelHostnameLength]
+	}
+
+	// Validate result after transformation
+	if err := ValidateHostname(name); err != nil {
+		return "", fmt.Errorf(
+			"hostname invalid after normalisation: %w",
+			err,
+		)
+	}
+
+	return name, nil
 }
 
 // generateMagicDNSRootDomains generates a list of DNS entries to be included in `Routes` in `MapResponse`.
diff --git a/hscontrol/util/string.go b/hscontrol/util/string.go
index 624d8bc0..d1d7ece7 100644
--- a/hscontrol/util/string.go
+++ b/hscontrol/util/string.go
@@ -66,6 +66,11 @@ func MustGenerateRandomStringDNSSafe(size int) string {
 	return hash
 }
 
+func InvalidString() string {
+	hash, _ := GenerateRandomStringDNSSafe(8)
+	return "invalid-" + hash
+}
+
 func TailNodesToString(nodes []*tailcfg.Node) string {
 	temp := make([]string, len(nodes))
 
diff --git a/hscontrol/util/util.go b/hscontrol/util/util.go
index 143998cc..a9dc748e 100644
--- a/hscontrol/util/util.go
+++ b/hscontrol/util/util.go
@@ -1,6 +1,7 @@
 package util
 
 import (
+	"cmp"
 	"errors"
 	"fmt"
 	"net/netip"
@@ -264,54 +265,32 @@ func IsCI() bool {
 // if Hostinfo is nil or Hostname is empty. This prevents nil pointer dereferences
 // and ensures nodes always have a valid hostname.
 // The hostname is truncated to 63 characters to comply with DNS label length limits (RFC 1123).
-func SafeHostname(hostinfo *tailcfg.Hostinfo, machineKey, nodeKey string) string {
+// EnsureHostname guarantees a valid hostname for node registration.
+// This function never fails - it always returns a valid hostname.
+//
+// Strategy:
+// 1. If hostinfo is nil/empty → generate default from keys
+// 2. If hostname is provided → normalise it
+// 3. If normalisation fails → generate invalid-<random> replacement
+//
+// Returns the guaranteed-valid hostname to use.
+func EnsureHostname(hostinfo *tailcfg.Hostinfo, machineKey, nodeKey string) string {
 	if hostinfo == nil || hostinfo.Hostname == "" {
-		// Generate a default hostname using machine key prefix
-		if machineKey != "" {
-			keyPrefix := machineKey
-			if len(machineKey) > 8 {
-				keyPrefix = machineKey[:8]
-			}
-			return fmt.Sprintf("node-%s", keyPrefix)
+		key := cmp.Or(machineKey, nodeKey)
+		if key == "" {
+			return "unknown-node"
 		}
-		if nodeKey != "" {
-			keyPrefix := nodeKey
-			if len(nodeKey) > 8 {
-				keyPrefix = nodeKey[:8]
-			}
-			return fmt.Sprintf("node-%s", keyPrefix)
+		keyPrefix := key
+		if len(key) > 8 {
+			keyPrefix = key[:8]
 		}
-		return "unknown-node"
-	}
-
-	hostname := hostinfo.Hostname
-
-	// Validate hostname length - DNS label limit is 63 characters (RFC 1123)
-	// Truncate if necessary to ensure compatibility with given name generation
-	if len(hostname) > 63 {
-		hostname = hostname[:63]
+		return fmt.Sprintf("node-%s", keyPrefix)
 	}
 
-	return hostname
-}
-
-// EnsureValidHostinfo ensures that Hostinfo is non-nil and has a valid hostname.
-// If Hostinfo is nil, it creates a minimal valid Hostinfo with a generated hostname.
-// Returns the validated/created Hostinfo and the extracted hostname.
-func EnsureValidHostinfo(hostinfo *tailcfg.Hostinfo, machineKey, nodeKey string) (*tailcfg.Hostinfo, string) {
-	if hostinfo == nil {
-		hostname := SafeHostname(nil, machineKey, nodeKey)
-		return &tailcfg.Hostinfo{
-			Hostname: hostname,
-		}, hostname
-	}
-
-	hostname := SafeHostname(hostinfo, machineKey, nodeKey)
-
-	// Update the hostname in the hostinfo if it was empty or if it was truncated
-	if hostinfo.Hostname == "" || hostinfo.Hostname != hostname {
-		hostinfo.Hostname = hostname
+	lowercased := strings.ToLower(hostinfo.Hostname)
+	if err := ValidateHostname(lowercased); err == nil {
+		return lowercased
 	}
 
-	return hostinfo, hostname
+	return InvalidString()
 }



Test patch:
diff --git a/hscontrol/db/node_test.go b/hscontrol/db/node_test.go
index 84e30e0a..b51dba1c 100644
--- a/hscontrol/db/node_test.go
+++ b/hscontrol/db/node_test.go
@@ -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)
@@ -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)
 
@@ -684,7 +704,11 @@ 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)
@@ -692,10 +716,12 @@ func TestRenameNode(t *testing.T) {
 	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)
@@ -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 {
@@ -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)
 
@@ -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)
@@ -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) {
diff --git a/hscontrol/types/node_test.go b/hscontrol/types/node_test.go
index f6d1d027..41af5d13 100644
--- a/hscontrol/types/node_test.go
+++ b/hscontrol/types/node_test.go
@@ -369,7 +369,7 @@ func TestApplyHostnameFromHostInfo(t *testing.T) {
 			},
 			want: Node{
 				GivenName: "manual-test.local",
-				Hostname:  "NewHostName.Local",
+				Hostname:  "newhostname.local",
 			},
 		},
 		{
@@ -383,7 +383,245 @@ func TestApplyHostnameFromHostInfo(t *testing.T) {
 			},
 			want: Node{
 				GivenName: "newhostname.local",
-				Hostname:  "NewHostName.Local",
+				Hostname:  "newhostname.local",
+			},
+		},
+		{
+			name: "invalid-hostname-with-emoji-rejected",
+			nodeBefore: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+			change: &tailcfg.Hostinfo{
+				Hostname: "hostname-with-💩",
+			},
+			want: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname", // Should reject and keep old hostname
+			},
+		},
+		{
+			name: "invalid-hostname-with-unicode-rejected",
+			nodeBefore: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+			change: &tailcfg.Hostinfo{
+				Hostname: "我的电脑",
+			},
+			want: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname", // Should keep old hostname
+			},
+		},
+		{
+			name: "invalid-hostname-with-special-chars-rejected",
+			nodeBefore: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+			change: &tailcfg.Hostinfo{
+				Hostname: "node-with-special!@#$%",
+			},
+			want: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname", // Should reject and keep old hostname
+			},
+		},
+		{
+			name: "invalid-hostname-too-short-rejected",
+			nodeBefore: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+			change: &tailcfg.Hostinfo{
+				Hostname: "a",
+			},
+			want: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname", // Should keep old hostname
+			},
+		},
+		{
+			name: "invalid-hostname-uppercase-accepted-lowercased",
+			nodeBefore: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+			change: &tailcfg.Hostinfo{
+				Hostname: "ValidHostName",
+			},
+			want: Node{
+				GivenName: "validhostname", // GivenName follows hostname when it changes
+				Hostname:  "validhostname", // Uppercase is lowercased, not rejected
+			},
+		},
+		{
+			name: "uppercase_to_lowercase_accepted",
+			nodeBefore: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+			change: &tailcfg.Hostinfo{
+				Hostname: "User2-Host",
+			},
+			want: Node{
+				GivenName: "user2-host",
+				Hostname:  "user2-host",
+			},
+		},
+		{
+			name: "at_sign_rejected",
+			nodeBefore: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+			change: &tailcfg.Hostinfo{
+				Hostname: "Test@Host",
+			},
+			want: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+		},
+		{
+			name: "chinese_chars_with_dash_rejected",
+			nodeBefore: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+			change: &tailcfg.Hostinfo{
+				Hostname: "server-北京-01",
+			},
+			want: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+		},
+		{
+			name: "chinese_only_rejected",
+			nodeBefore: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+			change: &tailcfg.Hostinfo{
+				Hostname: "我的电脑",
+			},
+			want: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+		},
+		{
+			name: "emoji_with_text_rejected",
+			nodeBefore: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+			change: &tailcfg.Hostinfo{
+				Hostname: "laptop-🚀",
+			},
+			want: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+		},
+		{
+			name: "mixed_chinese_emoji_rejected",
+			nodeBefore: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+			change: &tailcfg.Hostinfo{
+				Hostname: "测试💻机器",
+			},
+			want: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+		},
+		{
+			name: "only_emojis_rejected",
+			nodeBefore: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+			change: &tailcfg.Hostinfo{
+				Hostname: "🎉🎊",
+			},
+			want: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+		},
+		{
+			name: "only_at_signs_rejected",
+			nodeBefore: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+			change: &tailcfg.Hostinfo{
+				Hostname: "@@@",
+			},
+			want: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+		},
+		{
+			name: "starts_with_dash_rejected",
+			nodeBefore: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+			change: &tailcfg.Hostinfo{
+				Hostname: "-test",
+			},
+			want: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+		},
+		{
+			name: "ends_with_dash_rejected",
+			nodeBefore: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+			change: &tailcfg.Hostinfo{
+				Hostname: "test-",
+			},
+			want: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+		},
+		{
+			name: "too_long_hostname_rejected",
+			nodeBefore: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+			change: &tailcfg.Hostinfo{
+				Hostname: strings.Repeat("t", 65),
+			},
+			want: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+		},
+		{
+			name: "underscore_rejected",
+			nodeBefore: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
+			},
+			change: &tailcfg.Hostinfo{
+				Hostname: "test_node",
+			},
+			want: Node{
+				GivenName: "valid-hostname",
+				Hostname:  "valid-hostname",
 			},
 		},
 	}
diff --git a/hscontrol/util/dns_test.go b/hscontrol/util/dns_test.go
index 140b70e2..b492e4d6 100644
--- a/hscontrol/util/dns_test.go
+++ b/hscontrol/util/dns_test.go
@@ -2,6 +2,7 @@ package util
 
 import (
 	"net/netip"
+	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -9,94 +10,173 @@ import (
 	"tailscale.com/util/must"
 )
 
-func TestCheckForFQDNRules(t *testing.T) {
+func TestNormaliseHostname(t *testing.T) {
 	type args struct {
 		name string
 	}
 	tests := []struct {
 		name    string
 		args    args
+		want    string
 		wantErr bool
 	}{
 		{
-			name:    "valid: user",
+			name:    "valid: lowercase user",
 			args:    args{name: "valid-user"},
+			want:    "valid-user",
 			wantErr: false,
 		},
 		{
-			name:    "invalid: capitalized user",
+			name:    "normalise: capitalized user",
 			args:    args{name: "Invalid-CapItaLIzed-user"},
-			wantErr: true,
+			want:    "invalid-capitalized-user",
+			wantErr: false,
 		},
 		{
-			name:    "invalid: email as user",
+			name:    "normalise: email as user",
 			args:    args{name: "foo.bar@example.com"},
-			wantErr: true,
+			want:    "foo.barexample.com",
+			wantErr: false,
 		},
 		{
-			name:    "invalid: chars in user name",
+			name:    "normalise: chars in user name",
 			args:    args{name: "super-user+name"},
-			wantErr: true,
+			want:    "super-username",
+			wantErr: false,
 		},
 		{
-			name: "invalid: too long name for user",
+			name: "invalid: too long name truncated leaves trailing hyphen",
 			args: args{
 				name: "super-long-useruseruser-name-that-should-be-a-little-more-than-63-chars",
 			},
+			want:    "",
+			wantErr: true,
+		},
+		{
+			name:    "invalid: emoji stripped leaves trailing hyphen",
+			args:    args{name: "hostname-with-💩"},
+			want:    "",
+			wantErr: true,
+		},
+		{
+			name:    "normalise: multiple emojis stripped",
+			args:    args{name: "node-🎉-🚀-test"},
+			want:    "node---test",
+			wantErr: false,
+		},
+		{
+			name:    "invalid: only emoji becomes empty",
+			args:    args{name: "💩"},
+			want:    "",
+			wantErr: true,
+		},
+		{
+			name:    "invalid: emoji at start leaves leading hyphen",
+			args:    args{name: "🚀-rocket-node"},
+			want:    "",
+			wantErr: true,
+		},
+		{
+			name:    "invalid: emoji at end leaves trailing hyphen",
+			args:    args{name: "node-test-🎉"},
+			want:    "",
 			wantErr: true,
 		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			if err := CheckForFQDNRules(tt.args.name); (err != nil) != tt.wantErr {
-				t.Errorf("CheckForFQDNRules() error = %v, wantErr %v", err, tt.wantErr)
+			got, err := NormaliseHostname(tt.args.name)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("NormaliseHostname() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if !tt.wantErr && got != tt.want {
+				t.Errorf("NormaliseHostname() = %v, want %v", got, tt.want)
 			}
 		})
 	}
 }
 
-func TestConvertWithFQDNRules(t *testing.T) {
+func TestValidateHostname(t *testing.T) {
 	tests := []struct {
-		name        string
-		hostname    string
-		dnsHostName string
+		name          string
+		hostname      string
+		wantErr       bool
+		errorContains string
 	}{
 		{
-			name:        "User1.test",
-			hostname:    "User1.Test",
-			dnsHostName: "user1.test",
+			name:     "valid lowercase",
+			hostname: "valid-hostname",
+			wantErr:  false,
+		},
+		{
+			name:          "uppercase rejected",
+			hostname:      "MyHostname",
+			wantErr:       true,
+			errorContains: "must be lowercase",
 		},
 		{
-			name:        "User'1$2.test",
-			hostname:    "User'1$2.Test",
-			dnsHostName: "user12.test",
+			name:          "too short",
+			hostname:      "a",
+			wantErr:       true,
+			errorContains: "too short",
 		},
 		{
-			name:        "User-^_12.local.test",
-			hostname:    "User-^_12.local.Test",
-			dnsHostName: "user-12.local.test",
+			name:          "too long",
+			hostname:      "a" + strings.Repeat("b", 63),
+			wantErr:       true,
+			errorContains: "too long",
 		},
 		{
-			name:        "User-MacBook-Pro",
-			hostname:    "User-MacBook-Pro",
-			dnsHostName: "user-macbook-pro",
+			name:          "emoji rejected",
+			hostname:      "hostname-💩",
+			wantErr:       true,
+			errorContains: "invalid characters",
 		},
 		{
-			name:        "User-Linux-Ubuntu/Fedora",
-			hostname:    "User-Linux-Ubuntu/Fedora",
-			dnsHostName: "user-linux-ubuntufedora",
+			name:          "starts with hyphen",
+			hostname:      "-hostname",
+			wantErr:       true,
+			errorContains: "cannot start or end with a hyphen",
 		},
 		{
-			name:        "User-[Space]123",
-			hostname:    "User-[ ]123",
-			dnsHostName: "user-123",
+			name:          "ends with hyphen",
+			hostname:      "hostname-",
+			wantErr:       true,
+			errorContains: "cannot start or end with a hyphen",
+		},
+		{
+			name:          "starts with dot",
+			hostname:      ".hostname",
+			wantErr:       true,
+			errorContains: "cannot start or end with a dot",
+		},
+		{
+			name:          "ends with dot",
+			hostname:      "hostname.",
+			wantErr:       true,
+			errorContains: "cannot start or end with a dot",
+		},
+		{
+			name:          "special characters",
+			hostname:      "host!@#$name",
+			wantErr:       true,
+			errorContains: "invalid characters",
 		},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			fqdnHostName := ConvertWithFQDNRules(tt.hostname)
-			assert.Equal(t, tt.dnsHostName, fqdnHostName)
+			err := ValidateHostname(tt.hostname)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("ValidateHostname() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if tt.wantErr && tt.errorContains != "" {
+				if err == nil || !strings.Contains(err.Error(), tt.errorContains) {
+					t.Errorf("ValidateHostname() error = %v, should contain %q", err, tt.errorContains)
+				}
+			}
 		})
 	}
 }
diff --git a/hscontrol/util/util_test.go b/hscontrol/util/util_test.go
index e0414071..22418e34 100644
--- a/hscontrol/util/util_test.go
+++ b/hscontrol/util/util_test.go
@@ -3,6 +3,7 @@ package util
 import (
 	"errors"
 	"net/netip"
+	"strings"
 	"testing"
 	"time"
 
@@ -795,7 +796,7 @@ over a maximum of 30 hops:
 	}
 }
 
-func TestSafeHostname(t *testing.T) {
+func TestEnsureHostname(t *testing.T) {
 	t.Parallel()
 
 	tests := []struct {
@@ -878,7 +879,7 @@ func TestSafeHostname(t *testing.T) {
 			},
 			machineKey: "mkey12345678",
 			nodeKey:    "nkey12345678",
-			want:       "123456789012345678901234567890123456789012345678901234567890123",
+			want:       "invalid-",
 		},
 		{
 			name: "hostname_very_long_truncated",
@@ -887,7 +888,7 @@ func TestSafeHostname(t *testing.T) {
 			},
 			machineKey: "mkey12345678",
 			nodeKey:    "nkey12345678",
-			want:       "test-node-with-very-long-hostname-that-exceeds-dns-label-limits",
+			want:       "invalid-",
 		},
 		{
 			name: "hostname_with_special_chars",
@@ -896,7 +897,7 @@ func TestSafeHostname(t *testing.T) {
 			},
 			machineKey: "mkey12345678",
 			nodeKey:    "nkey12345678",
-			want:       "node-with-special!@#$%",
+			want:       "invalid-",
 		},
 		{
 			name: "hostname_with_unicode",
@@ -905,7 +906,7 @@ func TestSafeHostname(t *testing.T) {
 			},
 			machineKey: "mkey12345678",
 			nodeKey:    "nkey12345678",
-			want:       "node-ñoño-测试",
+			want:       "invalid-",
 		},
 		{
 			name: "short_machine_key",
@@ -925,20 +926,160 @@ func TestSafeHostname(t *testing.T) {
 			nodeKey:    "short",
 			want:       "node-short",
 		},
+		{
+			name: "hostname_with_emoji_replaced",
+			hostinfo: &tailcfg.Hostinfo{
+				Hostname: "hostname-with-💩",
+			},
+			machineKey: "mkey12345678",
+			nodeKey:    "nkey12345678",
+			want:       "invalid-",
+		},
+		{
+			name: "hostname_only_emoji_replaced",
+			hostinfo: &tailcfg.Hostinfo{
+				Hostname: "🚀",
+			},
+			machineKey: "mkey12345678",
+			nodeKey:    "nkey12345678",
+			want:       "invalid-",
+		},
+		{
+			name: "hostname_with_multiple_emojis_replaced",
+			hostinfo: &tailcfg.Hostinfo{
+				Hostname: "node-🎉-🚀-test",
+			},
+			machineKey: "mkey12345678",
+			nodeKey:    "nkey12345678",
+			want:       "invalid-",
+		},
+		{
+			name: "uppercase_to_lowercase",
+			hostinfo: &tailcfg.Hostinfo{
+				Hostname: "User2-Host",
+			},
+			machineKey: "mkey12345678",
+			nodeKey:    "nkey12345678",
+			want:       "user2-host",
+		},
+		{
+			name: "underscore_removed",
+			hostinfo: &tailcfg.Hostinfo{
+				Hostname: "test_node",
+			},
+			machineKey: "mkey12345678",
+			nodeKey:    "nkey12345678",
+			want:       "invalid-",
+		},
+		{
+			name: "at_sign_invalid",
+			hostinfo: &tailcfg.Hostinfo{
+				Hostname: "Test@Host",
+			},
+			machineKey: "mkey12345678",
+			nodeKey:    "nkey12345678",
+			want:       "invalid-",
+		},
+		{
+			name: "chinese_chars_with_dash_invalid",
+			hostinfo: &tailcfg.Hostinfo{
+				Hostname: "server-北京-01",
+			},
+			machineKey: "mkey12345678",
+			nodeKey:    "nkey12345678",
+			want:       "invalid-",
+		},
+		{
+			name: "chinese_only_invalid",
+			hostinfo: &tailcfg.Hostinfo{
+				Hostname: "我的电脑",
+			},
+			machineKey: "mkey12345678",
+			nodeKey:    "nkey12345678",
+			want:       "invalid-",
+		},
+		{
+			name: "emoji_with_text_invalid",
+			hostinfo: &tailcfg.Hostinfo{
+				Hostname: "laptop-🚀",
+			},
+			machineKey: "mkey12345678",
+			nodeKey:    "nkey12345678",
+			want:       "invalid-",
+		},
+		{
+			name: "mixed_chinese_emoji_invalid",
+			hostinfo: &tailcfg.Hostinfo{
+				Hostname: "测试💻机器",
+			},
+			machineKey: "mkey12345678",
+			nodeKey:    "nkey12345678",
+			want:       "invalid-",
+		},
+		{
+			name: "only_emojis_invalid",
+			hostinfo: &tailcfg.Hostinfo{
+				Hostname: "🎉🎊",
+			},
+			machineKey: "mkey12345678",
+			nodeKey:    "nkey12345678",
+			want:       "invalid-",
+		},
+		{
+			name: "only_at_signs_invalid",
+			hostinfo: &tailcfg.Hostinfo{
+				Hostname: "@@@",
+			},
+			machineKey: "mkey12345678",
+			nodeKey:    "nkey12345678",
+			want:       "invalid-",
+		},
+		{
+			name: "starts_with_dash_invalid",
+			hostinfo: &tailcfg.Hostinfo{
+				Hostname: "-test",
+			},
+			machineKey: "mkey12345678",
+			nodeKey:    "nkey12345678",
+			want:       "invalid-",
+		},
+		{
+			name: "ends_with_dash_invalid",
+			hostinfo: &tailcfg.Hostinfo{
+				Hostname: "test-",
+			},
+			machineKey: "mkey12345678",
+			nodeKey:    "nkey12345678",
+			want:       "invalid-",
+		},
+		{
+			name: "very_long_hostname_truncated",
+			hostinfo: &tailcfg.Hostinfo{
+				Hostname: strings.Repeat("t", 70),
+			},
+			machineKey: "mkey12345678",
+			nodeKey:    "nkey12345678",
+			want:       "invalid-",
+		},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			t.Parallel()
-			got := SafeHostname(tt.hostinfo, tt.machineKey, tt.nodeKey)
-			if got != tt.want {
-				t.Errorf("SafeHostname() = %v, want %v", got, tt.want)
+			got := EnsureHostname(tt.hostinfo, tt.machineKey, tt.nodeKey)
+			// For invalid hostnames, we just check the prefix since the random part varies
+			if strings.HasPrefix(tt.want, "invalid-") {
+				if !strings.HasPrefix(got, "invalid-") {
+					t.Errorf("EnsureHostname() = %v, want prefix %v", got, tt.want)
+				}
+			} else if got != tt.want {
+				t.Errorf("EnsureHostname() = %v, want %v", got, tt.want)
 			}
 		})
 	}
 }
 
-func TestEnsureValidHostinfo(t *testing.T) {
+func TestEnsureHostnameWithHostinfo(t *testing.T) {
 	t.Parallel()
 
 	tests := []struct {
@@ -976,14 +1117,6 @@ func TestEnsureValidHostinfo(t *testing.T) {
 			machineKey:   "mkey12345678",
 			nodeKey:      "nkey12345678",
 			wantHostname: "node-mkey1234",
-			checkHostinfo: func(t *testing.T, hi *tailcfg.Hostinfo) {
-				if hi == nil {
-					t.Error("hostinfo should not be nil")
-				}
-				if hi.Hostname != "node-mkey1234" {
-					t.Errorf("hostname = %v, want node-mkey1234", hi.Hostname)
-				}
-			},
 		},
 		{
 			name: "empty_hostname_updated",
@@ -994,37 +1127,15 @@ func TestEnsureValidHostinfo(t *testing.T) {
 			machineKey:   "mkey12345678",
 			nodeKey:      "nkey12345678",
 			wantHostname: "node-mkey1234",
-			checkHostinfo: func(t *testing.T, hi *tailcfg.Hostinfo) {
-				if hi == nil {
-					t.Error("hostinfo should not be nil")
-				}
-				if hi.Hostname != "node-mkey1234" {
-					t.Errorf("hostname = %v, want node-mkey1234", hi.Hostname)
-				}
-				if hi.OS != "darwin" {
-					t.Errorf("OS = %v, want darwin", hi.OS)
-				}
-			},
 		},
 		{
-			name: "long_hostname_truncated",
+			name: "long_hostname_rejected",
 			hostinfo: &tailcfg.Hostinfo{
 				Hostname: "test-node-with-very-long-hostname-that-exceeds-dns-label-limits-of-63-characters",
 			},
 			machineKey:   "mkey12345678",
 			nodeKey:      "nkey12345678",
-			wantHostname: "test-node-with-very-long-hostname-that-exceeds-dns-label-limits",
-			checkHostinfo: func(t *testing.T, hi *tailcfg.Hostinfo) {
-				if hi == nil {
-					t.Error("hostinfo should not be nil")
-				}
-				if hi.Hostname != "test-node-with-very-long-hostname-that-exceeds-dns-label-limits" {
-					t.Errorf("hostname = %v, want truncated", hi.Hostname)
-				}
-				if len(hi.Hostname) != 63 {
-					t.Errorf("hostname length = %v, want 63", len(hi.Hostname))
-				}
-			},
+			wantHostname: "invalid-",
 		},
 		{
 			name:         "nil_hostinfo_node_key_only",
@@ -1128,23 +1239,20 @@ func TestEnsureValidHostinfo(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			t.Parallel()
-			gotHostinfo, gotHostname := EnsureValidHostinfo(tt.hostinfo, tt.machineKey, tt.nodeKey)
-
-			if gotHostname != tt.wantHostname {
-				t.Errorf("EnsureValidHostinfo() hostname = %v, want %v", gotHostname, tt.wantHostname)
-			}
-			if gotHostinfo == nil {
-				t.Error("returned hostinfo should never be nil")
-			}
-
-			if tt.checkHostinfo != nil {
-				tt.checkHostinfo(t, gotHostinfo)
+			gotHostname := EnsureHostname(tt.hostinfo, tt.machineKey, tt.nodeKey)
+			// For invalid hostnames, we just check the prefix since the random part varies
+			if strings.HasPrefix(tt.wantHostname, "invalid-") {
+				if !strings.HasPrefix(gotHostname, "invalid-") {
+					t.Errorf("EnsureHostname() = %v, want prefix %v", gotHostname, tt.wantHostname)
+				}
+			} else if gotHostname != tt.wantHostname {
+				t.Errorf("EnsureHostname() hostname = %v, want %v", gotHostname, tt.wantHostname)
 			}
 		})
 	}
 }
 
-func TestSafeHostname_DNSLabelLimit(t *testing.T) {
+func TestEnsureHostname_DNSLabelLimit(t *testing.T) {
 	t.Parallel()
 
 	testCases := []string{
@@ -1157,7 +1265,7 @@ func TestSafeHostname_DNSLabelLimit(t *testing.T) {
 	for i, hostname := range testCases {
 		t.Run(cmp.Diff("", ""), func(t *testing.T) {
 			hostinfo := &tailcfg.Hostinfo{Hostname: hostname}
-			result := SafeHostname(hostinfo, "mkey", "nkey")
+			result := EnsureHostname(hostinfo, "mkey", "nkey")
 			if len(result) > 63 {
 				t.Errorf("test case %d: hostname length = %d, want <= 63", i, len(result))
 			}
@@ -1165,7 +1273,7 @@ func TestSafeHostname_DNSLabelLimit(t *testing.T) {
 	}
 }
 
-func TestEnsureValidHostinfo_Idempotent(t *testing.T) {
+func TestEnsureHostname_Idempotent(t *testing.T) {
 	t.Parallel()
 
 	originalHostinfo := &tailcfg.Hostinfo{
@@ -1173,16 +1281,10 @@ func TestEnsureValidHostinfo_Idempotent(t *testing.T) {
 		OS:       "linux",
 	}
 
-	hostinfo1, hostname1 := EnsureValidHostinfo(originalHostinfo, "mkey", "nkey")
-	hostinfo2, hostname2 := EnsureValidHostinfo(hostinfo1, "mkey", "nkey")
+	hostname1 := EnsureHostname(originalHostinfo, "mkey", "nkey")
+	hostname2 := EnsureHostname(originalHostinfo, "mkey", "nkey")
 
 	if hostname1 != hostname2 {
 		t.Errorf("hostnames not equal: %v != %v", hostname1, hostname2)
 	}
-	if hostinfo1.Hostname != hostinfo2.Hostname {
-		t.Errorf("hostinfo hostnames not equal: %v != %v", hostinfo1.Hostname, hostinfo2.Hostname)
-	}
-	if hostinfo1.OS != hostinfo2.OS {
-		t.Errorf("hostinfo OS not equal: %v != %v", hostinfo1.OS, hostinfo2.OS)
-	}
 }
diff --git a/integration/cli_test.go b/integration/cli_test.go
index 40afd2c3..d6616d62 100644
--- a/integration/cli_test.go
+++ b/integration/cli_test.go
@@ -1164,7 +1164,7 @@ func TestNodeCommand(t *testing.T) {
 				"debug",
 				"create-node",
 				"--name",
-				fmt.Sprintf("otherUser-node-%d", index+1),
+				fmt.Sprintf("otheruser-node-%d", index+1),
 				"--user",
 				"other-user",
 				"--key",
@@ -1221,8 +1221,8 @@ func TestNodeCommand(t *testing.T) {
 	assert.Equal(t, uint64(6), listAllWithotherUser[5].GetId())
 	assert.Equal(t, uint64(7), listAllWithotherUser[6].GetId())
 
-	assert.Equal(t, "otherUser-node-1", listAllWithotherUser[5].GetName())
-	assert.Equal(t, "otherUser-node-2", listAllWithotherUser[6].GetName())
+	assert.Equal(t, "otheruser-node-1", listAllWithotherUser[5].GetName())
+	assert.Equal(t, "otheruser-node-2", listAllWithotherUser[6].GetName())
 
 	// Test list all nodes after added otherUser
 	var listOnlyotherUserMachineUser []v1.Node
@@ -1248,12 +1248,12 @@ func TestNodeCommand(t *testing.T) {
 
 	assert.Equal(
 		t,
-		"otherUser-node-1",
+		"otheruser-node-1",
 		listOnlyotherUserMachineUser[0].GetName(),
 	)
 	assert.Equal(
 		t,
-		"otherUser-node-2",
+		"otheruser-node-2",
 		listOnlyotherUserMachineUser[1].GetName(),
 	)
 
@@ -1558,7 +1558,7 @@ func TestNodeRenameCommand(t *testing.T) {
 			strings.Repeat("t", 64),
 		},
 	)
-	assert.ErrorContains(t, err, "not be over 63 chars")
+	assert.ErrorContains(t, err, "must not exceed 63 characters")
 
 	var listAllAfterRenameAttempt []v1.Node
 	err = executeAndUnmarshal(
diff --git a/integration/general_test.go b/integration/general_test.go
index ab6d4f71..83160e9b 100644
--- a/integration/general_test.go
+++ b/integration/general_test.go
@@ -514,7 +514,7 @@ func TestUpdateHostnameFromClient(t *testing.T) {
 
 	hostnames := map[string]string{
 		"1": "user1-host",
-		"2": "User2-Host",
+		"2": "user2-host",
 		"3": "user3-host",
 	}
 
@@ -577,7 +577,11 @@ func TestUpdateHostnameFromClient(t *testing.T) {
 		for _, node := range nodes {
 			hostname := hostnames[strconv.FormatUint(node.GetId(), 10)]
 			assert.Equal(ct, hostname, node.GetName(), "Node name should match hostname")
-			assert.Equal(ct, util.ConvertWithFQDNRules(hostname), node.GetGivenName(), "Given name should match FQDN rules")
+
+			// GivenName is normalized (lowercase, invalid chars stripped)
+			normalised, err := util.NormaliseHostname(hostname)
+			assert.NoError(ct, err)
+			assert.Equal(ct, normalised, node.GetGivenName(), "Given name should match FQDN rules")
 		}
 	}, 20*time.Second, 1*time.Second)
 
@@ -675,12 +679,13 @@ func TestUpdateHostnameFromClient(t *testing.T) {
 		for _, node := range nodes {
 			hostname := hostnames[strconv.FormatUint(node.GetId(), 10)]
 			givenName := fmt.Sprintf("%d-givenname", node.GetId())
-			if node.GetName() != hostname+"NEW" || node.GetGivenName() != givenName {
+			// Hostnames are lowercased before being stored, so "NEW" becomes "new"
+			if node.GetName() != hostname+"new" || node.GetGivenName() != givenName {
 				return false
 			}
 		}
 		return true
-	}, time.Second, 50*time.Millisecond, "hostname updates should be reflected in node list with NEW suffix")
+	}, time.Second, 50*time.Millisecond, "hostname updates should be reflected in node list with new suffix")
 }
 
 func TestExpireNode(t *testing.T) {