diff --git a/README.md b/README.md index d689234..4350e41 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Start the Target panel with no arguments: proxyctl ``` -Press `m` for Proxy Profile management, then `n` to create a Profile, enter a name and proxy URL, and press Ctrl+S to save. The editor supports cursor movement, Home, End, Delete, Backspace, insertion, paste, and horizontal scrolling; Tab or the arrow keys move between fields, and Escape cancels without saving. Validation stays beside each field. The management page supports reviewing obscured connection details, editing, and safe deletion; Active Profile switching remains a separate dashboard picker workflow rather than a management action. Single mode applies one URL to HTTP, HTTPS, and ALL/SOCKS. Ctrl+T selects advanced mode for separate values without discarding values entered in either mode. A bare `host:port` is accepted and normalized to `http://`. The Single proxy URL field visibly offers Protocol Probe with Ctrl+D; it runs only on that explicit keypress, and its credential-safe progress and HTTP/SOCKS5 result stay with the affected URL field rather than global status. When one endpoint supports both protocols, the probe switches the form to advanced mode and fills HTTP/HTTPS with `http://` and ALL/SOCKS with `socks5://`. Bypass accepts comma- or newline-delimited hostnames, domain suffixes, IPv4, IPv6, and CIDR entries; the editor previews normalized, deduplicated values and identifies invalid items before save. +Press `m` for Proxy Profile management, then `n` to create a Profile, enter a name and proxy URL, and press Ctrl+S to save. The editor supports cursor movement, Home, End, Delete, Backspace, insertion, paste, and horizontal scrolling; Tab or the arrow keys move between fields, and Escape cancels without saving. Validation stays beside each field. The management page supports reviewing obscured connection details, editing, and safe deletion; Active Profile switching remains a separate dashboard picker workflow rather than a management action. Single mode applies one URL to HTTP, HTTPS, and ALL/SOCKS. Ctrl+T selects advanced mode for separate values without discarding values entered in either mode. A bare `host:port` is accepted and normalized to `http://`. The Single proxy URL field visibly offers Protocol Probe with Ctrl+D; it runs only on that explicit keypress, and its credential-safe progress and HTTP/SOCKS5 result stay with the initiating URL context rather than global status. When one endpoint supports both protocols, the probe switches the form to advanced mode, retains a read-only Single URL probe source row, and fills HTTP/HTTPS with `http://` and ALL/SOCKS with `socks5://`. Bypass accepts comma- or newline-delimited hostnames, domain suffixes, IPv4, IPv6, and CIDR entries; the editor previews normalized, deduplicated values and identifies invalid items before save. The dashboard shows the Active Profile. Press `p` to open the searchable Active Profile picker, type to filter, use arrows to select, and press Enter to activate; Escape cancels without changing the Active Profile. Use arrows or `j`/`k` to select an Available Target and Space to run its contextual action: Enable, Disable, Apply Profile, or Resolve. Drift Resolution explicitly offers Reapply Active Profile and Restore Original; restoration refuses safely if proxyctl no longer has sufficient ownership evidence. Press `r` to refresh detection, `u` to show unavailable Targets, and `?` on any page or dialog for contextual Help. Help scrolls with arrows, `j`/`k`, Page Up/Page Down, Home, and End; `q` or Escape closes it without changing the page, focus, selection, or viewport beneath it. Applying or reapplying requires an Active Profile, while Restore Original remains available without one. @@ -116,7 +116,7 @@ Inspect failures and rerun until reset reports no failed managed Target. Then re ## Privacy and safety boundaries -proxyctl has no telemetry, automatic update checks, or automatic proxy-connectivity tests. It does not contact the network on its own; the only network traffic it generates is the user-initiated Protocol Probe (Ctrl+D in the Profile form), a handshake check that never forwards traffic through the proxy and never contacts anything beyond the endpoint itself. Local diagnostic logs are credential-redacted and bounded in size. Proxy credentials remain present where a selected Target requires them, so normal local file security still matters. +proxyctl has no telemetry, automatic update checks, or automatic proxy-connectivity tests. It does not contact the network on its own; the only network traffic it generates is the user-initiated Protocol Probe (Ctrl+D in the Profile form), a handshake check that never forwards traffic through the proxy and never contacts anything beyond the endpoint itself. Local diagnostic logs are credential-redacted and bounded in size. Profile form frames mask the entire URL userinfo while retaining the underlying value for saving. Proxy credentials remain present where a selected Target requires them, so normal local file security still matters. proxyctl does **not** manage or supervise proxy processes such as Clash, sing-box, or Xray. It does not manage subscriptions, nodes, latency, routing rules, transparent proxying, TUN/iptables/nftables, arbitrary services or commands, plugins, or a complete headless Profile/Target CLI. diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 973e18f..5e7b1d7 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -271,6 +271,13 @@ func RedactText(value string) string { return credentialPattern.ReplaceAllString(value, `${1}${2}:REDACTED@`) } +// RedactUserinfoText removes usernames as well as passwords. Form frames use +// this stricter boundary because even the identity of the probe source is +// private while a Profile is being edited. +func RedactUserinfoText(value string) string { + return credentialPattern.ReplaceAllString(value, `${1}REDACTED@`) +} + func ObscureURL(value string) string { u, err := url.Parse(value) if err != nil || u.User == nil { @@ -283,9 +290,9 @@ func ObscureURL(value string) string { return u.String() } -// ObscureURLForEditing masks only password runes while preserving the input's +// ObscureURLForEditing masks every userinfo rune while preserving the input's // rune count. A text input clone can therefore retain its real cursor and -// horizontal viewport positions without ever rendering the credential. +// horizontal viewport positions without rendering either username or password. func ObscureURLForEditing(value string) string { matches := credentialPattern.FindAllStringSubmatchIndex(value, -1) if len(matches) == 0 { @@ -294,10 +301,10 @@ func ObscureURLForEditing(value string) string { var masked strings.Builder last := 0 for _, match := range matches { - passwordStart, passwordEnd := match[6], match[7] - masked.WriteString(value[last:passwordStart]) - masked.WriteString(strings.Repeat("•", len([]rune(value[passwordStart:passwordEnd])))) - last = passwordEnd + userinfoStart, userinfoEnd := match[4], match[7] + masked.WriteString(value[last:userinfoStart]) + masked.WriteString(strings.Repeat("•", len([]rune(value[userinfoStart:userinfoEnd])))) + last = userinfoEnd } masked.WriteString(value[last:]) return masked.String() diff --git a/internal/tui/foundation_test.go b/internal/tui/foundation_test.go index fc0fde0..8207e22 100644 --- a/internal/tui/foundation_test.go +++ b/internal/tui/foundation_test.go @@ -158,8 +158,14 @@ func TestCompleteModelDefensivelyRedactsEveryRenderedPage(t *testing.T) { if strings.Contains(frame, secret) { t.Fatalf("frame %d exposed a credential:\n%s", i, frame) } - if !strings.Contains(frame, redacted) { - t.Fatalf("frame %d did not apply the presentation redaction path:\n%s", i, frame) + if i < 3 { + if !strings.Contains(frame, redacted) { + t.Fatalf("frame %d did not apply the presentation redaction path:\n%s", i, frame) + } + continue + } + if strings.Contains(frame, "alice") || !strings.Contains(frame, "REDACTED@proxy.example:8443") { + t.Fatalf("form frame %d did not mask the entire userinfo:\n%s", i, frame) } } } diff --git a/internal/tui/help_dialog.go b/internal/tui/help_dialog.go index b63ebc6..f605f6e 100644 --- a/internal/tui/help_dialog.go +++ b/internal/tui/help_dialog.go @@ -149,8 +149,8 @@ func (p terminalPresentation) contextualHelp(model presentationState) string { default: body.WriteString("The Single proxy URL field visibly offers Protocol Probe; focus that field and press Ctrl+D to run it explicitly.\n") } - } else if model.form.probe.feedback != "" { - body.WriteString("The Protocol Probe result remains attached to the generated Advanced Profile URL values.\n") + } else if model.form.hasProbeSourceAttachment() { + body.WriteString("The Protocol Probe result remains attached to its read-only initiating Single proxy URL context. The HTTP, HTTPS, and ALL/SOCKS values were generated from that probe.\n") } default: body.WriteString(p.theme.section.Render("Dashboard")) diff --git a/internal/tui/help_dialog_test.go b/internal/tui/help_dialog_test.go index 521aec8..52f497a 100644 --- a/internal/tui/help_dialog_test.go +++ b/internal/tui/help_dialog_test.go @@ -1,6 +1,7 @@ package tui import ( + "context" "fmt" "strings" "testing" @@ -8,6 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "proxyctl/internal/app" + "proxyctl/internal/probe" "proxyctl/internal/profile" ) @@ -304,10 +306,6 @@ func TestCompleteModelFooterHintsMatchContextAndAcceptedKeys(t *testing.T) { if probeCommand == nil || !strings.Contains(model.View(), "Protocol Probe in progress") || strings.Contains(model.View(), "Ctrl+D: probe") { t.Fatalf("probe footer did not follow accepted in-field action and progress:\n%s", model.View()) } - model = updateKey(model, "?") - assertUsableFrame(t, model.View(), 80, 24, "Protocol Probe is in progress", "Ctrl+D does not start a duplicate probe") - model = updateKey(model, "esc") - model = updateKey(model, "ctrl+t") if strings.Contains(model.View(), "Ctrl+D: probe") || !strings.Contains(model.View(), "advanced mode") { t.Fatalf("Advanced form advertised a Single-field Protocol Probe:\n%s", model.View()) @@ -335,6 +333,37 @@ func TestCompleteModelFooterHintsMatchContextAndAcceptedKeys(t *testing.T) { }) } +func TestCompleteModelHelpDocumentsProbeShortcutExplicitOnlyAttachmentAndDuplicateSuppression(t *testing.T) { + model := openProtocolProbeEditor(t, &recordingModule{}, func(context.Context, string) (probe.Capabilities, error) { + return probe.Capabilities{HTTP: true, SOCKS5: true}, nil + }, "proxy.example:7890") + + model = updateKey(model, "?") + assertUsableFrame(t, model.View(), 80, 24, + "Ctrl+D explicitly runs Protocol Probe for the focused Single proxy URL field.", + "never runs on form entry, focus changes, or save.") + model = updateKey(model, "esc") + + model, command := startProtocolProbe(t, model) + updated, duplicate := model.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) + model = updated.(Model) + if duplicate != nil { + t.Fatal("repeated Probe shortcut emitted duplicate work") + } + model = updateKey(model, "?") + assertUsableFrame(t, model.View(), 80, 24, + "Protocol Probe is in progress for the Single proxy URL field.", + "Ctrl+D does not start a duplicate probe") + model = updateKey(model, "esc") + + model = finishProtocolProbe(t, model, command) + model = updateKey(model, "?") + assertUsableFrame(t, model.View(), 80, 24, + "The Protocol Probe result remains attached to its read-only initiating Single", + "proxy URL context. The HTTP, HTTPS, and ALL/SOCKS values were generated from", + "that probe.") +} + func TestCompleteModelHelpOverEditorPreservesFieldCursorPreviewAndResizeState(t *testing.T) { module := &profileManagementModule{} model := openNewProfileEditor(t, module) diff --git a/internal/tui/model.go b/internal/tui/model.go index 3a1e39a..1c284d3 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2,9 +2,7 @@ package tui import ( "context" - "errors" "fmt" - "strings" tea "github.com/charmbracelet/bubbletea" @@ -47,14 +45,12 @@ type Model struct { detect func(context.Context, string) (probe.Capabilities, error) } -// probeDone carries one finished Protocol Probe back to the form. Endpoint and -// input text deliberately stay in formState: an asynchronous result cannot -// introduce userinfo into status, and formID plus generation reject stale work. +// probeDone carries only detector output and formState's opaque typed token. +// Source data and all validation/application rules remain owned by formState. type probeDone struct { - caps probe.Capabilities - err error - formID uint64 - generation uint64 + caps probe.Capabilities + err error + token probeToken } type profileDeleteConfirmation struct { @@ -177,57 +173,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { return m, waitForOperation(progress.updates) } if done, ok := message.(probeDone); ok { - if m.page != formPage || !m.form.probe.active || done.formID != m.form.id || done.generation != m.form.probe.generation { - return m, nil - } - m.form.probe.active = false - address := m.form.probe.address - switch { - case done.err != nil: - // Detector errors are intentionally not rendered. Production errors are - // based on the credential-free dial endpoint, but this boundary also - // protects frames if a lower layer returns hostile credential text. - m.form.probe.feedback = "Protocol Probe failed for " + address - case !done.caps.HTTP && !done.caps.SOCKS5: - m.form.probe.feedback = "Protocol Probe: unrecognized protocol at " + address - default: - raw := m.form.profile.SingleURL - if done.caps.HTTP && done.caps.SOCKS5 { - httpURL, err := probe.URL(raw, probe.Capabilities{HTTP: true}) - if err != nil { - m.form.probe.feedback = "Protocol Probe could not apply the detected capabilities" - return m, nil - } - socksURL, err := probe.URL(raw, probe.Capabilities{SOCKS5: true}) - if err != nil { - m.form.probe.feedback = "Protocol Probe could not apply the detected capabilities" - return m, nil - } - m.form.profile.Mode = profile.AdvancedMode - m.form.profile.HTTPURL = httpURL - m.form.profile.HTTPSURL = httpURL - m.form.profile.AllURL = strings.Replace(socksURL, "socks5h://", "socks5://", 1) - m.form.advancedInitialized = true - m.form.probe.field = profile.HTTPURLField - m.form.probe.feedback = "Protocol Probe: detected HTTP and SOCKS5 proxy at " + address - m.form.rebuildInputs(1) - return m, nil - } - rewritten, err := probe.URL(raw, done.caps) - if err != nil { - m.form.probe.feedback = "Protocol Probe could not apply the detected capabilities" - return m, nil - } - m.form.profile.SingleURL = rewritten - m.form.probe.field = profile.SingleURLField - kind := "SOCKS5" - if done.caps.HTTP { - kind = "HTTP" - } - m.form.probe.feedback = "Protocol Probe: detected " + kind + " proxy at " + address - m.form.rebuildInputs(1) - } - return m, nil + return m, m.form.applyProbeResult(done.token, done.caps, done.err) } key, ok := message.(tea.KeyMsg) if !ok { @@ -596,26 +542,14 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) { m.form = formState{} return m, nil case tea.KeyCtrlD: - if m.form.profile.Mode != profile.SingleMode || m.form.currentField() != profile.SingleURLField || m.form.probe.active { + address, token, started := m.form.startProbe() + if !started { return m, nil } - m.form.syncFocusedValue() - m.form.invalidateProbe() - m.form.probe.field = profile.SingleURLField - address, err := probeAddress(m.form.profile.SingleURL) - if err != nil { - m.form.probe.feedback = "Protocol Probe: " + err.Error() - return m, nil - } - m.form.probe.active = true - m.form.probe.address = address - m.form.probe.feedback = "Protocol Probe: checking " + address + "…" detect := m.detect - formID := m.form.id - generation := m.form.probe.generation return m, func() tea.Msg { caps, err := detect(context.Background(), address) - return probeDone{caps: caps, err: err, formID: formID, generation: generation} + return probeDone{caps: caps, err: err, token: token} } case tea.KeyCtrlT: return m, m.form.toggleMode() @@ -653,17 +587,6 @@ func (m *Model) applyProfileMutation(intent app.Intent, preferredName string) ap return result } -// probeAddress extracts a bare host:port from the typed endpoint for dialing -// and status text, dropping any userinfo so credentials are never displayed -// or sent anywhere. -func probeAddress(raw string) (string, error) { - u, err := profile.ParseProxyURL(raw) - if err != nil { - return "", errors.New("Single proxy URL must include host:port") - } - return u.Host, nil -} - func (m Model) selectedProfile() (profile.Profile, bool) { if m.selectedProfileName != "" { for i := range m.snapshot.Profiles { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 335bf2e..41135d1 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -1,15 +1,12 @@ package tui import ( - "context" - "errors" "strings" "testing" tea "github.com/charmbracelet/bubbletea" "proxyctl/internal/app" - "proxyctl/internal/probe" "proxyctl/internal/profile" ) @@ -138,349 +135,6 @@ func TestNoProfileStateStaysOnAccessibleMainPage(t *testing.T) { } } -func TestProtocolProbeDetectsHTTPAndRewritesSingleURL(t *testing.T) { - module := &recordingModule{} - model := NewModel(module) - var probed []string - model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { - probed = append(probed, address) - return probe.Capabilities{HTTP: true}, nil - } - model = updateKey(model, "m") - model = updateKey(model, "n") - model = updateRunes(model, "home") - model = updateKey(model, "tab") - model = updateRunes(model, "alice:secret@localhost:8888") - - model, cmd := pressCtrlD(model) - if cmd == nil { - t.Fatal("ctrl+d did not start a Protocol Probe") - } - updated, _ := model.Update(cmd()) - model = updated.(Model) - - if len(probed) != 1 || probed[0] != "localhost:8888" { - t.Fatalf("Protocol Probe dialed %#v, want exactly localhost:8888 without userinfo", probed) - } - if got := model.form.profile.SingleURL; got != "http://alice:secret@localhost:8888" { - t.Fatalf("Single URL after probe = %q, want detected scheme with preserved userinfo", got) - } - view := model.View() - if !strings.Contains(view, "Protocol Probe: detected HTTP proxy at localhost:8888") { - t.Fatalf("form view missing detection status:\n%s", view) - } - if strings.Contains(view, "secret") { - t.Fatalf("form view exposed proxy password:\n%s", view) - } -} - -func pressCtrlD(model Model) (Model, tea.Cmd) { - updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) - return updated.(Model), cmd -} - -func TestProtocolProbeFailureLeavesFieldUntouchedAndSavePossible(t *testing.T) { - module := &recordingModule{} - model := NewModel(module) - model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { - return probe.Capabilities{}, errors.New("connection refused") - } - model = updateKey(model, "m") - model = updateKey(model, "n") - model = updateRunes(model, "home") - model = updateKey(model, "tab") - model = updateRunes(model, "localhost:8888") - - model, cmd := pressCtrlD(model) - if cmd == nil { - t.Fatal("ctrl+d did not start a Protocol Probe") - } - updated, _ := model.Update(cmd()) - model = updated.(Model) - - if got := model.form.profile.SingleURL; got != "localhost:8888" { - t.Fatalf("failed probe rewrote Single URL to %q", got) - } - if view := model.View(); !strings.Contains(view, "Protocol Probe failed for localhost:8888") || strings.Contains(view, "connection refused") { - t.Fatalf("form view missing credential-safe field failure status:\n%s", view) - } - model = updateKey(model, "ctrl+s") - if len(module.intents) != 1 || module.intents[0].Kind != app.CreateProfile { - t.Fatalf("save after failed probe dispatched %#v", module.intents) - } -} - -func TestProtocolProbeUnrecognizedProtocolLeavesFieldUntouched(t *testing.T) { - module := &recordingModule{} - model := NewModel(module) - model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { - return probe.Capabilities{}, nil - } - model = updateKey(model, "m") - model = updateKey(model, "n") - model = updateRunes(model, "home") - model = updateKey(model, "tab") - model = updateRunes(model, "localhost:8888") - - model, cmd := pressCtrlD(model) - if cmd == nil { - t.Fatal("ctrl+d did not start a Protocol Probe") - } - updated, _ := model.Update(cmd()) - model = updated.(Model) - - if got := model.form.profile.SingleURL; got != "localhost:8888" { - t.Fatalf("unrecognized probe rewrote Single URL to %q", got) - } - if view := model.View(); !strings.Contains(view, "Protocol Probe: unrecognized protocol at localhost:8888") { - t.Fatalf("form view missing unrecognized status:\n%s", view) - } -} - -func TestProtocolProbeIgnoresRepeatedCtrlDWhileInFlight(t *testing.T) { - module := &recordingModule{} - model := NewModel(module) - calls := 0 - model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { - calls++ - return probe.Capabilities{HTTP: true}, nil - } - model = updateKey(model, "m") - model = updateKey(model, "n") - model = updateRunes(model, "home") - model = updateKey(model, "tab") - model = updateRunes(model, "localhost:8888") - - model, first := pressCtrlD(model) - if first == nil { - t.Fatal("ctrl+d did not start a Protocol Probe") - } - model, second := pressCtrlD(model) - if second != nil { - t.Fatal("ctrl+d during an in-flight probe started a second one") - } - updated, _ := model.Update(first()) - model = updated.(Model) - - if calls != 1 { - t.Fatalf("probe ran %d times, want 1", calls) - } - if got := model.form.profile.SingleURL; got != "http://localhost:8888" { - t.Fatalf("Single URL after probe = %q", got) - } -} - -func TestProtocolProbeLateResultIgnoredAfterEscLeavesForm(t *testing.T) { - module := &recordingModule{} - model := NewModel(module) - model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { - return probe.Capabilities{HTTP: true}, nil - } - model = updateKey(model, "m") - model = updateKey(model, "n") - model = updateRunes(model, "home") - model = updateKey(model, "tab") - model = updateRunes(model, "localhost:8888") - - model, cmd := pressCtrlD(model) - if cmd == nil { - t.Fatal("ctrl+d did not start a Protocol Probe") - } - model = updateKey(model, "esc") - updated, _ := model.Update(cmd()) - model = updated.(Model) - - if model.page != profilesPage { - t.Fatalf("page = %d, want profilesPage", model.page) - } - if strings.Contains(model.status, "detected HTTP proxy") { - t.Fatalf("profiles page status mutated by late probe result: %s", model.status) - } - if model.form.profile.SingleURL != "" { - t.Fatalf("abandoned form SingleURL mutated to %q", model.form.profile.SingleURL) - } -} - -func TestProtocolProbeLateResultIgnoredAfterSaveLeavesForm(t *testing.T) { - module := &recordingModule{} - model := NewModel(module) - model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { - return probe.Capabilities{HTTP: true}, nil - } - model = updateKey(model, "m") - model = updateKey(model, "n") - model = updateRunes(model, "home") - model = updateKey(model, "tab") - model = updateRunes(model, "localhost:8888") - - model, cmd := pressCtrlD(model) - if cmd == nil { - t.Fatal("ctrl+d did not start a Protocol Probe") - } - model = updateKey(model, "ctrl+s") - if len(module.intents) != 1 { - t.Fatalf("save dispatched %d intents, want 1", len(module.intents)) - } - - updated, _ := model.Update(cmd()) - model = updated.(Model) - - if model.page != profilesPage { - t.Fatalf("page = %d, want profilesPage", model.page) - } - if strings.Contains(model.status, "detected HTTP proxy") { - t.Fatalf("profiles page status mutated by late probe result: %s", model.status) - } - if model.form.profile.SingleURL != "" { - t.Fatalf("abandoned form SingleURL mutated to %q", model.form.profile.SingleURL) - } -} - -func TestSaveFormDoesNotTriggerProtocolProbe(t *testing.T) { - module := &recordingModule{} - model := NewModel(module) - calls := 0 - model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { - calls++ - return probe.Capabilities{HTTP: true}, nil - } - model = updateKey(model, "m") - model = updateKey(model, "n") - model = updateRunes(model, "home") - model = updateKey(model, "tab") - model = updateRunes(model, "localhost:8888") - - model = updateKey(model, "ctrl+s") - if calls != 0 { - t.Fatalf("ctrl+s triggered %d probe call(s), want 0", calls) - } - if len(module.intents) != 1 || module.intents[0].Kind != app.CreateProfile { - t.Fatalf("ctrl+s dispatched %#v", module.intents) - } -} - -func TestProtocolProbeOnlyTriggersOnSingleURLField(t *testing.T) { - module := &recordingModule{} - model := NewModel(module) - calls := 0 - model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { - calls++ - return probe.Capabilities{HTTP: true}, nil - } - model = updateKey(model, "m") - model = updateKey(model, "n") - model = updateRunes(model, "home") - - // Field 0 is Name. - model, cmd := pressCtrlD(model) - if cmd != nil { - t.Fatal("ctrl+d on Name field started a Protocol Probe") - } - - // Move to Single URL and probe once so we can reach the Bypass field. - model = updateKey(model, "tab") - model = updateRunes(model, "localhost:8888") - model, cmd = pressCtrlD(model) - if cmd == nil { - t.Fatal("ctrl+d on Single URL field did not start a Protocol Probe") - } - updated, _ := model.Update(cmd()) - model = updated.(Model) - - // Move to Bypass (field 2) and press Ctrl+D again. - model = updateKey(model, "tab") - model, cmd = pressCtrlD(model) - if cmd != nil { - t.Fatal("ctrl+d on Bypass field started a Protocol Probe") - } - - if calls != 1 { - t.Fatalf("Protocol Probe ran %d times, want 1", calls) - } -} - -func TestProtocolProbeDetectsDualProtocolEndpointBuildsCompatibleAdvancedProfile(t *testing.T) { - module := &recordingModule{} - model := NewModel(module) - model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { - return probe.Capabilities{HTTP: true, SOCKS5: true}, nil - } - model = updateKey(model, "m") - model = updateKey(model, "n") - model = updateRunes(model, "home") - model = updateKey(model, "tab") - model = updateRunes(model, "localhost:7890") - - model, cmd := pressCtrlD(model) - if cmd == nil { - t.Fatal("ctrl+d did not start a Protocol Probe") - } - updated, _ := model.Update(cmd()) - model = updated.(Model) - - got := model.form.profile - if got.Mode != profile.AdvancedMode || got.HTTPURL != "http://localhost:7890" || got.HTTPSURL != "http://localhost:7890" || got.AllURL != "socks5://localhost:7890" { - t.Fatalf("dual-protocol Profile = %#v", got) - } - if view := model.View(); !strings.Contains(view, "Protocol Probe: detected HTTP and SOCKS5 proxy at localhost:7890") { - t.Fatalf("form view missing dual-protocol detection status:\n%s", view) - } -} - -func TestProtocolProbeDetectsSOCKS5AndRewritesSingleURL(t *testing.T) { - module := &recordingModule{} - model := NewModel(module) - model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { - return probe.Capabilities{SOCKS5: true}, nil - } - model = updateKey(model, "m") - model = updateKey(model, "n") - model = updateRunes(model, "home") - model = updateKey(model, "tab") - model = updateRunes(model, "localhost:8888") - - model, cmd := pressCtrlD(model) - if cmd == nil { - t.Fatal("ctrl+d did not start a Protocol Probe") - } - updated, _ := model.Update(cmd()) - model = updated.(Model) - - if got := model.form.profile.SingleURL; got != "socks5h://localhost:8888" { - t.Fatalf("Single URL after SOCKS5 probe = %q, want socks5h://localhost:8888", got) - } - if view := model.View(); !strings.Contains(view, "Protocol Probe: detected SOCKS5 proxy at localhost:8888") { - t.Fatalf("form view missing SOCKS5 detection status:\n%s", view) - } -} - -func TestProtocolProbeInAdvancedModeShowsHintWithoutProbing(t *testing.T) { - module := &recordingModule{} - model := NewModel(module) - calls := 0 - model.detect = func(_ context.Context, address string) (probe.Capabilities, error) { - calls++ - return probe.Capabilities{HTTP: true}, nil - } - model = updateKey(model, "m") - model = updateKey(model, "n") - model = updateRunes(model, "home") - model = updateKey(model, "ctrl+t") - model = updateKey(model, "tab") - model = updateRunes(model, "localhost:8888") - - model, cmd := pressCtrlD(model) - if cmd != nil { - t.Fatal("ctrl+d in Advanced mode started a Protocol Probe") - } - if calls != 0 { - t.Fatalf("Advanced mode probe ran %d times, want 0", calls) - } - if view := model.View(); strings.Contains(view, "Protocol Probe") || strings.Contains(view, "Ctrl+D") { - t.Fatalf("Advanced mode advertised an inapplicable Protocol Probe action:\n%s", view) - } -} - func updateKey(model Model, key string) Model { msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)} special := map[string]tea.KeyType{ diff --git a/internal/tui/pages.go b/internal/tui/pages.go index 0853485..c4466a8 100644 --- a/internal/tui/pages.go +++ b/internal/tui/pages.go @@ -238,6 +238,12 @@ func (p terminalPresentation) formView(model presentationState) terminalPage { fmt.Fprintf(&fields, "\n %s", probeText) currentLine++ } + if item.field == profile.NameField && model.form.hasProbeSourceAttachment() { + fmt.Fprintf(&fields, "\n Single proxy URL probe [read-only]: %s", profile.ObscureURLForEditing(model.form.probe.sourceValue)) + fmt.Fprintf(&fields, "\n %s", model.form.probe.feedback) + fields.WriteString("\n Generated Advanced URL fields from this Single proxy URL probe:") + currentLine += 3 + } validation := model.form.validationText(i) if item.field == profile.NameField { if conflict := model.form.nameConflict(model.snapshot.Profiles); conflict != "" { diff --git a/internal/tui/presentation.go b/internal/tui/presentation.go index dc644a6..1d9f3ac 100644 --- a/internal/tui/presentation.go +++ b/internal/tui/presentation.go @@ -146,8 +146,13 @@ func (p terminalPresentation) Render(state presentationState) string { page = redactTerminalPage(page) // Dashboard composition retains structured Target metadata until pane sizes // are known. Redact the fully composed frame as the final presentation - // boundary as well, so no render path can bypass credential scrubbing. - return profile.RedactText(p.renderShell(page, width, height)) + // boundary as well, so no render path can bypass credential scrubbing. Form + // frames use the stricter boundary that removes the entire userinfo. + frame := p.renderShell(page, width, height) + if state.page == formPage { + return profile.RedactUserinfoText(frame) + } + return profile.RedactText(frame) } func renderStatus(state presentationState) string { diff --git a/internal/tui/profile_editor_test.go b/internal/tui/profile_editor_test.go index 64d82e7..2603506 100644 --- a/internal/tui/profile_editor_test.go +++ b/internal/tui/profile_editor_test.go @@ -55,26 +55,34 @@ func TestCompleteModelProfileEditorSupportsCursorEditingDeletionAndPaste(t *test } } -func TestCompleteModelProfileEditorHorizontallyScrollsLongSecretURLWithoutDisclosure(t *testing.T) { +func TestCompleteModelProfileEditorMasksEntireUserinfoWhilePreservingScrollAndSavedValue(t *testing.T) { module := &profileManagementModule{} model := openNewProfileEditor(t, module) model = updateRunes(model, "office") model = updateKey(model, "tab") + const username = "operator-private" const secret = "credential-never-rendered" - const raw = "https://operator:" + secret + "@this-is-a-very-long-proxy-hostname-for-horizontal-scroll.example:8443" + const raw = "https://" + username + ":" + secret + "@this-is-a-very-long-proxy-hostname-for-horizontal-scroll.example:8443" model = updateRunes(model, raw) end := model.View() assertUsableFrame(t, end, 80, 24, "[focused]", "scroll.example:8443") - if strings.Contains(end, secret) || strings.Contains(end, "https://operator") { - t.Fatalf("end-scrolled input disclosed credentials or failed to scroll:\n%s", end) + for _, literal := range []string{username, secret} { + if strings.Contains(end, literal) { + t.Fatalf("end-scrolled input disclosed userinfo %q:\n%s", literal, end) + } } model = editorKey(model, tea.KeyHome) start := model.View() - assertUsableFrame(t, start, 80, 24, "https://operator:") - if strings.Contains(start, secret) || strings.Contains(start, "scroll.example:8443") { - t.Fatalf("home did not scroll to the safe beginning of the input:\n%s", start) + assertUsableFrame(t, start, 80, 24, "https://") + if strings.Contains(start, username) || strings.Contains(start, secret) || strings.Contains(start, "scroll.example:8443") { + t.Fatalf("home did not scroll to the masked beginning of the input:\n%s", start) + } + + model = updateKey(model, "ctrl+s") + if len(module.intents) != 1 || module.intents[0].Profile.SingleURL != raw { + t.Fatalf("editable masking changed saved Profile semantics: %#v", module.intents) } } diff --git a/internal/tui/profile_form.go b/internal/tui/profile_form.go index 6fbe682..645d1cb 100644 --- a/internal/tui/profile_form.go +++ b/internal/tui/profile_form.go @@ -1,12 +1,14 @@ package tui import ( + "errors" "fmt" "strings" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "proxyctl/internal/probe" "proxyctl/internal/profile" ) @@ -22,12 +24,19 @@ type formDefinition struct { value string } -type protocolProbeFieldState struct { - active bool +type probeToken struct { + formID uint64 generation uint64 - address string - feedback string - field profile.Field + source profile.Field +} + +type protocolProbeFieldState struct { + active bool + token probeToken + address string + feedback string + sourceValue string + sourceAttached bool } type formState struct { @@ -39,6 +48,7 @@ type formState struct { bypassPreviewLine int validation profile.Validation advancedInitialized bool + probeGeneration uint64 probe protocolProbeFieldState } @@ -136,7 +146,7 @@ func (f *formState) updateInput(key tea.KeyMsg) tea.Cmd { f.bypassPreviewLine = 0 changedField := f.inputs[f.field].field f.syncFocusedValue() - if changedField == profile.SingleURLField { + if isProxyURLField(changedField) { f.invalidateProbe() } f.refreshValidation() @@ -195,12 +205,106 @@ func (f formState) currentField() profile.Field { return f.inputs[f.field].field } +func isProxyURLField(field profile.Field) bool { + switch field { + case profile.SingleURLField, profile.HTTPURLField, profile.HTTPSURLField, profile.AllURLField: + return true + default: + return false + } +} + func (f *formState) invalidateProbe() { - f.probe.generation++ + f.probeGeneration++ + f.probe = protocolProbeFieldState{} +} + +// startProbe owns applicability, duplicate suppression, source capture, and +// token issuance so callers cannot accidentally start work outside formState's +// stale-result rules. +func (f *formState) startProbe() (string, probeToken, bool) { + if f.profile.Mode != profile.SingleMode || f.currentField() != profile.SingleURLField || f.probe.active { + return "", probeToken{}, false + } + f.syncFocusedValue() + f.invalidateProbe() + token := probeToken{formID: f.id, generation: f.probeGeneration, source: profile.SingleURLField} + f.probe.token = token + f.probe.sourceValue = f.profile.SingleURL + address, err := probeAddress(f.probe.sourceValue) + if err != nil { + f.probe.feedback = "Protocol Probe: " + err.Error() + return "", probeToken{}, false + } + f.probe.active = true + f.probe.address = address + f.probe.feedback = "Protocol Probe: checking " + address + "…" + return address, token, true +} + +// applyProbeResult validates and applies a completion as one form-owned +// operation. Every stale-result invariant stays here: form identity, +// generation, source field, active work, mode, and unchanged source value. +func (f *formState) applyProbeResult(token probeToken, caps probe.Capabilities, resultErr error) tea.Cmd { + if !f.probe.active || token != f.probe.token || token.formID != f.id || + token.source != profile.SingleURLField || f.profile.Mode != profile.SingleMode || + f.profile.SingleURL != f.probe.sourceValue { + return nil + } f.probe.active = false - f.probe.address = "" - f.probe.feedback = "" - f.probe.field = "" + address, raw := f.probe.address, f.probe.sourceValue + switch { + case resultErr != nil: + f.probe.feedback = "Protocol Probe failed for " + address + case !caps.HTTP && !caps.SOCKS5: + f.probe.feedback = "Protocol Probe: unrecognized protocol at " + address + case caps.HTTP && caps.SOCKS5: + httpURL, err := probe.URL(raw, probe.Capabilities{HTTP: true}) + if err != nil { + f.probe.feedback = "Protocol Probe could not apply the detected capabilities" + return nil + } + socksURL, err := probe.URL(raw, probe.Capabilities{SOCKS5: true}) + if err != nil { + f.probe.feedback = "Protocol Probe could not apply the detected capabilities" + return nil + } + f.profile.Mode = profile.AdvancedMode + f.profile.HTTPURL = httpURL + f.profile.HTTPSURL = httpURL + f.profile.AllURL = strings.Replace(socksURL, "socks5h://", "socks5://", 1) + f.advancedInitialized = true + f.probe.feedback = "Protocol Probe: detected HTTP and SOCKS5 proxy at " + address + f.probe.sourceAttached = true + return f.rebuildInputs(1) + default: + rewritten, err := probe.URL(raw, caps) + if err != nil { + f.probe.feedback = "Protocol Probe could not apply the detected capabilities" + return nil + } + f.profile.SingleURL = rewritten + f.setInputValue(profile.SingleURLField, rewritten) + kind := "SOCKS5" + if caps.HTTP { + kind = "HTTP" + } + f.probe.feedback = "Protocol Probe: detected " + kind + " proxy at " + address + } + f.refreshValidation() + return nil +} + +func (f *formState) setInputValue(field profile.Field, value string) { + for i := range f.inputs { + if f.inputs[i].field != field { + continue + } + position := f.inputs[i].input.Position() + f.inputs[i].input.SetValue(value) + f.inputs[i].input.SetCursor(position) + return + } } func (f formState) probeText(field profile.Field) string { @@ -210,12 +314,22 @@ func (f formState) probeText(field profile.Field) string { } return "Protocol Probe [Ctrl+D]: available" } - if field == f.probe.field { - return f.probe.feedback - } return "" } +func (f formState) hasProbeSourceAttachment() bool { + return f.profile.Mode == profile.AdvancedMode && f.probe.sourceAttached && + f.probe.token.source == profile.SingleURLField && f.probe.feedback != "" +} + +func probeAddress(raw string) (string, error) { + u, err := profile.ParseProxyURL(raw) + if err != nil { + return "", errors.New("Single proxy URL must include host:port") + } + return u.Host, nil +} + func (f *formState) scrollBypassPreview(delta int) { if f.currentField() != profile.BypassField { return diff --git a/internal/tui/protocol_probe_field_action_test.go b/internal/tui/protocol_probe_field_action_test.go index af6e686..5cbdb85 100644 --- a/internal/tui/protocol_probe_field_action_test.go +++ b/internal/tui/protocol_probe_field_action_test.go @@ -105,6 +105,49 @@ func TestCompleteModelProtocolProbeNeverRunsWithoutExplicitInvocation(t *testing } } +func TestCompleteModelProtocolProbeCompletionPreservesAnotherEditedFieldsFocusCursorAndViewport(t *testing.T) { + module := &recordingModule{} + model := openProtocolProbeEditor(t, module, func(context.Context, string) (probe.Capabilities, error) { + return probe.Capabilities{HTTP: true}, nil + }, "proxy.example:8080") + model, command := startProtocolProbe(t, model) + + model = updateKey(model, "shift+tab") + model = editorKey(model, tea.KeyHome) + model = updateRunes(model, "a-very-long-prefix-that-forces-the-name-field-to-scroll-horizontally-") + model = editorKey(model, tea.KeyLeft) + before := model.View() + assertUsableFrame(t, before, 80, 24, "> Name [focused]", "Protocol Probe: checking proxy.example:8080") + + model = finishProtocolProbe(t, model, command) + after := model.View() + assertUsableFrame(t, after, 80, 24, "> Name [focused]", "Protocol Probe: detected HTTP proxy at proxy.example:8080") + if fieldLine(before, "> Name [focused]") != fieldLine(after, "> Name [focused]") { + t.Fatalf("non-mode-changing probe completion reset the edited field viewport or cursor:\nbefore:\n%s\nafter:\n%s", before, after) + } + + model = updateRunes(model, "X") + model = updateKey(model, "ctrl+s") + if len(module.intents) != 1 { + t.Fatalf("save after asynchronous completion dispatched %#v", module.intents) + } + if got, want := module.intents[0].Profile.Name, "a-very-long-prefix-that-forces-the-name-field-to-scroll-horizontallyX-office"; got != want { + t.Fatalf("focus/cursor was not preserved: saved name = %q, want %q", got, want) + } + if got := module.intents[0].Profile.SingleURL; got != "http://proxy.example:8080" { + t.Fatalf("probe did not update its affected URL input: %q", got) + } +} + +func fieldLine(frame, label string) string { + for _, line := range strings.Split(frame, "\n") { + if strings.Contains(line, label) { + return line + } + } + return "" +} + func TestCompleteModelProtocolProbePopulatesEstablishedCapabilityRepresentations(t *testing.T) { tests := []struct { name string @@ -143,12 +186,12 @@ func TestCompleteModelProtocolProbePopulatesEstablishedCapabilityRepresentations } attachedField := "Single proxy URL" if test.caps.HTTP && test.caps.SOCKS5 { - attachedField = "HTTP URL" + attachedField = "Single proxy URL probe [read-only]" } field := strings.Index(view, attachedField) result := strings.Index(view, "Protocol Probe: detected") if field < 0 || result < field || strings.Count(view[field:result], "\n") > 1 { - t.Fatalf("capability result was not attached to its populated URL field:\n%s", view) + t.Fatalf("capability result was not attached to its initiating URL context:\n%s", view) } model = updateKey(model, "ctrl+s") @@ -163,6 +206,27 @@ func TestCompleteModelProtocolProbePopulatesEstablishedCapabilityRepresentations } } +func TestCompleteModelProtocolProbeDualResultRetainsSingleSourceAndLinksGeneratedFields(t *testing.T) { + model := openProtocolProbeEditor(t, &recordingModule{}, func(context.Context, string) (probe.Capabilities, error) { + return probe.Capabilities{HTTP: true, SOCKS5: true}, nil + }, "proxy.example:7890") + model, command := startProtocolProbe(t, model) + model = finishProtocolProbe(t, model, command) + view := model.View() + assertUsableFrame(t, view, 80, 24, + "Create Proxy Profile — advanced mode", + "Single proxy URL probe [read-only]: proxy.example:7890", + "Protocol Probe: detected HTTP and SOCKS5 proxy at proxy.example:7890", + "Generated Advanced URL fields from this Single proxy URL probe:", + "HTTP URL", "HTTPS URL", "ALL/SOCKS URL") + if strings.Index(view, "Single proxy URL probe [read-only]") > strings.Index(view, "HTTP URL") { + t.Fatalf("initiating Single URL context was not visibly linked before generated fields:\n%s", view) + } + if strings.Contains(fieldLine(view, "HTTP URL"), "Protocol Probe") { + t.Fatalf("dual result was silently relabelled as an HTTP-field probe:\n%s", view) + } +} + func TestCompleteModelProtocolProbeFailureAndUnrecognizedResultsRemainEditableAndSavable(t *testing.T) { tests := []struct { name string @@ -338,33 +402,43 @@ func TestCompleteModelProtocolProbeSurvivesResizeWithoutRestartingOrDetaching(t } } -func TestCompleteModelProtocolProbeKeepsCredentialsOutOfDialStatusErrorsAndFrames(t *testing.T) { +func TestCompleteModelProtocolProbeKeepsAllUserinfoOutOfDialStatusErrorsAndEveryFormFrame(t *testing.T) { const ( + inputUser = "alice-private" inputSecret = "input-secret" + errorUser = "mallory-private" errorSecret = "error-secret" ) var dialed []string - model := openProtocolProbeEditor(t, &recordingModule{}, func(_ context.Context, address string) (probe.Capabilities, error) { + module := &recordingModule{} + model := openProtocolProbeEditor(t, module, func(_ context.Context, address string) (probe.Capabilities, error) { dialed = append(dialed, address) - return probe.Capabilities{}, errors.New("failed https://mallory:" + errorSecret + "@evil.example:9999") - }, "alice:"+inputSecret+"@proxy.example:8080") + return probe.Capabilities{}, errors.New("failed https://" + errorUser + ":" + errorSecret + "@evil.example:9999") + }, inputUser+":"+inputSecret+"@proxy.example:8080") + frames := []string{model.View()} model, command := startProtocolProbe(t, model) - progress := model.View() - if strings.Contains(progress, inputSecret) { - t.Fatalf("probe progress exposed credentials:\n%s", progress) - } - for _, line := range strings.Split(progress, "\n") { - if strings.Contains(line, "Protocol Probe") && strings.Contains(line, "alice") { - t.Fatalf("probe-generated progress exposed userinfo:\n%s", progress) - } - } + frames = append(frames, model.View()) model = finishProtocolProbe(t, model, command) - view := model.View() + frames = append(frames, model.View()) + model = resize(model, 60, 16) + frames = append(frames, model.View()) + model = resize(model, 80, 24) + frames = append(frames, model.View()) + if len(dialed) != 1 || dialed[0] != "proxy.example:8080" { t.Fatalf("probe dial evidence = %#v, want credential-free endpoint", dialed) } - if strings.Contains(view, inputSecret) || strings.Contains(view, errorSecret) || strings.Contains(view, "mallory") { - t.Fatalf("probe frame exposed input or lower-layer credentials:\n%s", view) + for i, frame := range frames { + for _, literal := range []string{inputUser, inputSecret, errorUser, errorSecret} { + if strings.Contains(frame, literal) { + t.Fatalf("form frame %d exposed userinfo literal %q:\n%s", i, literal, frame) + } + } + } + assertUsableFrame(t, frames[len(frames)-1], 80, 24, "Protocol Probe failed for proxy.example:8080") + + model = updateKey(model, "ctrl+s") + if len(module.intents) != 1 || module.intents[0].Profile.SingleURL != "http://"+inputUser+":"+inputSecret+"@proxy.example:8080" { + t.Fatalf("userinfo masking weakened saved Profile semantics: %#v", module.intents) } - assertUsableFrame(t, view, 80, 24, "Protocol Probe failed for proxy.example:8080") }