mirror of
				https://github.com/juanfont/headscale.git
				synced 2025-10-28 10:51:44 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			261 lines
		
	
	
		
			7.2 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			261 lines
		
	
	
		
			7.2 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| package util
 | |
| 
 | |
| import (
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"net/netip"
 | |
| 	"net/url"
 | |
| 	"os"
 | |
| 	"regexp"
 | |
| 	"strconv"
 | |
| 	"strings"
 | |
| 	"time"
 | |
| 
 | |
| 	"tailscale.com/util/cmpver"
 | |
| )
 | |
| 
 | |
| 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("multiple URLs found: %s and %s", urlStr, line)
 | |
| 			}
 | |
| 			urlStr = line
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if urlStr == "" {
 | |
| 		return nil, errors.New("no URL found")
 | |
| 	}
 | |
| 
 | |
| 	loginURL, err := url.Parse(urlStr)
 | |
| 	if err != nil {
 | |
| 		return nil, fmt.Errorf("failed to parse 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{}, errors.New("empty traceroute output")
 | |
| 	}
 | |
| 
 | |
| 	// 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("parsing traceroute header: %s", 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
 | |
| 		var hopIP netip.Addr
 | |
| 		var 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 {
 | |
| 					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 {
 | |
| 					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 = errors.New("traceroute did not reach target")
 | |
| 	}
 | |
| 
 | |
| 	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
 | |
| }
 |