Files
headscale/hscontrol/util/util.go
Kristoffer Dalby d6dfdc100c hscontrol: route hostname handling through dnsname and NodeStore
Ingest (registration and MapRequest updates) now calls
dnsname.SanitizeHostname directly and lets NodeStore auto-bump on
collision. Admin rename uses dnsname.ValidLabel + SetGivenName so
conflicts are surfaced to the caller instead of silently mutated.

Three duplicate invalidDNSRegex definitions, the old NormaliseHostname
and ValidateHostname helpers, EnsureHostname, InvalidString,
ApplyHostnameFromHostInfo, GivenNameHasBeenChanged, generateGivenName
and EnsureUniqueGivenName are removed along with their tests.
ValidateHostname's username half is retained as ValidateUsername for
users.go.

The SaaS-matching collision rule replaces the random "invalid-xxxxxx"
fallback and the 8-character hash suffix; the empty-input fallback is
the literal "node". TestUpdateHostnameFromClient now exercises the
rewrite end-to-end with awkward macOS/Windows names.

Fixes #3188
Fixes #2926
Fixes #2343
Fixes #2762
Fixes #2449
Updates #2177
Updates #2121
Updates #363
2026-04-18 15:12:21 +01:00

299 lines
8.2 KiB
Go

package util
import (
"errors"
"fmt"
"net/netip"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"time"
"tailscale.com/util/cmpver"
)
// URL parsing errors.
var (
ErrMultipleURLsFound = errors.New("multiple URLs found")
ErrNoURLFound = errors.New("no URL found")
ErrEmptyTracerouteOutput = errors.New("empty traceroute output")
ErrTracerouteHeaderParse = errors.New("parsing traceroute header")
ErrTracerouteDidNotReach = errors.New("traceroute did not reach target")
)
func TailscaleVersionNewerOrEqual(minimum, toCheck string) bool {
if cmpver.Compare(minimum, toCheck) <= 0 ||
toCheck == "unstable" ||
toCheck == "head" {
return true
}
return false
}
// ParseLoginURLFromCLILogin parses the output of the tailscale up command to extract the login URL.
// It returns an error if not exactly one URL is found.
func ParseLoginURLFromCLILogin(output string) (*url.URL, error) {
lines := strings.Split(output, "\n")
var urlStr string
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "http://") || strings.HasPrefix(line, "https://") {
if urlStr != "" {
return nil, fmt.Errorf("%w: %s and %s", ErrMultipleURLsFound, urlStr, line)
}
urlStr = line
}
}
if urlStr == "" {
return nil, ErrNoURLFound
}
loginURL, err := url.Parse(urlStr)
if err != nil {
return nil, fmt.Errorf("parsing URL: %w", err)
}
return loginURL, nil
}
type TraceroutePath struct {
// Hop is the current jump in the total traceroute.
Hop int
// Hostname is the resolved hostname or IP address identifying the jump
Hostname string
// IP is the IP address of the jump
IP netip.Addr
// Latencies is a list of the latencies for this jump
Latencies []time.Duration
}
type Traceroute struct {
// Hostname is the resolved hostname or IP address identifying the target
Hostname string
// IP is the IP address of the target
IP netip.Addr
// Route is the path taken to reach the target if successful. The list is ordered by the path taken.
Route []TraceroutePath
// Success indicates if the traceroute was successful.
Success bool
// Err contains an error if the traceroute was not successful.
Err error
}
// ParseTraceroute parses the output of the traceroute command and returns a Traceroute struct.
func ParseTraceroute(output string) (Traceroute, error) {
lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) < 1 {
return Traceroute{}, ErrEmptyTracerouteOutput
}
// Parse the header line - handle both 'traceroute' and 'tracert' (Windows)
headerRegex := regexp.MustCompile(`(?i)(?:traceroute|tracing route) to ([^ ]+) (?:\[([^\]]+)\]|\(([^)]+)\))`)
headerMatches := headerRegex.FindStringSubmatch(lines[0])
if len(headerMatches) < 2 {
return Traceroute{}, fmt.Errorf("%w: %s", ErrTracerouteHeaderParse, lines[0])
}
hostname := headerMatches[1]
// IP can be in either capture group 2 or 3 depending on format
ipStr := headerMatches[2]
if ipStr == "" {
ipStr = headerMatches[3]
}
ip, err := netip.ParseAddr(ipStr)
if err != nil {
return Traceroute{}, fmt.Errorf("parsing IP address %s: %w", ipStr, err)
}
result := Traceroute{
Hostname: hostname,
IP: ip,
Route: []TraceroutePath{},
Success: false,
}
// More flexible regex that handles various traceroute output formats
// Main pattern handles: "hostname (IP)", "hostname [IP]", "IP only", "* * *"
hopRegex := regexp.MustCompile(`^\s*(\d+)\s+(.*)$`)
// Patterns for parsing the hop details
hostIPRegex := regexp.MustCompile(`^([^ ]+) \(([^)]+)\)`)
hostIPBracketRegex := regexp.MustCompile(`^([^ ]+) \[([^\]]+)\]`)
// Pattern for latencies with flexible spacing and optional '<'
latencyRegex := regexp.MustCompile(`(<?\d+(?:\.\d+)?)\s*ms\b`)
for i := 1; i < len(lines); i++ {
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
matches := hopRegex.FindStringSubmatch(line)
if len(matches) == 0 {
continue
}
hop, err := strconv.Atoi(matches[1])
if err != nil {
// Skip lines that don't start with a hop number
continue
}
remainder := strings.TrimSpace(matches[2])
var (
hopHostname string
hopIP netip.Addr
latencies []time.Duration
)
// Check for Windows tracert format which has latencies before hostname
// Format: " 1 <1 ms <1 ms <1 ms router.local [192.168.1.1]"
latencyFirst := false
if strings.Contains(remainder, " ms ") && !strings.HasPrefix(remainder, "*") {
// Check if latencies appear before any hostname/IP
firstSpace := strings.Index(remainder, " ")
if firstSpace > 0 {
firstPart := remainder[:firstSpace]
if _, err := strconv.ParseFloat(strings.TrimPrefix(firstPart, "<"), 64); err == nil { //nolint:noinlineerr
latencyFirst = true
}
}
}
if latencyFirst {
// Windows format: extract latencies first
for {
latMatch := latencyRegex.FindStringSubmatchIndex(remainder)
if latMatch == nil || latMatch[0] > 0 {
break
}
// Extract and remove the latency from the beginning
latStr := strings.TrimPrefix(remainder[latMatch[2]:latMatch[3]], "<")
ms, err := strconv.ParseFloat(latStr, 64)
if err == nil {
// Round to nearest microsecond to avoid floating point precision issues
duration := time.Duration(ms * float64(time.Millisecond))
latencies = append(latencies, duration.Round(time.Microsecond))
}
remainder = strings.TrimSpace(remainder[latMatch[1]:])
}
}
// Now parse hostname/IP from remainder
if strings.HasPrefix(remainder, "*") {
// Timeout hop
hopHostname = "*"
// Skip any remaining asterisks
remainder = strings.TrimLeft(remainder, "* ")
} else if hostMatch := hostIPRegex.FindStringSubmatch(remainder); len(hostMatch) >= 3 {
// Format: hostname (IP)
hopHostname = hostMatch[1]
hopIP, _ = netip.ParseAddr(hostMatch[2])
remainder = strings.TrimSpace(remainder[len(hostMatch[0]):])
} else if hostMatch := hostIPBracketRegex.FindStringSubmatch(remainder); len(hostMatch) >= 3 {
// Format: hostname [IP] (Windows)
hopHostname = hostMatch[1]
hopIP, _ = netip.ParseAddr(hostMatch[2])
remainder = strings.TrimSpace(remainder[len(hostMatch[0]):])
} else {
// Try to parse as IP only or hostname only
parts := strings.Fields(remainder)
if len(parts) > 0 {
hopHostname = parts[0]
if ip, err := netip.ParseAddr(parts[0]); err == nil { //nolint:noinlineerr
hopIP = ip
}
remainder = strings.TrimSpace(strings.Join(parts[1:], " "))
}
}
// Extract latencies from the remaining part (if not already done)
if !latencyFirst {
latencyMatches := latencyRegex.FindAllStringSubmatch(remainder, -1)
for _, match := range latencyMatches {
if len(match) > 1 {
// Remove '<' prefix if present (e.g., "<1 ms")
latStr := strings.TrimPrefix(match[1], "<")
ms, err := strconv.ParseFloat(latStr, 64)
if err == nil {
// Round to nearest microsecond to avoid floating point precision issues
duration := time.Duration(ms * float64(time.Millisecond))
latencies = append(latencies, duration.Round(time.Microsecond))
}
}
}
}
path := TraceroutePath{
Hop: hop,
Hostname: hopHostname,
IP: hopIP,
Latencies: latencies,
}
result.Route = append(result.Route, path)
// Check if we've reached the target
if hopIP == ip {
result.Success = true
}
}
// If we didn't reach the target, it's unsuccessful
if !result.Success {
result.Err = ErrTracerouteDidNotReach
}
return result, nil
}
func IsCI() bool {
if _, ok := os.LookupEnv("CI"); ok {
return true
}
if _, ok := os.LookupEnv("GITHUB_RUN_ID"); ok {
return true
}
return false
}
// GenerateRegistrationKey generates a vanity key for tracking web authentication
// registration flows in logs. This key is NOT stored in the database and does NOT use bcrypt -
// it's purely for observability and correlating log entries during the registration process.
func GenerateRegistrationKey() (string, error) {
const (
registerKeyPrefix = "hskey-reg-" //nolint:gosec // This is a vanity key for logging, not a credential
registerKeyLength = 64
)
randomPart, err := GenerateRandomStringURLSafe(registerKeyLength)
if err != nil {
return "", fmt.Errorf("generating registration key: %w", err)
}
return registerKeyPrefix + randomPart, nil
}