package profile import ( "errors" "fmt" "net" "net/url" "regexp" "strconv" "strings" ) type Mode string const ( SingleMode Mode = "single" AdvancedMode Mode = "advanced" ) type Profile struct { Name string `json:"name"` Mode Mode `json:"mode"` SingleURL string `json:"singleUrl,omitempty"` HTTPURL string `json:"httpUrl,omitempty"` HTTPSURL string `json:"httpsUrl,omitempty"` AllURL string `json:"allUrl,omitempty"` Bypass []string `json:"bypass,omitempty"` } type State struct { Profiles []Profile `json:"profiles"` ActiveProfile string `json:"activeProfile,omitempty"` } type Store interface { Load() (State, error) Mutate(func(State) (State, error)) (State, error) } // Field identifies the Profile input responsible for a validation error. // Presentation layers use it to keep feedback attached to the field a user can // correct instead of parsing canonical error strings. type Field string const ( NameField Field = "name" ModeField Field = "mode" SingleURLField Field = "single-url" HTTPURLField Field = "http-url" HTTPSURLField Field = "https-url" AllURLField Field = "all-url" BypassField Field = "bypass" ) // BypassItemValidation describes one delimited Bypass item. Valid items retain // their canonical value even when another item is invalid, enabling a truthful // live preview without weakening save validation. type BypassItemValidation struct { Index int Input string Normalized string Err error } // Validation is the shared canonical validation result used by forms and save. // Normalized is complete only when Err returns nil; BypassItems always contains // the independently interpreted Bypass entries. type Validation struct { Normalized Profile FieldErrors map[Field]error BypassItems []BypassItemValidation firstError error } func (v Validation) Err() error { return v.firstError } func (v Validation) FieldError(field Field) error { return v.FieldErrors[field] } // ValidateFields performs the same validation and normalization as // ValidateAndNormalize while retaining structured, field-specific feedback. func ValidateFields(input Profile) Validation { p := input validation := Validation{FieldErrors: make(map[Field]error)} setError := func(field Field, err error) { if err == nil { return } if validation.FieldErrors[field] == nil { validation.FieldErrors[field] = err } if validation.firstError == nil { validation.firstError = err } } p.Name = strings.TrimSpace(p.Name) if p.Name == "" { setError(NameField, errors.New("Profile name is required")) } if p.Mode == "" { p.Mode = SingleMode } switch p.Mode { case SingleMode: value, err := validateURL("Single proxy URL", p.SingleURL) setError(SingleURLField, err) if err == nil { p.SingleURL = value p.HTTPURL, p.HTTPSURL, p.AllURL = value, value, value } case AdvancedMode: if p.HTTPURL == "" && p.HTTPSURL == "" && p.AllURL == "" && strings.TrimSpace(p.SingleURL) != "" { value, err := validateURL("Single proxy URL", p.SingleURL) setError(HTTPURLField, err) if err == nil { p.SingleURL = value p.HTTPURL, p.HTTPSURL, p.AllURL = value, value, value } } var err error if p.HTTPURL, err = validateOptionalURL("HTTP proxy URL", p.HTTPURL); err != nil { setError(HTTPURLField, err) } if p.HTTPSURL, err = validateOptionalURL("HTTPS proxy URL", p.HTTPSURL); err != nil { setError(HTTPSURLField, err) } if p.AllURL, err = validateOptionalURL("ALL/SOCKS proxy URL", p.AllURL); err != nil { setError(AllURLField, err) } if p.HTTPURL == "" && p.HTTPSURL == "" && p.AllURL == "" && validation.FieldErrors[HTTPURLField] == nil && validation.FieldErrors[HTTPSURLField] == nil && validation.FieldErrors[AllURLField] == nil { setError(HTTPURLField, errors.New("at least one advanced proxy URL is required")) } default: setError(ModeField, errors.New("proxy mode must be single or advanced")) } seen := make(map[string]bool) canonicalBypass := make([]string, 0, len(p.Bypass)) for _, raw := range p.Bypass { for _, item := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '\n' }) { entry := BypassItemValidation{Index: len(validation.BypassItems) + 1, Input: strings.TrimSpace(item)} entry.Normalized, entry.Err = normalizeBypassEntry(item) validation.BypassItems = append(validation.BypassItems, entry) if entry.Err != nil { setError(BypassField, entry.Err) continue } if !seen[entry.Normalized] { seen[entry.Normalized] = true canonicalBypass = append(canonicalBypass, entry.Normalized) } } } p.Bypass = canonicalBypass validation.Normalized = p return validation } func ValidateAndNormalize(input Profile) (Profile, error) { validation := ValidateFields(input) if err := validation.Err(); err != nil { return Profile{}, err } return validation.Normalized, nil } func validateOptionalURL(field, value string) (string, error) { if strings.TrimSpace(value) == "" { return "", nil } return validateURL(field, value) } // SchemePrefixPattern matches a URL scheme prefix. var SchemePrefixPattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*://`) var ( errProxyURLEmpty = errors.New("URL is required") errProxyURLMalformed = errors.New("URL is malformed") errProxyURLHost = errors.New("URL must include a host") errProxyURLPort = errors.New("URL must include a port") ) // ParseProxyURL parses raw as a proxy URL, defaulting the scheme to http:// // when none is present, and requires a non-empty host and port. func ParseProxyURL(raw string) (*url.URL, error) { value := strings.TrimSpace(raw) if value == "" { return nil, errProxyURLEmpty } // A bare host:port (curl convention) defaults to http://. Prepending // before url.Parse avoids "localhost:8888" being read as scheme // "localhost". if !SchemePrefixPattern.MatchString(value) { value = "http://" + value } u, err := url.Parse(value) if err != nil { return nil, errProxyURLMalformed } if u.Hostname() == "" { return nil, errProxyURLHost } if u.Port() == "" { return nil, errProxyURLPort } return u, nil } func validateURL(field, value string) (string, error) { u, err := ParseProxyURL(value) if err != nil { switch err { case errProxyURLEmpty: return "", fmt.Errorf("%s is required", field) case errProxyURLHost: return "", fmt.Errorf("%s must include a host", field) case errProxyURLPort: return "", fmt.Errorf("%s must include a port", field) default: return "", fmt.Errorf("%s is malformed", field) } } switch strings.ToLower(u.Scheme) { case "http", "https", "socks5", "socks5h": default: return "", fmt.Errorf("%s must use http, https, socks5, or socks5h", field) } if u.Path != "" || u.RawQuery != "" || u.Fragment != "" { return "", fmt.Errorf("%s must not include a path, query, or fragment", field) } port := u.Port() n, err := strconv.Atoi(port) if err != nil || n < 1 || n > 65535 { return "", fmt.Errorf("%s port must be between 1 and 65535", field) } if net.ParseIP(u.Hostname()) == nil && (looksLikeIPv4(u.Hostname()) || !validHostname(u.Hostname())) { return "", fmt.Errorf("%s contains an invalid host", field) } u.Scheme = strings.ToLower(u.Scheme) return u.String(), nil } func normalizeBypassEntry(raw string) (string, error) { value := strings.TrimSpace(raw) if value == "" { return "", errors.New("Bypass entries cannot be empty") } if _, network, err := net.ParseCIDR(value); err == nil { return network.String(), nil } if ip := net.ParseIP(value); ip != nil { return ip.String(), nil } suffix := strings.HasPrefix(value, ".") host := strings.ToLower(strings.TrimSuffix(strings.TrimPrefix(value, "."), ".")) if looksLikeIPv4(host) || !validHostname(host) { return "", errors.New("Bypass entry must be a hostname, domain suffix, IP address, or CIDR range") } if suffix { return "." + host, nil } return host, nil } type credentialSpan struct { userinfoStart int passwordStart int at int } // credentialSpans is deliberately a scanner rather than a URL regular // expression. Diagnostics routinely contain values rejected by net/url, and a // malformed password may contain whitespace, CR/LF, slashes, or additional @ // signs. The scanner requires the comparatively stable user:...@host shape, // chooses an @ only when what follows can be an authority, and stops each // replacement at that @ so surrounding diagnostics remain untouched. func credentialSpans(value string) []credentialSpan { var spans []credentialSpan for cursor := 0; cursor < len(value); { colon := strings.IndexByte(value[cursor:], ':') if colon < 0 { break } colon += cursor if colon+2 < len(value) && value[colon+1:colon+3] == "//" { cursor = colon + 3 continue } start := credentialUsernameStart(value, colon) if start == colon || (colon+1 < len(value) && isAuthorityBoundary(value[colon+1]) && !hasSchemeAuthorityPrefix(value, start)) { cursor = colon + 1 continue } at := credentialDelimiter(value, colon+1) if at < 0 || hasNestedCredentialStart(value, colon+1, at) { cursor = colon + 1 continue } spans = append(spans, credentialSpan{userinfoStart: start, passwordStart: colon + 1, at: at}) cursor = at + 1 } return spans } func credentialUsernameStart(value string, colon int) int { start := colon for start > 0 && isCredentialUsernameByte(value[start-1]) { start-- } if start > 0 && (isCredentialUsernameByte(value[start-1]) || value[start-1] == '@') { return colon } if start > 0 && value[start-1] == '/' && (start < 3 || value[start-3:start] != "://") { return colon } return start } func hasSchemeAuthorityPrefix(value string, usernameStart int) bool { return usernameStart >= 3 && value[usernameStart-3:usernameStart] == "://" } func isCredentialUsernameByte(value byte) bool { return value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9' || strings.ContainsRune("._~%+-", rune(value)) } func credentialDelimiter(value string, passwordStart int) int { for at := passwordStart; at < len(value); at++ { if value[at] != '@' { continue } authorityStart := at + 1 end := authorityStart for end < len(value) && !isAuthorityBoundary(value[end]) { end++ } if end == authorityStart || strings.ContainsRune(value[authorityStart:end], '@') { continue } return at } return -1 } func isAuthorityBoundary(value byte) bool { switch value { case ' ', '\t', '\r', '\n', '/', '?', '#', '\'', '"', '<', '>', '[', ']', '(', ')', '{', '}', ',', ';': return true default: return value < 0x20 || value == 0x7f } } func hasNestedCredentialStart(value string, passwordStart, at int) bool { for nested := passwordStart; nested < at; nested++ { if value[nested] != ':' { continue } // A later scheme authority proves the earlier colon was prose (for // example "Proxy URL:") rather than the credential separator. if nested+2 < len(value) && value[nested+1:nested+3] == "//" { return true } if credentialUsernameStart(value, nested) < nested && credentialDelimiter(value, nested+1) == at { return true } } return false } func redactCredentialSpans(value string, removeUsername bool) string { spans := credentialSpans(value) if len(spans) == 0 { return value } var redacted strings.Builder last := 0 for _, span := range spans { redacted.WriteString(value[last:span.userinfoStart]) if !removeUsername { redacted.WriteString(value[span.userinfoStart:span.passwordStart]) } redacted.WriteString("REDACTED@") last = span.at + 1 } redacted.WriteString(value[last:]) return redacted.String() } // RedactText removes passwords from valid and malformed credential-bearing // text while retaining usernames for diagnostics where that behavior is useful. func RedactText(value string) string { return redactCredentialSpans(value, false) } // RedactUserinfoText removes usernames as well as passwords. Form frames use // this stricter boundary because even the identity of the probe source is // private while a Profile is being edited. func RedactUserinfoText(value string) string { return redactCredentialSpans(value, true) } func ObscureURL(value string) string { u, err := url.Parse(value) if err != nil || u.User == nil { return RedactText(value) } if _, present := u.User.Password(); !present { return value } u.User = url.UserPassword(u.User.Username(), "REDACTED") return u.String() } // ObscureURLForEditing masks every userinfo rune while preserving the input's // rune count. A text input clone can therefore retain its real cursor and // horizontal viewport positions without rendering either username or password. func ObscureURLForEditing(value string) string { spans := credentialSpans(value) if len(spans) == 0 { return value } var masked strings.Builder last := 0 for _, span := range spans { masked.WriteString(value[last:span.userinfoStart]) masked.WriteString(strings.Repeat("•", len([]rune(value[span.userinfoStart:span.at])))) last = span.at } masked.WriteString(value[last:]) return masked.String() } func looksLikeIPv4(host string) bool { parts := strings.Split(host, ".") if len(parts) != 4 { return false } for _, part := range parts { if part == "" { return false } for _, r := range part { if r < '0' || r > '9' { return false } } } return true } func validHostname(host string) bool { if len(host) == 0 || len(host) > 253 || strings.HasPrefix(host, ".") || strings.HasSuffix(host, ".") { return false } for _, label := range strings.Split(host, ".") { if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { return false } for _, r := range label { if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-') { return false } } } return true }