fix: address protocol probe review findings
This commit is contained in:
@@ -48,7 +48,7 @@ Start the Target panel with no arguments:
|
|||||||
proxyctl
|
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.
|
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
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -271,6 +271,13 @@ func RedactText(value string) string {
|
|||||||
return credentialPattern.ReplaceAllString(value, `${1}${2}:REDACTED@`)
|
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 {
|
func ObscureURL(value string) string {
|
||||||
u, err := url.Parse(value)
|
u, err := url.Parse(value)
|
||||||
if err != nil || u.User == nil {
|
if err != nil || u.User == nil {
|
||||||
@@ -283,9 +290,9 @@ func ObscureURL(value string) string {
|
|||||||
return u.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
|
// 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 {
|
func ObscureURLForEditing(value string) string {
|
||||||
matches := credentialPattern.FindAllStringSubmatchIndex(value, -1)
|
matches := credentialPattern.FindAllStringSubmatchIndex(value, -1)
|
||||||
if len(matches) == 0 {
|
if len(matches) == 0 {
|
||||||
@@ -294,10 +301,10 @@ func ObscureURLForEditing(value string) string {
|
|||||||
var masked strings.Builder
|
var masked strings.Builder
|
||||||
last := 0
|
last := 0
|
||||||
for _, match := range matches {
|
for _, match := range matches {
|
||||||
passwordStart, passwordEnd := match[6], match[7]
|
userinfoStart, userinfoEnd := match[4], match[7]
|
||||||
masked.WriteString(value[last:passwordStart])
|
masked.WriteString(value[last:userinfoStart])
|
||||||
masked.WriteString(strings.Repeat("•", len([]rune(value[passwordStart:passwordEnd]))))
|
masked.WriteString(strings.Repeat("•", len([]rune(value[userinfoStart:userinfoEnd]))))
|
||||||
last = passwordEnd
|
last = userinfoEnd
|
||||||
}
|
}
|
||||||
masked.WriteString(value[last:])
|
masked.WriteString(value[last:])
|
||||||
return masked.String()
|
return masked.String()
|
||||||
|
|||||||
@@ -158,8 +158,14 @@ func TestCompleteModelDefensivelyRedactsEveryRenderedPage(t *testing.T) {
|
|||||||
if strings.Contains(frame, secret) {
|
if strings.Contains(frame, secret) {
|
||||||
t.Fatalf("frame %d exposed a credential:\n%s", i, frame)
|
t.Fatalf("frame %d exposed a credential:\n%s", i, frame)
|
||||||
}
|
}
|
||||||
if !strings.Contains(frame, redacted) {
|
if i < 3 {
|
||||||
t.Fatalf("frame %d did not apply the presentation redaction path:\n%s", i, frame)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,8 +149,8 @@ func (p terminalPresentation) contextualHelp(model presentationState) string {
|
|||||||
default:
|
default:
|
||||||
body.WriteString("The Single proxy URL field visibly offers Protocol Probe; focus that field and press Ctrl+D to run it explicitly.\n")
|
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 != "" {
|
} else if model.form.hasProbeSourceAttachment() {
|
||||||
body.WriteString("The Protocol Probe result remains attached to the generated Advanced Profile URL values.\n")
|
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:
|
default:
|
||||||
body.WriteString(p.theme.section.Render("Dashboard"))
|
body.WriteString(p.theme.section.Render("Dashboard"))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
"proxyctl/internal/app"
|
"proxyctl/internal/app"
|
||||||
|
"proxyctl/internal/probe"
|
||||||
"proxyctl/internal/profile"
|
"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") {
|
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())
|
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")
|
model = updateKey(model, "ctrl+t")
|
||||||
if strings.Contains(model.View(), "Ctrl+D: probe") || !strings.Contains(model.View(), "advanced mode") {
|
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())
|
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) {
|
func TestCompleteModelHelpOverEditorPreservesFieldCursorPreviewAndResizeState(t *testing.T) {
|
||||||
module := &profileManagementModule{}
|
module := &profileManagementModule{}
|
||||||
model := openNewProfileEditor(t, module)
|
model := openNewProfileEditor(t, module)
|
||||||
|
|||||||
+9
-86
@@ -2,9 +2,7 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
@@ -47,14 +45,12 @@ type Model struct {
|
|||||||
detect func(context.Context, string) (probe.Capabilities, error)
|
detect func(context.Context, string) (probe.Capabilities, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// probeDone carries one finished Protocol Probe back to the form. Endpoint and
|
// probeDone carries only detector output and formState's opaque typed token.
|
||||||
// input text deliberately stay in formState: an asynchronous result cannot
|
// Source data and all validation/application rules remain owned by formState.
|
||||||
// introduce userinfo into status, and formID plus generation reject stale work.
|
|
||||||
type probeDone struct {
|
type probeDone struct {
|
||||||
caps probe.Capabilities
|
caps probe.Capabilities
|
||||||
err error
|
err error
|
||||||
formID uint64
|
token probeToken
|
||||||
generation uint64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type profileDeleteConfirmation struct {
|
type profileDeleteConfirmation struct {
|
||||||
@@ -177,57 +173,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, waitForOperation(progress.updates)
|
return m, waitForOperation(progress.updates)
|
||||||
}
|
}
|
||||||
if done, ok := message.(probeDone); ok {
|
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, m.form.applyProbeResult(done.token, done.caps, done.err)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
key, ok := message.(tea.KeyMsg)
|
key, ok := message.(tea.KeyMsg)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -596,26 +542,14 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.form = formState{}
|
m.form = formState{}
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyCtrlD:
|
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
|
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
|
detect := m.detect
|
||||||
formID := m.form.id
|
|
||||||
generation := m.form.probe.generation
|
|
||||||
return m, func() tea.Msg {
|
return m, func() tea.Msg {
|
||||||
caps, err := detect(context.Background(), address)
|
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:
|
case tea.KeyCtrlT:
|
||||||
return m, m.form.toggleMode()
|
return m, m.form.toggleMode()
|
||||||
@@ -653,17 +587,6 @@ func (m *Model) applyProfileMutation(intent app.Intent, preferredName string) ap
|
|||||||
return result
|
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) {
|
func (m Model) selectedProfile() (profile.Profile, bool) {
|
||||||
if m.selectedProfileName != "" {
|
if m.selectedProfileName != "" {
|
||||||
for i := range m.snapshot.Profiles {
|
for i := range m.snapshot.Profiles {
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
"proxyctl/internal/app"
|
"proxyctl/internal/app"
|
||||||
"proxyctl/internal/probe"
|
|
||||||
"proxyctl/internal/profile"
|
"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 {
|
func updateKey(model Model, key string) Model {
|
||||||
msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
|
msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
|
||||||
special := map[string]tea.KeyType{
|
special := map[string]tea.KeyType{
|
||||||
|
|||||||
@@ -238,6 +238,12 @@ func (p terminalPresentation) formView(model presentationState) terminalPage {
|
|||||||
fmt.Fprintf(&fields, "\n %s", probeText)
|
fmt.Fprintf(&fields, "\n %s", probeText)
|
||||||
currentLine++
|
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)
|
validation := model.form.validationText(i)
|
||||||
if item.field == profile.NameField {
|
if item.field == profile.NameField {
|
||||||
if conflict := model.form.nameConflict(model.snapshot.Profiles); conflict != "" {
|
if conflict := model.form.nameConflict(model.snapshot.Profiles); conflict != "" {
|
||||||
|
|||||||
@@ -146,8 +146,13 @@ func (p terminalPresentation) Render(state presentationState) string {
|
|||||||
page = redactTerminalPage(page)
|
page = redactTerminalPage(page)
|
||||||
// Dashboard composition retains structured Target metadata until pane sizes
|
// Dashboard composition retains structured Target metadata until pane sizes
|
||||||
// are known. Redact the fully composed frame as the final presentation
|
// are known. Redact the fully composed frame as the final presentation
|
||||||
// boundary as well, so no render path can bypass credential scrubbing.
|
// boundary as well, so no render path can bypass credential scrubbing. Form
|
||||||
return profile.RedactText(p.renderShell(page, width, height))
|
// 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 {
|
func renderStatus(state presentationState) string {
|
||||||
|
|||||||
@@ -55,26 +55,34 @@ func TestCompleteModelProfileEditorSupportsCursorEditingDeletionAndPaste(t *test
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompleteModelProfileEditorHorizontallyScrollsLongSecretURLWithoutDisclosure(t *testing.T) {
|
func TestCompleteModelProfileEditorMasksEntireUserinfoWhilePreservingScrollAndSavedValue(t *testing.T) {
|
||||||
module := &profileManagementModule{}
|
module := &profileManagementModule{}
|
||||||
model := openNewProfileEditor(t, module)
|
model := openNewProfileEditor(t, module)
|
||||||
model = updateRunes(model, "office")
|
model = updateRunes(model, "office")
|
||||||
model = updateKey(model, "tab")
|
model = updateKey(model, "tab")
|
||||||
|
const username = "operator-private"
|
||||||
const secret = "credential-never-rendered"
|
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)
|
model = updateRunes(model, raw)
|
||||||
|
|
||||||
end := model.View()
|
end := model.View()
|
||||||
assertUsableFrame(t, end, 80, 24, "[focused]", "scroll.example:8443")
|
assertUsableFrame(t, end, 80, 24, "[focused]", "scroll.example:8443")
|
||||||
if strings.Contains(end, secret) || strings.Contains(end, "https://operator") {
|
for _, literal := range []string{username, secret} {
|
||||||
t.Fatalf("end-scrolled input disclosed credentials or failed to scroll:\n%s", end)
|
if strings.Contains(end, literal) {
|
||||||
|
t.Fatalf("end-scrolled input disclosed userinfo %q:\n%s", literal, end)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
model = editorKey(model, tea.KeyHome)
|
model = editorKey(model, tea.KeyHome)
|
||||||
start := model.View()
|
start := model.View()
|
||||||
assertUsableFrame(t, start, 80, 24, "https://operator:")
|
assertUsableFrame(t, start, 80, 24, "https://")
|
||||||
if strings.Contains(start, secret) || strings.Contains(start, "scroll.example:8443") {
|
if strings.Contains(start, username) || 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)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+127
-13
@@ -1,12 +1,14 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"proxyctl/internal/probe"
|
||||||
"proxyctl/internal/profile"
|
"proxyctl/internal/profile"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,12 +24,19 @@ type formDefinition struct {
|
|||||||
value string
|
value string
|
||||||
}
|
}
|
||||||
|
|
||||||
type protocolProbeFieldState struct {
|
type probeToken struct {
|
||||||
active bool
|
formID uint64
|
||||||
generation uint64
|
generation uint64
|
||||||
address string
|
source profile.Field
|
||||||
feedback string
|
}
|
||||||
field profile.Field
|
|
||||||
|
type protocolProbeFieldState struct {
|
||||||
|
active bool
|
||||||
|
token probeToken
|
||||||
|
address string
|
||||||
|
feedback string
|
||||||
|
sourceValue string
|
||||||
|
sourceAttached bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type formState struct {
|
type formState struct {
|
||||||
@@ -39,6 +48,7 @@ type formState struct {
|
|||||||
bypassPreviewLine int
|
bypassPreviewLine int
|
||||||
validation profile.Validation
|
validation profile.Validation
|
||||||
advancedInitialized bool
|
advancedInitialized bool
|
||||||
|
probeGeneration uint64
|
||||||
probe protocolProbeFieldState
|
probe protocolProbeFieldState
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +146,7 @@ func (f *formState) updateInput(key tea.KeyMsg) tea.Cmd {
|
|||||||
f.bypassPreviewLine = 0
|
f.bypassPreviewLine = 0
|
||||||
changedField := f.inputs[f.field].field
|
changedField := f.inputs[f.field].field
|
||||||
f.syncFocusedValue()
|
f.syncFocusedValue()
|
||||||
if changedField == profile.SingleURLField {
|
if isProxyURLField(changedField) {
|
||||||
f.invalidateProbe()
|
f.invalidateProbe()
|
||||||
}
|
}
|
||||||
f.refreshValidation()
|
f.refreshValidation()
|
||||||
@@ -195,12 +205,106 @@ func (f formState) currentField() profile.Field {
|
|||||||
return f.inputs[f.field].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() {
|
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.active = false
|
||||||
f.probe.address = ""
|
address, raw := f.probe.address, f.probe.sourceValue
|
||||||
f.probe.feedback = ""
|
switch {
|
||||||
f.probe.field = ""
|
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 {
|
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"
|
return "Protocol Probe [Ctrl+D]: available"
|
||||||
}
|
}
|
||||||
if field == f.probe.field {
|
|
||||||
return f.probe.feedback
|
|
||||||
}
|
|
||||||
return ""
|
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) {
|
func (f *formState) scrollBypassPreview(delta int) {
|
||||||
if f.currentField() != profile.BypassField {
|
if f.currentField() != profile.BypassField {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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) {
|
func TestCompleteModelProtocolProbePopulatesEstablishedCapabilityRepresentations(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -143,12 +186,12 @@ func TestCompleteModelProtocolProbePopulatesEstablishedCapabilityRepresentations
|
|||||||
}
|
}
|
||||||
attachedField := "Single proxy URL"
|
attachedField := "Single proxy URL"
|
||||||
if test.caps.HTTP && test.caps.SOCKS5 {
|
if test.caps.HTTP && test.caps.SOCKS5 {
|
||||||
attachedField = "HTTP URL"
|
attachedField = "Single proxy URL probe [read-only]"
|
||||||
}
|
}
|
||||||
field := strings.Index(view, attachedField)
|
field := strings.Index(view, attachedField)
|
||||||
result := strings.Index(view, "Protocol Probe: detected")
|
result := strings.Index(view, "Protocol Probe: detected")
|
||||||
if field < 0 || result < field || strings.Count(view[field:result], "\n") > 1 {
|
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")
|
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) {
|
func TestCompleteModelProtocolProbeFailureAndUnrecognizedResultsRemainEditableAndSavable(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -338,33 +402,43 @@ func TestCompleteModelProtocolProbeSurvivesResizeWithoutRestartingOrDetaching(t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompleteModelProtocolProbeKeepsCredentialsOutOfDialStatusErrorsAndFrames(t *testing.T) {
|
func TestCompleteModelProtocolProbeKeepsAllUserinfoOutOfDialStatusErrorsAndEveryFormFrame(t *testing.T) {
|
||||||
const (
|
const (
|
||||||
|
inputUser = "alice-private"
|
||||||
inputSecret = "input-secret"
|
inputSecret = "input-secret"
|
||||||
|
errorUser = "mallory-private"
|
||||||
errorSecret = "error-secret"
|
errorSecret = "error-secret"
|
||||||
)
|
)
|
||||||
var dialed []string
|
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)
|
dialed = append(dialed, address)
|
||||||
return probe.Capabilities{}, errors.New("failed https://mallory:" + errorSecret + "@evil.example:9999")
|
return probe.Capabilities{}, errors.New("failed https://" + errorUser + ":" + errorSecret + "@evil.example:9999")
|
||||||
}, "alice:"+inputSecret+"@proxy.example:8080")
|
}, inputUser+":"+inputSecret+"@proxy.example:8080")
|
||||||
|
frames := []string{model.View()}
|
||||||
model, command := startProtocolProbe(t, model)
|
model, command := startProtocolProbe(t, model)
|
||||||
progress := model.View()
|
frames = append(frames, 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
model = finishProtocolProbe(t, model, command)
|
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" {
|
if len(dialed) != 1 || dialed[0] != "proxy.example:8080" {
|
||||||
t.Fatalf("probe dial evidence = %#v, want credential-free endpoint", dialed)
|
t.Fatalf("probe dial evidence = %#v, want credential-free endpoint", dialed)
|
||||||
}
|
}
|
||||||
if strings.Contains(view, inputSecret) || strings.Contains(view, errorSecret) || strings.Contains(view, "mallory") {
|
for i, frame := range frames {
|
||||||
t.Fatalf("probe frame exposed input or lower-layer credentials:\n%s", view)
|
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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user