feat: add persistent proxy profile management
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
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)
|
||||
Save(State) error
|
||||
}
|
||||
|
||||
func ValidateAndNormalize(input Profile) (Profile, error) {
|
||||
p := input
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
if p.Name == "" {
|
||||
return Profile{}, 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)
|
||||
if err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
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 {
|
||||
return Profile{}, err
|
||||
}
|
||||
if p.HTTPSURL, err = validateOptionalURL("HTTPS proxy URL", p.HTTPSURL); err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
if p.AllURL, err = validateOptionalURL("ALL/SOCKS proxy URL", p.AllURL); err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
if p.HTTPURL == "" && p.HTTPSURL == "" && p.AllURL == "" {
|
||||
return Profile{}, errors.New("at least one advanced proxy URL is required")
|
||||
}
|
||||
default:
|
||||
return Profile{}, errors.New("proxy mode must be single or advanced")
|
||||
}
|
||||
|
||||
bypass, err := normalizeBypass(p.Bypass)
|
||||
if err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
p.Bypass = bypass
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func validateOptionalURL(field, value string) (string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "", nil
|
||||
}
|
||||
return validateURL(field, value)
|
||||
}
|
||||
|
||||
func validateURL(field, value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("%s is required", field)
|
||||
}
|
||||
u, err := url.Parse(value)
|
||||
if err != nil {
|
||||
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.Hostname() == "" {
|
||||
return "", fmt.Errorf("%s must include a host", field)
|
||||
}
|
||||
if u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
|
||||
return "", fmt.Errorf("%s must not include a path, query, or fragment", field)
|
||||
}
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
return "", fmt.Errorf("%s must include a port", field)
|
||||
}
|
||||
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 normalizeBypass(values []string) ([]string, error) {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]bool)
|
||||
for _, raw := range values {
|
||||
for _, item := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '\n' }) {
|
||||
value, err := normalizeBypassEntry(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !seen[value] {
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, 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]*)@`)
|
||||
|
||||
func ObscureURL(value string) string {
|
||||
u, err := url.Parse(value)
|
||||
if err != nil || u.User == nil {
|
||||
return credentialPattern.ReplaceAllString(value, `${1}://${2}:REDACTED@`)
|
||||
}
|
||||
if _, present := u.User.Password(); !present {
|
||||
return value
|
||||
}
|
||||
u.User = url.UserPassword(u.User.Username(), "REDACTED")
|
||||
return u.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
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type FileStore struct {
|
||||
path string
|
||||
}
|
||||
|
||||
func NewFileStore(path string) *FileStore {
|
||||
return &FileStore{path: path}
|
||||
}
|
||||
|
||||
func ConfigPath() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("locate user configuration directory: %w", err)
|
||||
}
|
||||
return filepath.Join(configDir, "proxyctl", "profiles.json"), nil
|
||||
}
|
||||
|
||||
func (s *FileStore) Load() (State, error) {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return State{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return State{}, fmt.Errorf("read Profile configuration: %w", err)
|
||||
}
|
||||
var state State
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return State{}, fmt.Errorf("parse Profile configuration: %w", err)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *FileStore) Save(state State) error {
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
||||
return fmt.Errorf("create Profile configuration directory: %w", err)
|
||||
}
|
||||
if err := os.Chmod(filepath.Dir(s.path), 0o700); err != nil {
|
||||
return fmt.Errorf("secure Profile configuration directory: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode Profile configuration: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
temp, err := os.CreateTemp(filepath.Dir(s.path), ".profiles-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary Profile configuration: %w", err)
|
||||
}
|
||||
tempName := temp.Name()
|
||||
defer os.Remove(tempName)
|
||||
if err := temp.Chmod(0o600); err != nil {
|
||||
temp.Close()
|
||||
return fmt.Errorf("secure temporary Profile configuration: %w", err)
|
||||
}
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
temp.Close()
|
||||
return fmt.Errorf("write temporary Profile configuration: %w", err)
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
temp.Close()
|
||||
return fmt.Errorf("sync temporary Profile configuration: %w", err)
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary Profile configuration: %w", err)
|
||||
}
|
||||
if err := os.Rename(tempName, s.path); err != nil {
|
||||
return fmt.Errorf("replace Profile configuration: %w", err)
|
||||
}
|
||||
if err := os.Chmod(s.path, 0o600); err != nil {
|
||||
return fmt.Errorf("secure Profile configuration: %w", err)
|
||||
}
|
||||
dir, err := os.Open(filepath.Dir(s.path))
|
||||
if err == nil {
|
||||
err = dir.Sync()
|
||||
_ = dir.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("sync Profile configuration directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user