mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-20 07:41:31 +02:00
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
299 lines
8.2 KiB
Go
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
|
|
}
|