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:
proxyctl coordinator
2026-07-17 17:36:50 +08:00
parent c8a8ab2457
commit 0f4c122eff
9 changed files with 948 additions and 15 deletions
+81 -2
View File
@@ -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)
}