feat: add persistent proxy profile management

This commit is contained in:
proxyctl coordinator
2026-07-17 02:08:51 +08:00
parent 73b027b85b
commit f9469cf6a2
13 changed files with 1390 additions and 0 deletions
+164
View File
@@ -0,0 +1,164 @@
package app
import (
"errors"
"fmt"
"proxyctl/internal/profile"
)
type IntentKind int
const (
CreateProfile IntentKind = iota + 1
UpdateProfile
DeleteProfile
ActivateProfile
)
type Intent struct {
Kind IntentKind
Name string
Profile profile.Profile
Confirm bool
}
type Snapshot struct {
Profiles []profile.Profile
ActiveProfile string
}
type Result struct {
Snapshot Snapshot
Notice string
Err error
}
type Module interface {
Snapshot() Snapshot
Dispatch(Intent) Result
}
type Application struct {
store profile.Store
state profile.State
}
func New(store profile.Store) (*Application, error) {
state, err := store.Load()
if err != nil {
return nil, err
}
return &Application{store: store, state: state}, nil
}
func (a *Application) Snapshot() Snapshot {
profiles := append([]profile.Profile(nil), a.state.Profiles...)
return Snapshot{Profiles: profiles, ActiveProfile: a.state.ActiveProfile}
}
func (a *Application) Dispatch(intent Intent) Result {
var notice string
var err error
switch intent.Kind {
case CreateProfile:
notice, err = a.create(intent.Profile)
case ActivateProfile:
notice, err = a.activate(intent.Name)
case UpdateProfile:
notice, err = a.update(intent.Name, intent.Profile)
case DeleteProfile:
notice, err = a.delete(intent.Name, intent.Confirm)
default:
err = errors.New("unsupported Profile action")
}
return Result{Snapshot: a.Snapshot(), Notice: notice, Err: err}
}
func (a *Application) create(input profile.Profile) (string, error) {
p, err := profile.ValidateAndNormalize(input)
if err != nil {
return "", err
}
if a.index(p.Name) >= 0 {
return "", fmt.Errorf("a Profile named %q already exists", p.Name)
}
next := cloneState(a.state)
next.Profiles = append(next.Profiles, p)
if err := a.store.Save(next); err != nil {
return "", err
}
a.state = next
return fmt.Sprintf("Created Profile %q", p.Name), nil
}
func (a *Application) activate(name string) (string, error) {
if a.index(name) < 0 {
return "", fmt.Errorf("Profile %q does not exist", name)
}
next := cloneState(a.state)
next.ActiveProfile = name
if err := a.store.Save(next); err != nil {
return "", err
}
a.state = next
return fmt.Sprintf("Activated Profile %q", name), nil
}
func (a *Application) update(originalName string, input profile.Profile) (string, error) {
index := a.index(originalName)
if index < 0 {
return "", fmt.Errorf("Profile %q does not exist", originalName)
}
p, err := profile.ValidateAndNormalize(input)
if err != nil {
return "", err
}
if other := a.index(p.Name); other >= 0 && other != index {
return "", fmt.Errorf("a Profile named %q already exists", p.Name)
}
next := cloneState(a.state)
next.Profiles[index] = p
if next.ActiveProfile == originalName {
next.ActiveProfile = p.Name
}
if err := a.store.Save(next); err != nil {
return "", err
}
a.state = next
return fmt.Sprintf("Updated Profile %q", p.Name), nil
}
func (a *Application) delete(name string, confirm bool) (string, error) {
index := a.index(name)
if index < 0 {
return "", fmt.Errorf("Profile %q does not exist", name)
}
if len(a.state.Profiles) == 1 && !confirm {
return "", errors.New("deleting the final Profile requires confirmation")
}
next := cloneState(a.state)
next.Profiles = append(next.Profiles[:index], next.Profiles[index+1:]...)
if next.ActiveProfile == name {
next.ActiveProfile = ""
}
if err := a.store.Save(next); err != nil {
return "", err
}
a.state = next
return fmt.Sprintf("Deleted Profile %q", name), nil
}
func (a *Application) index(name string) int {
for i := range a.state.Profiles {
if a.state.Profiles[i].Name == name {
return i
}
}
return -1
}
func cloneState(state profile.State) profile.State {
state.Profiles = append([]profile.Profile(nil), state.Profiles...)
return state
}
+69
View File
@@ -0,0 +1,69 @@
package app_test
import (
"path/filepath"
"testing"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
func TestUserCanCreateAndActivatePersistentSingleProxyProfile(t *testing.T) {
configFile := filepath.Join(t.TempDir(), "proxyctl", "profiles.json")
application, err := app.New(profile.NewFileStore(configFile))
if err != nil {
t.Fatalf("start application: %v", err)
}
result := application.Dispatch(app.Intent{
Kind: app.CreateProfile,
Profile: profile.Profile{
Name: "office",
Mode: profile.SingleMode,
SingleURL: "http://proxy.example:8080",
Bypass: []string{"LOCALHOST", ".Example.com", "localhost"},
},
})
if result.Err != nil {
t.Fatalf("create Profile: %v", result.Err)
}
result = application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "office"})
if result.Err != nil {
t.Fatalf("activate Profile: %v", result.Err)
}
if result.Snapshot.ActiveProfile != "office" {
t.Fatalf("Active Profile = %q, want office", result.Snapshot.ActiveProfile)
}
got := result.Snapshot.Profiles[0]
if got.SingleURL != "http://proxy.example:8080" {
t.Fatalf("single proxy value = %q", got.SingleURL)
}
if want := []string{"localhost", ".example.com"}; !equalStrings(got.Bypass, want) {
t.Fatalf("normalized Bypass = %#v, want %#v", got.Bypass, want)
}
if got.HTTPURL != "http://proxy.example:8080" || got.HTTPSURL != "http://proxy.example:8080" || got.AllURL != "http://proxy.example:8080" {
t.Fatalf("Single proxy was not expanded: %#v", got)
}
restarted, err := app.New(profile.NewFileStore(configFile))
if err != nil {
t.Fatalf("restart application: %v", err)
}
persisted := restarted.Snapshot()
if persisted.ActiveProfile != "office" || len(persisted.Profiles) != 1 {
t.Fatalf("persisted snapshot = %#v", persisted)
}
}
func equalStrings(got, want []string) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
+75
View File
@@ -0,0 +1,75 @@
package app_test
import (
"path/filepath"
"strings"
"testing"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
func TestUserCanOpenSingleProfileInAdvancedModeWithoutLosingItsValue(t *testing.T) {
application, err := app.New(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")))
if err != nil {
t.Fatal(err)
}
created := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{
Name: "home", Mode: profile.SingleMode, SingleURL: "socks5h://127.0.0.1:1080",
}})
if created.Err != nil {
t.Fatal(created.Err)
}
edited := application.Dispatch(app.Intent{Kind: app.UpdateProfile, Name: "home", Profile: profile.Profile{
Name: "home", Mode: profile.AdvancedMode, SingleURL: "socks5h://127.0.0.1:1080",
}})
if edited.Err != nil {
t.Fatalf("open advanced editing: %v", edited.Err)
}
got := edited.Snapshot.Profiles[0]
if got.HTTPURL != got.SingleURL || got.HTTPSURL != got.SingleURL || got.AllURL != got.SingleURL {
t.Fatalf("advanced values = %q, %q, %q; want preserved %q", got.HTTPURL, got.HTTPSURL, got.AllURL, got.SingleURL)
}
}
func TestUserCanViewEditActivateAndDeleteProfiles(t *testing.T) {
application, err := app.New(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")))
if err != nil {
t.Fatal(err)
}
for _, p := range []profile.Profile{
{Name: "home", Mode: profile.SingleMode, SingleURL: "http://localhost:8080"},
{Name: "office", Mode: profile.AdvancedMode, HTTPURL: "http://proxy.example:3128", HTTPSURL: "https://proxy.example:4443", AllURL: "socks5://proxy.example:1080"},
} {
if result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: p}); result.Err != nil {
t.Fatalf("create %s: %v", p.Name, result.Err)
}
}
if result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{Name: "home", Mode: profile.SingleMode, SingleURL: "http://localhost:9000"}}); result.Err == nil || !strings.Contains(result.Err.Error(), "already exists") {
t.Fatalf("duplicate name error = %v", result.Err)
}
if result := application.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "office"}); result.Err != nil {
t.Fatal(result.Err)
}
updated := application.Dispatch(app.Intent{Kind: app.UpdateProfile, Name: "office", Profile: profile.Profile{
Name: "work", Mode: profile.AdvancedMode, HTTPURL: "http://proxy.example:3128", AllURL: "socks5h://proxy.example:1080",
}})
if updated.Err != nil {
t.Fatal(updated.Err)
}
if updated.Snapshot.ActiveProfile != "work" {
t.Fatalf("renamed Active Profile = %q", updated.Snapshot.ActiveProfile)
}
deleted := application.Dispatch(app.Intent{Kind: app.DeleteProfile, Name: "home", Confirm: true})
if deleted.Err != nil || len(deleted.Snapshot.Profiles) != 1 {
t.Fatalf("delete Profile result = %#v", deleted)
}
if unconfirmed := application.Dispatch(app.Intent{Kind: app.DeleteProfile, Name: "work"}); unconfirmed.Err == nil {
t.Fatal("deleting the final Profile did not require confirmation")
}
confirmed := application.Dispatch(app.Intent{Kind: app.DeleteProfile, Name: "work", Confirm: true})
if confirmed.Err != nil || confirmed.Snapshot.ActiveProfile != "" || len(confirmed.Snapshot.Profiles) != 0 {
t.Fatalf("delete final Profile result = %#v", confirmed)
}
}
+82
View File
@@ -0,0 +1,82 @@
package app_test
import (
"io"
"os"
"path/filepath"
"strings"
"testing"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
func TestProfileConfigurationUsesIsolatedXDGPathOwnerOnlyAndAtomicReplacement(t *testing.T) {
xdg := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdg)
path, err := profile.ConfigPath()
if err != nil {
t.Fatalf("resolve XDG configuration: %v", err)
}
wantPath := filepath.Join(xdg, "proxyctl", "profiles.json")
if path != wantPath {
t.Fatalf("configuration path = %q, want %q", path, wantPath)
}
application, err := app.New(profile.NewFileStore(path))
if err != nil {
t.Fatal(err)
}
created := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{
Name: "private", Mode: profile.SingleMode, SingleURL: "http://alice:secret@proxy.example:8080",
}})
if created.Err != nil {
t.Fatal(created.Err)
}
assertMode(t, filepath.Dir(path), 0o700)
assertMode(t, path, 0o600)
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "\n \"profiles\"") {
t.Fatalf("Profile configuration is not human-readable JSON:\n%s", data)
}
oldFile, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer oldFile.Close()
updated := application.Dispatch(app.Intent{Kind: app.UpdateProfile, Name: "private", Profile: profile.Profile{
Name: "renamed", Mode: profile.SingleMode, SingleURL: "http://alice:new-secret@proxy.example:8080",
}})
if updated.Err != nil {
t.Fatal(updated.Err)
}
oldData, err := io.ReadAll(oldFile)
if err != nil {
t.Fatal(err)
}
newData, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(oldData), `"name": "private"`) || !strings.Contains(string(newData), `"name": "renamed"`) {
t.Fatalf("configuration was not atomically replaced; old=%s new=%s", oldData, newData)
}
matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), ".profiles-*"))
if err != nil || len(matches) != 0 {
t.Fatalf("temporary Profile files left behind: %v (%v)", matches, err)
}
}
func assertMode(t *testing.T, path string, want os.FileMode) {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != want {
t.Fatalf("mode for %s = %#o, want %#o", path, got, want)
}
}
+72
View File
@@ -0,0 +1,72 @@
package app_test
import (
"path/filepath"
"strings"
"testing"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
func TestProfileValidationAcceptsSupportedURLsAndNormalizesEveryBypassKind(t *testing.T) {
application, err := app.New(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")))
if err != nil {
t.Fatal(err)
}
result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: profile.Profile{
Name: "mixed",
Mode: profile.AdvancedMode,
HTTPURL: "http://proxy.example:80",
HTTPSURL: "https://user:secret@proxy.example:443",
AllURL: "socks5h://[2001:db8::5]:1080",
Bypass: []string{
"LOCALHOST, Example.COM.",
".Internal.Example",
"192.0.2.1",
"2001:0db8::1",
"10.2.3.4/8",
"2001:db8:abcd::5/48",
"localhost",
},
}})
if result.Err != nil {
t.Fatalf("create validated Profile: %v", result.Err)
}
want := []string{"localhost", "example.com", ".internal.example", "192.0.2.1", "2001:db8::1", "10.0.0.0/8", "2001:db8:abcd::/48"}
if got := result.Snapshot.Profiles[0].Bypass; !equalStrings(got, want) {
t.Fatalf("normalized Bypass = %#v, want %#v", got, want)
}
}
func TestProfileValidationRejectsMalformedURLsAndBypassWithActionableErrors(t *testing.T) {
tests := []struct {
name string
profile profile.Profile
want string
}{
{"unsupported scheme", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "ftp://user:secret@proxy.example:21"}, "http, https, socks5, or socks5h"},
{"missing host", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://:8080"}, "host"},
{"missing port", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://proxy.example"}, "port"},
{"invalid port", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://proxy.example:70000"}, "between 1 and 65535"},
{"path", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080/path"}, "path"},
{"invalid IPv4 host", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://999.1.2.3:8080"}, "invalid host"},
{"invalid bypass", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080", Bypass: []string{"*"}}, "hostname, domain suffix, IP address, or CIDR"},
{"invalid IPv4 bypass", profile.Profile{Name: "bad", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080", Bypass: []string{"999.1.2.3"}}, "hostname, domain suffix, IP address, or CIDR"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
application, err := app.New(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")))
if err != nil {
t.Fatal(err)
}
result := application.Dispatch(app.Intent{Kind: app.CreateProfile, Profile: tt.profile})
if result.Err == nil || !strings.Contains(result.Err.Error(), tt.want) {
t.Fatalf("error = %v, want actionable text containing %q", result.Err, tt.want)
}
if strings.Contains(result.Err.Error(), "secret") {
t.Fatalf("validation error exposed proxy password: %v", result.Err)
}
})
}
}