feat: add persistent proxy profile management
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"proxyctl/internal/app"
|
||||
"proxyctl/internal/profile"
|
||||
"proxyctl/internal/tui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:], os.Stdin, os.Stdout); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "proxyctl:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string, input io.Reader, output io.Writer) error {
|
||||
if len(args) != 0 {
|
||||
return fmt.Errorf("this build supports no command-line arguments")
|
||||
}
|
||||
path, err := profile.ConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
application, err := app.New(profile.NewFileStore(path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
program := tea.NewProgram(tui.NewModel(application), tea.WithInput(input), tea.WithOutput(output))
|
||||
_, err = program.Run()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultInvocationStartsTUIWithNoProfileState(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
var output bytes.Buffer
|
||||
if err := run(nil, strings.NewReader("q"), &output); err != nil {
|
||||
t.Fatalf("run proxyctl: %v", err)
|
||||
}
|
||||
if !strings.Contains(output.String(), "No Active Profile") {
|
||||
t.Fatalf("default TUI output missing no-Profile state: %q", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultInvocationLoadsPersistedActiveProfile(t *testing.T) {
|
||||
xdg := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdg)
|
||||
writeConfig(t, filepath.Join(xdg, "proxyctl", "profiles.json"), `{
|
||||
"profiles": [{"name":"office","mode":"single","singleUrl":"http://proxy.example:8080","httpUrl":"http://proxy.example:8080","httpsUrl":"http://proxy.example:8080","allUrl":"http://proxy.example:8080"}],
|
||||
"activeProfile": "office"
|
||||
}`)
|
||||
var output bytes.Buffer
|
||||
if err := run(nil, strings.NewReader("q"), &output); err != nil {
|
||||
t.Fatalf("run proxyctl: %v", err)
|
||||
}
|
||||
if !strings.Contains(output.String(), "Active Profile: office") {
|
||||
t.Fatalf("default TUI output missing Active Profile: %q", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, path, contents string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
module proxyctl
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require github.com/charmbracelet/bubbletea v1.3.10
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"proxyctl/internal/app"
|
||||
"proxyctl/internal/profile"
|
||||
)
|
||||
|
||||
type page int
|
||||
|
||||
const (
|
||||
mainPage page = iota
|
||||
profilesPage
|
||||
formPage
|
||||
)
|
||||
|
||||
type formState struct {
|
||||
originalName string
|
||||
profile profile.Profile
|
||||
field int
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
application app.Module
|
||||
snapshot app.Snapshot
|
||||
page page
|
||||
selected int
|
||||
form formState
|
||||
confirm bool
|
||||
status string
|
||||
}
|
||||
|
||||
func NewModel(application app.Module) Model {
|
||||
return Model{application: application, snapshot: application.Snapshot()}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||
key, ok := message.(tea.KeyMsg)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
switch m.page {
|
||||
case mainPage:
|
||||
return m.updateMain(key)
|
||||
case profilesPage:
|
||||
return m.updateProfiles(key)
|
||||
case formPage:
|
||||
return m.updateForm(key)
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch key.String() {
|
||||
case "p":
|
||||
m.page = profilesPage
|
||||
m.status = ""
|
||||
case "q", "esc", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.confirm {
|
||||
switch key.String() {
|
||||
case "y":
|
||||
if selected, ok := m.selectedProfile(); ok {
|
||||
m.apply(app.Intent{Kind: app.DeleteProfile, Name: selected.Name, Confirm: true})
|
||||
}
|
||||
m.confirm = false
|
||||
case "n", "esc", "q":
|
||||
m.confirm = false
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch key.String() {
|
||||
case "q", "esc":
|
||||
m.page = mainPage
|
||||
m.status = ""
|
||||
case "up", "k":
|
||||
if m.selected > 0 {
|
||||
m.selected--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.selected+1 < len(m.snapshot.Profiles) {
|
||||
m.selected++
|
||||
}
|
||||
case "n":
|
||||
m.form = formState{profile: profile.Profile{Mode: profile.SingleMode}}
|
||||
m.page = formPage
|
||||
m.status = ""
|
||||
case "e":
|
||||
if selected, ok := m.selectedProfile(); ok {
|
||||
m.form = formState{originalName: selected.Name, profile: selected}
|
||||
m.page = formPage
|
||||
m.status = ""
|
||||
}
|
||||
case "a", "enter":
|
||||
if selected, ok := m.selectedProfile(); ok {
|
||||
m.apply(app.Intent{Kind: app.ActivateProfile, Name: selected.Name})
|
||||
}
|
||||
case "d":
|
||||
if _, ok := m.selectedProfile(); ok {
|
||||
m.confirm = true
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.page = profilesPage
|
||||
m.status = ""
|
||||
return m, nil
|
||||
case "ctrl+s":
|
||||
kind := app.CreateProfile
|
||||
if m.form.originalName != "" {
|
||||
kind = app.UpdateProfile
|
||||
}
|
||||
intent := app.Intent{Kind: kind, Name: m.form.originalName, Profile: m.form.profile}
|
||||
result := m.application.Dispatch(intent)
|
||||
m.snapshot = result.Snapshot
|
||||
if result.Err != nil {
|
||||
m.status = "Error: " + result.Err.Error()
|
||||
return m, nil
|
||||
}
|
||||
m.status = result.Notice
|
||||
m.page = profilesPage
|
||||
m.selectName(intent.Profile.Name)
|
||||
return m, nil
|
||||
case "ctrl+t":
|
||||
if m.form.profile.Mode == profile.SingleMode {
|
||||
m.form.profile.Mode = profile.AdvancedMode
|
||||
if m.form.profile.HTTPURL == "" && m.form.profile.HTTPSURL == "" && m.form.profile.AllURL == "" {
|
||||
m.form.profile.HTTPURL = m.form.profile.SingleURL
|
||||
m.form.profile.HTTPSURL = m.form.profile.SingleURL
|
||||
m.form.profile.AllURL = m.form.profile.SingleURL
|
||||
}
|
||||
} else {
|
||||
m.form.profile.Mode = profile.SingleMode
|
||||
}
|
||||
m.form.field = 0
|
||||
return m, nil
|
||||
case "tab", "down":
|
||||
m.form.field = (m.form.field + 1) % m.form.fieldCount()
|
||||
return m, nil
|
||||
case "shift+tab", "up":
|
||||
m.form.field = (m.form.field - 1 + m.form.fieldCount()) % m.form.fieldCount()
|
||||
return m, nil
|
||||
case "backspace":
|
||||
value := []rune(m.form.value())
|
||||
if len(value) > 0 {
|
||||
m.form.setValue(string(value[:len(value)-1]))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if key.Type == tea.KeyRunes {
|
||||
m.form.setValue(m.form.value() + string(key.Runes))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) apply(intent app.Intent) {
|
||||
result := m.application.Dispatch(intent)
|
||||
m.snapshot = result.Snapshot
|
||||
if m.selected >= len(m.snapshot.Profiles) && m.selected > 0 {
|
||||
m.selected--
|
||||
}
|
||||
if result.Err != nil {
|
||||
m.status = "Error: " + result.Err.Error()
|
||||
} else {
|
||||
m.status = result.Notice
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) selectedProfile() (profile.Profile, bool) {
|
||||
if m.selected < 0 || m.selected >= len(m.snapshot.Profiles) {
|
||||
return profile.Profile{}, false
|
||||
}
|
||||
return m.snapshot.Profiles[m.selected], true
|
||||
}
|
||||
|
||||
func (m *Model) selectName(name string) {
|
||||
for i := range m.snapshot.Profiles {
|
||||
if m.snapshot.Profiles[i].Name == name {
|
||||
m.selected = i
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
switch m.page {
|
||||
case profilesPage:
|
||||
return m.profilesView()
|
||||
case formPage:
|
||||
return m.formView()
|
||||
default:
|
||||
return m.mainView()
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) mainView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("proxyctl — Target panel\n\n")
|
||||
if m.snapshot.ActiveProfile == "" {
|
||||
b.WriteString("No Active Profile\n")
|
||||
b.WriteString("Create and activate a Proxy Profile before enabling Targets.\n")
|
||||
} else {
|
||||
fmt.Fprintf(&b, "Active Profile: %s\n", m.snapshot.ActiveProfile)
|
||||
}
|
||||
b.WriteString("\nNo Targets are available in this build.\n")
|
||||
b.WriteString("\np: manage Proxy Profiles q/Esc: quit\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m Model) profilesView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Proxy Profiles\n\n")
|
||||
if len(m.snapshot.Profiles) == 0 {
|
||||
b.WriteString("No Proxy Profiles. Press n to create one.\n")
|
||||
}
|
||||
for i, item := range m.snapshot.Profiles {
|
||||
cursor, active := " ", " "
|
||||
if i == m.selected {
|
||||
cursor = "> "
|
||||
}
|
||||
if item.Name == m.snapshot.ActiveProfile {
|
||||
active = "*"
|
||||
}
|
||||
fmt.Fprintf(&b, "%s%s %s\n", cursor, active, item.Name)
|
||||
}
|
||||
if selected, ok := m.selectedProfile(); ok {
|
||||
b.WriteString("\nSelected Profile\n")
|
||||
fmt.Fprintf(&b, "Mode: %s\n", selected.Mode)
|
||||
if selected.Mode == profile.SingleMode {
|
||||
fmt.Fprintf(&b, "Proxy: %s\n", profile.ObscureURL(selected.SingleURL))
|
||||
} else {
|
||||
fmt.Fprintf(&b, "HTTP: %s\nHTTPS: %s\nALL/SOCKS: %s\n",
|
||||
profile.ObscureURL(selected.HTTPURL), profile.ObscureURL(selected.HTTPSURL), profile.ObscureURL(selected.AllURL))
|
||||
}
|
||||
fmt.Fprintf(&b, "Bypass: %s\n", strings.Join(selected.Bypass, ", "))
|
||||
}
|
||||
if m.confirm {
|
||||
b.WriteString("\nDelete Profile? y: confirm n/Esc: cancel\n")
|
||||
} else {
|
||||
b.WriteString("\nn: new e: edit d: delete a/Enter: activate\n↑/k ↓/j: select q/Esc: back\n")
|
||||
}
|
||||
if m.status != "" {
|
||||
fmt.Fprintf(&b, "\n%s\n", m.status)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m Model) formView() string {
|
||||
var b strings.Builder
|
||||
action := "Create"
|
||||
if m.form.originalName != "" {
|
||||
action = "Edit"
|
||||
}
|
||||
fmt.Fprintf(&b, "%s Proxy Profile — %s mode\n\n", action, m.form.profile.Mode)
|
||||
labels := m.form.labels()
|
||||
for i, label := range labels {
|
||||
cursor := " "
|
||||
if i == m.form.field {
|
||||
cursor = "> "
|
||||
}
|
||||
value := m.form.valueAt(i)
|
||||
if label != "Name" && label != "Bypass" {
|
||||
value = profile.ObscureURL(value)
|
||||
}
|
||||
fmt.Fprintf(&b, "%s%s: %s\n", cursor, label, value)
|
||||
}
|
||||
b.WriteString("\nTab/↑/↓: move Ctrl+T: toggle Single/Advanced\nCtrl+S: save Esc: cancel\n")
|
||||
if m.status != "" {
|
||||
fmt.Fprintf(&b, "\n%s\n", m.status)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (f formState) labels() []string {
|
||||
if f.profile.Mode == profile.AdvancedMode {
|
||||
return []string{"Name", "HTTP URL", "HTTPS URL", "ALL/SOCKS URL", "Bypass"}
|
||||
}
|
||||
return []string{"Name", "Single proxy URL", "Bypass"}
|
||||
}
|
||||
|
||||
func (f formState) fieldCount() int { return len(f.labels()) }
|
||||
|
||||
func (f formState) value() string { return f.valueAt(f.field) }
|
||||
|
||||
func (f formState) valueAt(index int) string {
|
||||
if index == 0 {
|
||||
return f.profile.Name
|
||||
}
|
||||
if f.profile.Mode == profile.AdvancedMode {
|
||||
switch index {
|
||||
case 1:
|
||||
return f.profile.HTTPURL
|
||||
case 2:
|
||||
return f.profile.HTTPSURL
|
||||
case 3:
|
||||
return f.profile.AllURL
|
||||
default:
|
||||
return strings.Join(f.profile.Bypass, ", ")
|
||||
}
|
||||
}
|
||||
if index == 1 {
|
||||
return f.profile.SingleURL
|
||||
}
|
||||
return strings.Join(f.profile.Bypass, ", ")
|
||||
}
|
||||
|
||||
func (f *formState) setValue(value string) {
|
||||
if f.field == 0 {
|
||||
f.profile.Name = value
|
||||
return
|
||||
}
|
||||
if f.profile.Mode == profile.AdvancedMode {
|
||||
switch f.field {
|
||||
case 1:
|
||||
f.profile.HTTPURL = value
|
||||
case 2:
|
||||
f.profile.HTTPSURL = value
|
||||
case 3:
|
||||
f.profile.AllURL = value
|
||||
default:
|
||||
f.profile.Bypass = []string{value}
|
||||
}
|
||||
return
|
||||
}
|
||||
if f.field == 1 {
|
||||
f.profile.SingleURL = value
|
||||
} else {
|
||||
f.profile.Bypass = []string{value}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"proxyctl/internal/app"
|
||||
"proxyctl/internal/profile"
|
||||
)
|
||||
|
||||
type recordingModule struct {
|
||||
snapshot app.Snapshot
|
||||
intents []app.Intent
|
||||
}
|
||||
|
||||
func (m *recordingModule) Snapshot() app.Snapshot { return m.snapshot }
|
||||
func (m *recordingModule) Dispatch(intent app.Intent) app.Result {
|
||||
m.intents = append(m.intents, intent)
|
||||
return app.Result{Snapshot: m.snapshot, Notice: "done"}
|
||||
}
|
||||
|
||||
func TestMainAndProfilePagesMapKeysAndObscurePasswords(t *testing.T) {
|
||||
module := &recordingModule{snapshot: app.Snapshot{
|
||||
ActiveProfile: "office",
|
||||
Profiles: []profile.Profile{{
|
||||
Name: "office", Mode: profile.SingleMode,
|
||||
SingleURL: "https://alice:very-secret@proxy.example:8443",
|
||||
}},
|
||||
}}
|
||||
model := NewModel(module)
|
||||
if view := model.View(); !strings.Contains(view, "Active Profile: office") {
|
||||
t.Fatalf("main view missing Active Profile:\n%s", view)
|
||||
}
|
||||
|
||||
model = updateKey(model, "p")
|
||||
view := model.View()
|
||||
if !strings.Contains(view, "Proxy Profiles") || !strings.Contains(view, "alice:") {
|
||||
t.Fatalf("Profile page did not render selected Profile:\n%s", view)
|
||||
}
|
||||
if strings.Contains(view, "very-secret") {
|
||||
t.Fatalf("Profile page exposed proxy password:\n%s", view)
|
||||
}
|
||||
|
||||
model = updateKey(model, "a")
|
||||
if len(module.intents) != 1 || module.intents[0].Kind != app.ActivateProfile || module.intents[0].Name != "office" {
|
||||
t.Fatalf("activate key dispatched %#v", module.intents)
|
||||
}
|
||||
model = updateKey(model, "d")
|
||||
if !strings.Contains(model.View(), "Delete Profile") {
|
||||
t.Fatalf("delete key did not request confirmation:\n%s", model.View())
|
||||
}
|
||||
model = updateKey(model, "y")
|
||||
if len(module.intents) != 2 || module.intents[1].Kind != app.DeleteProfile || !module.intents[1].Confirm {
|
||||
t.Fatalf("confirmed delete dispatched %#v", module.intents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileFormMapsKeyboardInputToCreateIntent(t *testing.T) {
|
||||
module := &recordingModule{}
|
||||
model := NewModel(module)
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "n")
|
||||
model = updateRunes(model, "home")
|
||||
model = updateKey(model, "tab")
|
||||
model = updateRunes(model, "socks5://localhost:1080")
|
||||
model = updateKey(model, "tab")
|
||||
model = updateRunes(model, "localhost, .example.com")
|
||||
model = updateKey(model, "ctrl+s")
|
||||
|
||||
if len(module.intents) != 1 {
|
||||
t.Fatalf("create form dispatched %d intents", len(module.intents))
|
||||
}
|
||||
intent := module.intents[0]
|
||||
if intent.Kind != app.CreateProfile || intent.Profile.Name != "home" || intent.Profile.SingleURL != "socks5://localhost:1080" {
|
||||
t.Fatalf("create form dispatched %#v", intent)
|
||||
}
|
||||
if got := intent.Profile.Bypass; len(got) != 1 || got[0] != "localhost, .example.com" {
|
||||
t.Fatalf("create form Bypass = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderedMalformedProxyURLStillObscuresPassword(t *testing.T) {
|
||||
module := &recordingModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{
|
||||
Name: "manual", Mode: profile.SingleMode, SingleURL: "http://alice:do-not-show@proxy.example:bad",
|
||||
}}}}
|
||||
model := NewModel(module)
|
||||
model = updateKey(model, "p")
|
||||
if view := model.View(); strings.Contains(view, "do-not-show") {
|
||||
t.Fatalf("malformed proxy URL exposed password:\n%s", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoProfileStateStaysOnAccessibleMainPage(t *testing.T) {
|
||||
model := NewModel(&recordingModule{})
|
||||
view := model.View()
|
||||
if !strings.Contains(view, "No Active Profile") || !strings.Contains(view, "p: manage Proxy Profiles") {
|
||||
t.Fatalf("no-Profile main view is not actionable:\n%s", view)
|
||||
}
|
||||
}
|
||||
|
||||
func updateKey(model Model, key string) Model {
|
||||
msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
|
||||
switch key {
|
||||
case "tab":
|
||||
msg = tea.KeyMsg{Type: tea.KeyTab}
|
||||
case "ctrl+s":
|
||||
msg = tea.KeyMsg{Type: tea.KeyCtrlS}
|
||||
}
|
||||
updated, _ := model.Update(msg)
|
||||
return updated.(Model)
|
||||
}
|
||||
|
||||
func updateRunes(model Model, value string) Model {
|
||||
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)})
|
||||
return updated.(Model)
|
||||
}
|
||||
Reference in New Issue
Block a user