feat: add reusable proxy configuration
This commit is contained in:
@@ -5,8 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -92,10 +90,7 @@ func BuildArgs(spec domain.LaunchSpec) ([]string, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
proxy, err := normalizeProxy(normalized.ProxyServer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
proxy := normalized.ProxyServer
|
||||
args := []string{
|
||||
"--user-data-dir=" + normalized.UserDataDir,
|
||||
"--no-first-run",
|
||||
@@ -128,26 +123,6 @@ func BuildArgs(spec domain.LaunchSpec) ([]string, error) {
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func normalizeProxy(value string) (string, error) {
|
||||
proxy := strings.TrimSpace(value)
|
||||
if proxy == "" {
|
||||
return "", nil
|
||||
}
|
||||
parsed, err := url.Parse(proxy)
|
||||
if err != nil || parsed.User != nil || parsed.Hostname() == "" || parsed.Port() == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", fmt.Errorf("%w: proxy must be scheme://host:port without credentials", domain.ErrInvalidLaunchSpec)
|
||||
}
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
if scheme != "http" && scheme != "https" && scheme != "socks4" && scheme != "socks5" {
|
||||
return "", fmt.Errorf("%w: unsupported proxy scheme", domain.ErrInvalidLaunchSpec)
|
||||
}
|
||||
port, err := strconv.Atoi(parsed.Port())
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return "", fmt.Errorf("%w: proxy port out of range", domain.ErrInvalidLaunchSpec)
|
||||
}
|
||||
return scheme + "://" + net.JoinHostPort(strings.ToLower(parsed.Hostname()), strconv.Itoa(port)), nil
|
||||
}
|
||||
|
||||
func validateExtraArg(value string) error {
|
||||
arg := strings.TrimSpace(value)
|
||||
if arg == "" || !strings.HasPrefix(arg, "--") {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chub/internal/domain"
|
||||
@@ -25,14 +27,25 @@ type Settings struct {
|
||||
type Instance struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ProxyID string `json:"proxyId,omitempty"`
|
||||
Launch domain.LaunchSpec `json:"launch"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ProxyProfile is deliberately limited to a display name and a validated,
|
||||
// non-authenticated endpoint. Credentials must never enter local config.
|
||||
type ProxyProfile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Server string `json:"server"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type File struct {
|
||||
Version int `json:"version"`
|
||||
Settings Settings `json:"settings"`
|
||||
Instances []Instance `json:"instances"`
|
||||
Version int `json:"version"`
|
||||
Settings Settings `json:"settings"`
|
||||
Instances []Instance `json:"instances"`
|
||||
Proxies []ProxyProfile `json:"proxies"`
|
||||
}
|
||||
|
||||
type Store struct{ path string }
|
||||
@@ -75,6 +88,9 @@ func (s *Store) Load() (File, error) {
|
||||
if result.Settings.RemoteDebugStartPort == 0 {
|
||||
result.Settings.RemoteDebugStartPort = domain.DefaultRemoteDebugPort
|
||||
}
|
||||
if err := normalizeProxyConfig(&result); err != nil {
|
||||
return File{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -94,6 +110,12 @@ func (s *Store) Save(value File) error {
|
||||
if value.Instances == nil {
|
||||
value.Instances = []Instance{}
|
||||
}
|
||||
if value.Proxies == nil {
|
||||
value.Proxies = []ProxyProfile{}
|
||||
}
|
||||
if err := normalizeProxyConfig(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode config: %w", err)
|
||||
@@ -128,6 +150,79 @@ func (s *Store) Save(value File) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeProxyConfig(value *File) error {
|
||||
if value == nil {
|
||||
return errors.New("config is required")
|
||||
}
|
||||
byID := make(map[string]ProxyProfile, len(value.Proxies))
|
||||
names := make(map[string]struct{}, len(value.Proxies))
|
||||
for i := range value.Proxies {
|
||||
profile := &value.Proxies[i]
|
||||
profile.ID = strings.TrimSpace(profile.ID)
|
||||
profile.Name = strings.TrimSpace(profile.Name)
|
||||
if profile.ID == "" || profile.Name == "" {
|
||||
return errors.New("invalid proxy configuration")
|
||||
}
|
||||
if _, exists := byID[profile.ID]; exists {
|
||||
return errors.New("duplicate proxy configuration")
|
||||
}
|
||||
nameKey := strings.ToLower(profile.Name)
|
||||
if _, exists := names[nameKey]; exists {
|
||||
return errors.New("duplicate proxy configuration")
|
||||
}
|
||||
server, err := domain.NormalizeProxyServer(profile.Server)
|
||||
if err != nil || server == "" {
|
||||
return errors.New("invalid proxy configuration")
|
||||
}
|
||||
profile.Server = server
|
||||
byID[profile.ID] = *profile
|
||||
names[nameKey] = struct{}{}
|
||||
}
|
||||
|
||||
legacyByServer := make(map[string]string, len(value.Proxies))
|
||||
for _, profile := range value.Proxies {
|
||||
legacyByServer[profile.Server] = profile.ID
|
||||
}
|
||||
for i := range value.Instances {
|
||||
instance := &value.Instances[i]
|
||||
instance.ProxyID = strings.TrimSpace(instance.ProxyID)
|
||||
if instance.ProxyID != "" {
|
||||
if _, exists := byID[instance.ProxyID]; !exists {
|
||||
return errors.New("instance refers to an unavailable proxy")
|
||||
}
|
||||
instance.Launch.ProxyServer = ""
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(instance.Launch.ProxyServer) == "" {
|
||||
continue
|
||||
}
|
||||
server, err := domain.NormalizeProxyServer(instance.Launch.ProxyServer)
|
||||
if err != nil || server == "" {
|
||||
return errors.New("invalid proxy configuration")
|
||||
}
|
||||
proxyID := legacyByServer[server]
|
||||
if proxyID == "" {
|
||||
proxyID = nextLegacyProxyID(byID)
|
||||
profile := ProxyProfile{ID: proxyID, Name: "导入代理 " + strconv.Itoa(len(value.Proxies)+1), Server: server}
|
||||
value.Proxies = append(value.Proxies, profile)
|
||||
byID[proxyID] = profile
|
||||
legacyByServer[server] = proxyID
|
||||
}
|
||||
instance.ProxyID = proxyID
|
||||
instance.Launch.ProxyServer = ""
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nextLegacyProxyID(existing map[string]ProxyProfile) string {
|
||||
for index := 1; ; index++ {
|
||||
candidate := "legacy-proxy-" + strconv.Itoa(index)
|
||||
if _, exists := existing[candidate]; !exists {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func replace(target, temp string) error {
|
||||
backup := target + ".bak"
|
||||
_, statErr := os.Stat(target)
|
||||
|
||||
@@ -55,3 +55,40 @@ func TestStoreMissingFileReturnsDefaults(t *testing.T) {
|
||||
t.Fatalf("unexpected defaults: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRoundTripsReferencedProxyWithoutCredentials(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
store, err := New(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := File{
|
||||
Proxies: []ProxyProfile{{ID: "proxy-sg", Name: "新加坡出口", Server: "HTTPS://Proxy.Local:8443"}},
|
||||
Instances: []Instance{{ID: "a", Name: "运营", ProxyID: "proxy-sg", Launch: domain.LaunchSpec{Kind: domain.BrowserChrome, UserDataDir: `C:\profiles\a`}}},
|
||||
}
|
||||
if err := store.Save(want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := store.Load()
|
||||
if err != nil || len(got.Proxies) != 1 || got.Proxies[0].Server != "https://proxy.local:8443" || got.Instances[0].ProxyID != "proxy-sg" || got.Instances[0].Launch.ProxyServer != "" {
|
||||
t.Fatalf("proxy round trip = %#v, error = %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreMigratesLegacyProxyAndRejectsUnsafeConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"version":1,"settings":{},"instances":[{"id":"a","name":"运营","launch":{"Kind":"chrome","UserDataDir":"C:\\profiles\\a","ProxyServer":"http://127.0.0.1:8080"}}]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := New(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := store.Load()
|
||||
if err != nil || len(got.Proxies) != 1 || got.Instances[0].ProxyID == "" || got.Instances[0].Launch.ProxyServer != "" {
|
||||
t.Fatalf("legacy proxy migration = %#v, error = %v", got, err)
|
||||
}
|
||||
if err := store.Save(File{Proxies: []ProxyProfile{{ID: "bad", Name: "bad", Server: "http://user:secret@127.0.0.1:8080"}}}); err == nil {
|
||||
t.Fatal("credential proxy was persisted")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user