feat: add Protocol Probe and accept bare host:port in profiles
- Ctrl+D in the Single URL field runs a handshake probe against the typed endpoint. It detects HTTP and/or SOCKS5 without forwarding traffic or contacting external hosts. - HTTP-capable endpoints map to http://; SOCKS5-only endpoints map to socks5h://. Userinfo is preserved as typed. - Bare host:port input is accepted and defaults to http:// at save. - Update README privacy boundary and add .scratch/protocol-probe/spec.md.
This commit is contained in:
+81
-2
@@ -1,12 +1,15 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"proxyctl/internal/app"
|
||||
"proxyctl/internal/probe"
|
||||
"proxyctl/internal/profile"
|
||||
"proxyctl/internal/target"
|
||||
)
|
||||
@@ -24,6 +27,8 @@ type formState struct {
|
||||
originalName string
|
||||
profile profile.Profile
|
||||
field int
|
||||
probing bool
|
||||
probeGen uint64
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
@@ -42,6 +47,7 @@ type Model struct {
|
||||
profileTransition bool
|
||||
progressTarget string
|
||||
quitRequested bool
|
||||
detect func(context.Context, string) (probe.Capabilities, error)
|
||||
}
|
||||
|
||||
type targetOperationDone struct {
|
||||
@@ -57,6 +63,17 @@ type profileOperationProgress struct {
|
||||
updates <-chan app.Result
|
||||
}
|
||||
|
||||
// probeDone carries one finished Protocol Probe back to the form. address is
|
||||
// always bare host:port so status text can never leak credentials. gen matches
|
||||
// the probe generation stored in formState so late results can be ignored after
|
||||
// the user leaves the form.
|
||||
type probeDone struct {
|
||||
address string
|
||||
caps probe.Capabilities
|
||||
err error
|
||||
gen uint64
|
||||
}
|
||||
|
||||
func waitForProfileProgress(updates <-chan app.Result) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
result, ok := <-updates
|
||||
@@ -68,7 +85,7 @@ func waitForProfileProgress(updates <-chan app.Result) tea.Cmd {
|
||||
}
|
||||
|
||||
func NewModel(application app.Module) Model {
|
||||
return Model{application: application, snapshot: application.Snapshot()}
|
||||
return Model{application: application, snapshot: application.Snapshot(), detect: probe.Detect}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
@@ -174,6 +191,31 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, m.deferredQuit()
|
||||
}
|
||||
if done, ok := message.(probeDone); ok {
|
||||
if !m.form.probing || done.gen != m.form.probeGen {
|
||||
return m, nil
|
||||
}
|
||||
m.form.probing = false
|
||||
switch {
|
||||
case done.err != nil:
|
||||
m.status = "Protocol Probe: no proxy detected at " + done.address + ": " + done.err.Error()
|
||||
case !done.caps.HTTP && !done.caps.SOCKS5:
|
||||
m.status = "Protocol Probe: unrecognized protocol at " + done.address
|
||||
default:
|
||||
rewritten, err := probe.URL(m.form.profile.SingleURL, done.caps)
|
||||
if err != nil {
|
||||
m.status = "Protocol Probe: " + err.Error()
|
||||
return m, nil
|
||||
}
|
||||
m.form.profile.SingleURL = rewritten
|
||||
kind := "SOCKS5"
|
||||
if done.caps.HTTP {
|
||||
kind = "HTTP"
|
||||
}
|
||||
m.status = "Protocol Probe: detected " + kind + " proxy at " + done.address
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
key, ok := message.(tea.KeyMsg)
|
||||
if !ok {
|
||||
return m, nil
|
||||
@@ -351,6 +393,7 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
case "esc":
|
||||
m.page = profilesPage
|
||||
m.status = ""
|
||||
m.form = formState{}
|
||||
return m, nil
|
||||
case "ctrl+s":
|
||||
kind := app.CreateProfile
|
||||
@@ -362,12 +405,37 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.snapshot = result.Snapshot
|
||||
if result.Err != nil {
|
||||
m.status = "Error: " + result.Err.Error()
|
||||
m.form.probeGen++ // discard any in-flight probe result while staying on the form
|
||||
m.form.probing = false
|
||||
return m, nil
|
||||
}
|
||||
m.status = result.Notice
|
||||
m.page = profilesPage
|
||||
m.selectName(intent.Profile.Name)
|
||||
m.form = formState{}
|
||||
return m, nil
|
||||
case "ctrl+d":
|
||||
if m.form.profile.Mode != profile.SingleMode {
|
||||
m.status = "Protocol Probe: available in Single mode only"
|
||||
return m, nil
|
||||
}
|
||||
if m.form.field != 1 || m.form.probing {
|
||||
return m, nil
|
||||
}
|
||||
address, err := probeAddress(m.form.profile.SingleURL)
|
||||
if err != nil {
|
||||
m.status = "Protocol Probe: " + err.Error()
|
||||
return m, nil
|
||||
}
|
||||
m.form.probing = true
|
||||
m.form.probeGen++
|
||||
m.status = "Protocol Probe: checking " + address + "…"
|
||||
detect := m.detect
|
||||
gen := m.form.probeGen
|
||||
return m, func() tea.Msg {
|
||||
caps, err := detect(context.Background(), address)
|
||||
return probeDone{address: address, caps: caps, err: err, gen: gen}
|
||||
}
|
||||
case "ctrl+t":
|
||||
if m.form.profile.Mode == profile.SingleMode {
|
||||
m.form.profile.Mode = profile.AdvancedMode
|
||||
@@ -434,6 +502,17 @@ func (m *Model) apply(intent app.Intent) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.selected < 0 || m.selected >= len(m.snapshot.Profiles) {
|
||||
return profile.Profile{}, false
|
||||
@@ -592,7 +671,7 @@ func (m Model) formView() string {
|
||||
}
|
||||
fmt.Fprintf(&b, "%s%s: %s\n", cursor, label, value)
|
||||
}
|
||||
b.WriteString("\nTab/↑/↓: move Ctrl+T: toggle Single/Advanced\nCtrl+S: save Esc: cancel\n")
|
||||
b.WriteString("\nTab/↑/↓: move Ctrl+T: toggle Single/Advanced\nCtrl+S: save Ctrl+D: detect protocol Esc: cancel\n")
|
||||
if m.status != "" {
|
||||
fmt.Fprintf(&b, "\n%s\n", m.status)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"proxyctl/internal/app"
|
||||
"proxyctl/internal/probe"
|
||||
"proxyctl/internal/profile"
|
||||
)
|
||||
|
||||
@@ -127,6 +130,325 @@ 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, "p")
|
||||
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, "p")
|
||||
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: no proxy detected at localhost:8888: connection refused") {
|
||||
t.Fatalf("form view missing 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, "p")
|
||||
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, "p")
|
||||
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, "p")
|
||||
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")
|
||||
}
|
||||
gen := model.form.probeGen
|
||||
|
||||
model = updateKey(model, "esc")
|
||||
updated, _ := model.Update(probeDone{address: "localhost:8888", caps: probe.Capabilities{HTTP: true}, gen: gen})
|
||||
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, "p")
|
||||
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")
|
||||
}
|
||||
gen := model.form.probeGen
|
||||
|
||||
model = updateKey(model, "ctrl+s")
|
||||
if len(module.intents) != 1 {
|
||||
t.Fatalf("save dispatched %d intents, want 1", len(module.intents))
|
||||
}
|
||||
|
||||
updated, _ := model.Update(probeDone{address: "localhost:8888", caps: probe.Capabilities{HTTP: true}, gen: gen})
|
||||
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, "p")
|
||||
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, "p")
|
||||
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 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, "p")
|
||||
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, "p")
|
||||
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: available in Single mode only") {
|
||||
t.Fatalf("form view missing Advanced mode hint:\n%s", view)
|
||||
}
|
||||
}
|
||||
|
||||
func updateKey(model Model, key string) Model {
|
||||
msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
|
||||
switch key {
|
||||
|
||||
Reference in New Issue
Block a user