feat: add persistent proxy profile management
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user