466 lines
14 KiB
Go
466 lines
14 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
|
|
}
|
|
|
|
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
|
|
}
|