1package domain
2
3import (
4 "net"
5 "regexp"
6 "strings"
7)
8
9// hostnamePattern matches valid hostname characters (alphanumeric, hyphen, dot).
10// Used as a basic sanity check before detailed validation.
11// The actual validation uses net.ParseIP and explicit checks.
12var hostnamePattern = regexp.MustCompile(`^[a-zA-Z0-9.-]+$`)
13
14// IsValidRouteDomain validates that a domain is a public hostname suitable
15// for use as an application route. It rejects IP literals, localhost,
16// internal-only names, and authority strings containing ports.
17//
18// Valid: app.example.com, api.site.org, my-app.co.uk
19// Invalid: 192.168.1.1, localhost, myapp.local, example.com:8080
20func CanonicalRouteDomain(domainName string) (string, bool) {
21 canonical := strings.ToLower(strings.TrimSpace(domainName))
22 if !IsValidRouteDomain(canonical) {
23 return "", false
24 }
25 return canonical, true
26}
27
28func IsValidRouteDomain(domain string) bool {
29 // Reject empty
30 if domain == "" {
31 return false
32 }
33
34 // Basic character sanity check - only allow alphanumeric, hyphen, dot
35 // This also rejects paths, query strings, schemes (which contain :/ etc.)
36 if !hostnamePattern.MatchString(domain) {
37 return false
38 }
39
40 // Reject leading/trailing dots and consecutive dots
41 if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") ||
42 strings.Contains(domain, "..") {
43 return false
44 }
45
46 // Reject total hostname length > 253 (RFC 1035)
47 if len(domain) > 253 {
48 return false
49 }
50
51 // Must have at least one dot (domain + TLD minimum)
52 // This also rejects single-label names like "localhost", "myapp"
53 if !strings.Contains(domain, ".") {
54 return false
55 }
56
57 // Check if it looks like an IP address (IPv4 or IPv6)
58 // net.ParseIP accepts various formats including IPv6 brackets
59 if net.ParseIP(domain) != nil {
60 return false
61 }
62
63 // Check for internal-only TLDs
64 lowerDomain := strings.ToLower(domain)
65 if strings.HasSuffix(lowerDomain, ".local") ||
66 strings.HasSuffix(lowerDomain, ".internal") {
67 return false
68 }
69
70 // Check for localhost variants
71 if strings.HasPrefix(lowerDomain, "localhost.") {
72 return false
73 }
74 if strings.HasSuffix(lowerDomain, ".localhost") {
75 return false
76 }
77
78 // Validate each label conforms to RFC 1123
79 labels := strings.Split(domain, ".")
80 for _, label := range labels {
81 if !isValidLabel(label) {
82 return false
83 }
84 }
85
86 return true
87}
88
89// isValidLabel checks the per-label RFC 1123 length and hyphen rules.
90// Character allowlisting is enforced earlier by hostnamePattern.
91func isValidLabel(label string) bool {
92 if label == "" {
93 return false
94 }
95 if len(label) > 63 {
96 return false
97 }
98 if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
99 return false
100 }
101 return true
102}