feat: add persistent proxy profile management
This commit is contained in:
@@ -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