package auth import ( "fmt" "net" "regexp" "strings" "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" ) var hostnamePattern = regexp.MustCompile(`^(?i)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$|^localhost$`) // ValidateHostname checks a public hostname (empty is allowed for local/dev). func ValidateHostname(hostname string) error { trimmed := strings.TrimSpace(hostname) if trimmed == "" { return nil } if strings.Contains(trimmed, "/") || strings.Contains(trimmed, ":") || strings.Contains(trimmed, " ") { return fmt.Errorf("public_hostname must be a bare DNS name without scheme, port, or path") } if !hostnamePattern.MatchString(trimmed) { return fmt.Errorf("public_hostname is not a valid DNS hostname") } return nil } // HostMatches reports whether requestHost matches the configured public hostname. // An empty / whitespace public hostname means any host is allowed (local/dev). func HostMatches(requestHost string, publicHostname string) bool { wanted := strings.TrimSpace(publicHostname) if wanted == "" { return true } host := strings.TrimSpace(requestHost) if parsedHost, _, err := net.SplitHostPort(host); err == nil { host = parsedHost } return strings.EqualFold(host, wanted) } // IPAllowed evaluates whitelist/blacklist rules against clientIP. func IPAllowed(clientIP string, network settings.NetworkSettings) (bool, string) { network = settings.EffectiveNetwork(network) parsedIP := net.ParseIP(clientIP) if parsedIP == nil { return false, "unable to parse client IP" } switch network.AccessMode { case settings.AccessModeOpen, "": return true, "" case settings.AccessModeWhitelist: if matchesAnyRule(parsedIP, network.Rules) { return true, "" } return false, "client IP is not on the whitelist" case settings.AccessModeBlacklist: if matchesAnyRule(parsedIP, network.Rules) { return false, "client IP matches a blacklist rule" } return true, "" default: return false, "invalid access mode" } } // WouldLockOut estimates whether rules would block clientIP. func WouldLockOut(clientIP string, network settings.NetworkSettings) (bool, string) { allowed, reason := IPAllowed(clientIP, network) if allowed { return false, "" } return true, reason } func matchesAnyRule(ip net.IP, rules []string) bool { for _, rule := range rules { trimmed := strings.TrimSpace(rule) if trimmed == "" { continue } if strings.Contains(trimmed, "/") { _, network, err := net.ParseCIDR(trimmed) if err != nil { continue } if network.Contains(ip) { return true } continue } ruleIP := net.ParseIP(trimmed) if ruleIP != nil && ruleIP.Equal(ip) { return true } } return false } // ValidateListenAddress checks a bind address (IP or hostname). func ValidateListenAddress(address string) error { trimmed := strings.TrimSpace(address) if trimmed == "" { return fmt.Errorf("listen_address is required") } if trimmed == "0.0.0.0" || trimmed == "::" || trimmed == "localhost" { return nil } if net.ParseIP(trimmed) != nil { return nil } return fmt.Errorf("listen_address must be an IP address or 0.0.0.0") } // ValidateAccessRules validates CIDR/IP entries. func ValidateAccessRules(rules []string) error { for _, rule := range rules { trimmed := strings.TrimSpace(rule) if trimmed == "" { continue } if strings.Contains(trimmed, "/") { if _, _, err := net.ParseCIDR(trimmed); err != nil { return fmt.Errorf("invalid CIDR rule %q", trimmed) } continue } if net.ParseIP(trimmed) == nil { return fmt.Errorf("invalid IP rule %q", trimmed) } } return nil }