Files
proxy/internal/profile/profile.go
T

340 lines
9.9 KiB
Go

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
}
var credentialPattern = regexp.MustCompile(`(?i)(https?|socks5h?)://([^/@\s:]+):([^@\s]*)@`)
// RedactText removes URL passwords from diagnostic text without requiring the
// surrounding text to be a valid URL.
func RedactText(value string) string {
return credentialPattern.ReplaceAllString(value, `${1}://${2}:REDACTED@`)
}
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 only password runes while preserving the input's
// rune count. A text input clone can therefore retain its real cursor and
// horizontal viewport positions without ever rendering the credential.
func ObscureURLForEditing(value string) string {
matches := credentialPattern.FindAllStringSubmatchIndex(value, -1)
if len(matches) == 0 {
return value
}
var masked strings.Builder
last := 0
for _, match := range matches {
passwordStart, passwordEnd := match[6], match[7]
masked.WriteString(value[last:passwordStart])
masked.WriteString(strings.Repeat("•", len([]rune(value[passwordStart:passwordEnd]))))
last = passwordEnd
}
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
}