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:
@@ -0,0 +1,153 @@
|
||||
// Package probe implements the Protocol Probe: a user-initiated handshake check
|
||||
// against a Proxy Profile's endpoint that reports which proxy protocols (HTTP,
|
||||
// SOCKS5) the endpoint speaks. It never forwards traffic through the proxy and
|
||||
// never contacts anything beyond the endpoint itself.
|
||||
package probe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"proxyctl/internal/profile"
|
||||
)
|
||||
|
||||
// attemptTimeout bounds each handshake attempt; the two checks together keep
|
||||
// one Protocol Probe under roughly three seconds.
|
||||
const attemptTimeout = 1500 * time.Millisecond
|
||||
|
||||
// Capabilities reports which proxy protocols an endpoint speaks.
|
||||
type Capabilities struct {
|
||||
HTTP bool
|
||||
SOCKS5 bool
|
||||
}
|
||||
|
||||
// URL maps a typed endpoint and the detected Capabilities to a normalized
|
||||
// proxy URL. HTTP is preferred; a SOCKS-only endpoint maps to socks5h
|
||||
// (remote-DNS semantics). Any typed scheme is replaced and any user:pass@
|
||||
// userinfo is preserved exactly as typed (special characters are not
|
||||
// percent-encoded).
|
||||
func URL(raw string, caps Capabilities) (string, error) {
|
||||
scheme := ""
|
||||
switch {
|
||||
case caps.HTTP:
|
||||
scheme = "http"
|
||||
case caps.SOCKS5:
|
||||
scheme = "socks5h"
|
||||
default:
|
||||
return "", errors.New("no proxy protocol detected")
|
||||
}
|
||||
value := strings.TrimSpace(raw)
|
||||
|
||||
// Extract the raw userinfo before parsing: passwords may contain
|
||||
// characters such as '@' or spaces that net/url rejects, and we want to
|
||||
// preserve the exact bytes the user typed rather than percent-encode them.
|
||||
normalized := value
|
||||
if !profile.SchemePrefixPattern.MatchString(normalized) {
|
||||
normalized = "http://" + normalized
|
||||
}
|
||||
schemeEnd := strings.Index(normalized, "://")
|
||||
rest := normalized[schemeEnd+len("://"):]
|
||||
atIdx := strings.LastIndex(rest, "@")
|
||||
var rawUser string
|
||||
hostOnly := value
|
||||
if atIdx != -1 {
|
||||
rawUser = rest[:atIdx]
|
||||
hostOnly = normalized[:schemeEnd+len("://")] + rest[atIdx+1:]
|
||||
}
|
||||
|
||||
u, err := profile.ParseProxyURL(hostOnly)
|
||||
if err != nil {
|
||||
return "", errors.New("endpoint must include host:port")
|
||||
}
|
||||
result := scheme + "://"
|
||||
if rawUser != "" {
|
||||
result += rawUser + "@"
|
||||
}
|
||||
return result + u.Host, nil
|
||||
}
|
||||
|
||||
// Detect handshakes with the endpoint at address (host:port) and reports the
|
||||
// proxy protocols it speaks. Both checks always run so the result is truthful
|
||||
// for mixed ports. A dial failure (refused, timeout) is returned as an error;
|
||||
// a reachable endpoint that speaks neither protocol yields zero Capabilities
|
||||
// and a nil error. Credentials play no part: an auth rejection already
|
||||
// identifies the protocol.
|
||||
func Detect(ctx context.Context, address string) (Capabilities, error) {
|
||||
var caps Capabilities
|
||||
socks5, socks5Err := checkSOCKS5(ctx, address)
|
||||
caps.SOCKS5 = socks5
|
||||
http, httpErr := checkHTTP(ctx, address)
|
||||
caps.HTTP = http
|
||||
if caps.HTTP || caps.SOCKS5 {
|
||||
return caps, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Capabilities{}, err
|
||||
}
|
||||
if socks5Err != nil && httpErr != nil {
|
||||
return Capabilities{}, socks5Err
|
||||
}
|
||||
return Capabilities{}, nil
|
||||
}
|
||||
|
||||
// checkHTTP asks the proxy to CONNECT its own loopback discard port. The
|
||||
// proxy gets refused locally and answers with an HTTP error status line, so
|
||||
// the "HTTP/" reply prefix identifies the protocol without contacting
|
||||
// anything beyond the endpoint or forwarding traffic through it. A plain
|
||||
// HTTP server answers the same way; that false positive matches the default
|
||||
// assumption the form already makes.
|
||||
func checkHTTP(ctx context.Context, address string) (bool, error) {
|
||||
conn, err := dial(ctx, address)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer conn.Close()
|
||||
stop := context.AfterFunc(ctx, func() { conn.Close() })
|
||||
defer stop()
|
||||
if _, err := io.WriteString(conn, "CONNECT 127.0.0.1:9 HTTP/1.1\r\nHost: 127.0.0.1:9\r\n\r\n"); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
// Only the status line prefix matters; reading exactly len("HTTP/")
|
||||
// bytes keeps the check bounded no matter what the endpoint streams.
|
||||
prefix := make([]byte, len("HTTP/"))
|
||||
if _, err := io.ReadFull(conn, prefix); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return string(prefix) == "HTTP/", nil
|
||||
}
|
||||
|
||||
// checkSOCKS5 sends the version-5 greeting offering only the no-auth method.
|
||||
// The version byte of the reply identifies SOCKS5 even when the server
|
||||
// rejects every offered method (0xFF). Errors after a successful dial mean
|
||||
// the endpoint spoke something else and are not reported.
|
||||
func checkSOCKS5(ctx context.Context, address string) (bool, error) {
|
||||
conn, err := dial(ctx, address)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer conn.Close()
|
||||
stop := context.AfterFunc(ctx, func() { conn.Close() })
|
||||
defer stop()
|
||||
if _, err := conn.Write([]byte{0x05, 0x01, 0x00}); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
reply := make([]byte, 2)
|
||||
if _, err := io.ReadFull(conn, reply); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return reply[0] == 0x05, nil
|
||||
}
|
||||
|
||||
func dial(ctx context.Context, address string) (net.Conn, error) {
|
||||
dialer := net.Dialer{Timeout: attemptTimeout}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn.SetDeadline(time.Now().Add(attemptTimeout))
|
||||
return conn, nil
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package probe_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"proxyctl/internal/probe"
|
||||
)
|
||||
|
||||
// fakeEndpoint starts a listener on 127.0.0.1:0 and serves each accepted
|
||||
// connection with handler until the test ends.
|
||||
func fakeEndpoint(t *testing.T, handler func(net.Conn)) string {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { listener.Close() })
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go handler(conn)
|
||||
}
|
||||
}()
|
||||
return listener.Addr().String()
|
||||
}
|
||||
|
||||
// serveSOCKS5 answers the SOCKS5 greeting and closes anything else so the
|
||||
// HTTP check fails fast instead of waiting out its deadline.
|
||||
func serveSOCKS5(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
first := make([]byte, 1)
|
||||
if _, err := io.ReadFull(conn, first); err != nil {
|
||||
return
|
||||
}
|
||||
if first[0] != 0x05 {
|
||||
return
|
||||
}
|
||||
if _, err := io.ReadFull(conn, make([]byte, 2)); err != nil {
|
||||
return
|
||||
}
|
||||
conn.Write([]byte{0x05, 0x00})
|
||||
}
|
||||
|
||||
func TestDetectRecognizesSOCKS5Endpoint(t *testing.T) {
|
||||
address := fakeEndpoint(t, serveSOCKS5)
|
||||
caps, err := probe.Detect(context.Background(), address)
|
||||
if err != nil {
|
||||
t.Fatalf("Detect returned error: %v", err)
|
||||
}
|
||||
if !caps.SOCKS5 || caps.HTTP {
|
||||
t.Fatalf("Detect = %#v, want SOCKS5 only", caps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectSucceedsWhenSOCKS5RejectsNoAuth(t *testing.T) {
|
||||
// A SOCKS5 server may answer 0xFF ("no acceptable methods") to the
|
||||
// no-auth greeting; the version byte still identifies the protocol.
|
||||
address := fakeEndpoint(t, func(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
first := make([]byte, 1)
|
||||
if _, err := io.ReadFull(conn, first); err != nil {
|
||||
return
|
||||
}
|
||||
if first[0] != 0x05 {
|
||||
return
|
||||
}
|
||||
if _, err := io.ReadFull(conn, make([]byte, 2)); err != nil {
|
||||
return
|
||||
}
|
||||
conn.Write([]byte{0x05, 0xFF})
|
||||
})
|
||||
caps, err := probe.Detect(context.Background(), address)
|
||||
if err != nil {
|
||||
t.Fatalf("Detect returned error: %v", err)
|
||||
}
|
||||
if !caps.SOCKS5 {
|
||||
t.Fatalf("Detect = %#v, want SOCKS5", caps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectHonorsContextCancellation(t *testing.T) {
|
||||
// A silent endpoint accepts connections but never answers; cancellation
|
||||
// must cut the attempt short instead of waiting out the full timeout.
|
||||
address := fakeEndpoint(t, func(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
time.Sleep(10 * time.Second)
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
if _, err := probe.Detect(ctx, address); err == nil {
|
||||
t.Fatal("Detect on silent endpoint with cancelled context returned nil error")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 3*time.Second {
|
||||
t.Fatalf("Detect ignored context cancellation, took %s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// serveHTTPProxy closes the SOCKS5 attempt fast, reads the CONNECT request,
|
||||
// and answers with an HTTP error status line like a real proxy refusing its
|
||||
// own loopback discard port would.
|
||||
func serveHTTPProxy(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
first := make([]byte, 1)
|
||||
if _, err := io.ReadFull(conn, first); err != nil {
|
||||
return
|
||||
}
|
||||
if first[0] == 0x05 {
|
||||
return
|
||||
}
|
||||
reader := bufio.NewReader(conn)
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if line == "\r\n" {
|
||||
break
|
||||
}
|
||||
}
|
||||
conn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n"))
|
||||
}
|
||||
|
||||
func TestDetectRecognizesHTTPProxyEndpoint(t *testing.T) {
|
||||
address := fakeEndpoint(t, serveHTTPProxy)
|
||||
caps, err := probe.Detect(context.Background(), address)
|
||||
if err != nil {
|
||||
t.Fatalf("Detect returned error: %v", err)
|
||||
}
|
||||
if !caps.HTTP || caps.SOCKS5 {
|
||||
t.Fatalf("Detect = %#v, want HTTP only", caps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectReportsBothProtocolsOnMixedPort(t *testing.T) {
|
||||
// Both checks always run so a port answering both handshakes is
|
||||
// reported truthfully instead of stopping at the first hit.
|
||||
address := fakeEndpoint(t, func(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
first := make([]byte, 1)
|
||||
if _, err := io.ReadFull(conn, first); err != nil {
|
||||
return
|
||||
}
|
||||
if first[0] == 0x05 {
|
||||
if _, err := io.ReadFull(conn, make([]byte, 2)); err != nil {
|
||||
return
|
||||
}
|
||||
conn.Write([]byte{0x05, 0x00})
|
||||
return
|
||||
}
|
||||
conn.Write([]byte("HTTP/1.1 400 Bad Request\r\n\r\n"))
|
||||
})
|
||||
caps, err := probe.Detect(context.Background(), address)
|
||||
if err != nil {
|
||||
t.Fatalf("Detect returned error: %v", err)
|
||||
}
|
||||
if !caps.HTTP || !caps.SOCKS5 {
|
||||
t.Fatalf("Detect = %#v, want both HTTP and SOCKS5", caps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectReturnsDialErrorForClosedPort(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
address := listener.Addr().String()
|
||||
listener.Close()
|
||||
caps, err := probe.Detect(context.Background(), address)
|
||||
if err == nil {
|
||||
t.Fatalf("Detect on closed port = %#v, nil error; want dial error", caps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectReturnsZeroCapabilitiesForUnrecognizedEndpoint(t *testing.T) {
|
||||
address := fakeEndpoint(t, func(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
conn.Write([]byte("garbage\r\n"))
|
||||
})
|
||||
caps, err := probe.Detect(context.Background(), address)
|
||||
if err != nil {
|
||||
t.Fatalf("Detect on unrecognized endpoint returned error: %v", err)
|
||||
}
|
||||
if caps.HTTP || caps.SOCKS5 {
|
||||
t.Fatalf("Detect = %#v, want zero Capabilities", caps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLMapsCapabilitiesToScheme(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
caps probe.Capabilities
|
||||
want string
|
||||
}{
|
||||
{"http detected", "localhost:8888", probe.Capabilities{HTTP: true}, "http://localhost:8888"},
|
||||
{"http preferred over socks5", "localhost:8888", probe.Capabilities{HTTP: true, SOCKS5: true}, "http://localhost:8888"},
|
||||
{"socks5 only uses socks5h", "localhost:8888", probe.Capabilities{SOCKS5: true}, "socks5h://localhost:8888"},
|
||||
{"userinfo preserved", "user:secret@localhost:8888", probe.Capabilities{SOCKS5: true}, "socks5h://user:secret@localhost:8888"},
|
||||
{"typed scheme replaced", "socks5://localhost:1080", probe.Capabilities{HTTP: true}, "http://localhost:1080"},
|
||||
{"typed scheme and userinfo", "http://user:secret@localhost:8080", probe.Capabilities{SOCKS5: true}, "socks5h://user:secret@localhost:8080"},
|
||||
{"IPv6 endpoint", "[::1]:8888", probe.Capabilities{HTTP: true}, "http://[::1]:8888"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := probe.URL(tt.raw, tt.caps)
|
||||
if err != nil {
|
||||
t.Fatalf("URL(%q) returned error: %v", tt.raw, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("URL(%q) = %q, want %q", tt.raw, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLPreservesRawUserinfoBytesWithoutEscaping(t *testing.T) {
|
||||
got, err := probe.URL("user:p@ss word@proxy.example:8080", probe.Capabilities{HTTP: true})
|
||||
if err != nil {
|
||||
t.Fatalf("URL returned error: %v", err)
|
||||
}
|
||||
want := "http://user:p@ss word@proxy.example:8080"
|
||||
if got != want {
|
||||
t.Fatalf("URL = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLRejectsInputWithoutHostPort(t *testing.T) {
|
||||
for _, raw := range []string{"", "localhost", "http://localhost", "user:secret@"} {
|
||||
if got, err := probe.URL(raw, probe.Capabilities{HTTP: true}); err == nil {
|
||||
t.Fatalf("URL(%q) = %q, want error", raw, got)
|
||||
}
|
||||
}
|
||||
if got, err := probe.URL("localhost:8888", probe.Capabilities{}); err == nil {
|
||||
t.Fatalf("URL with no detected protocol = %q, want error", got)
|
||||
}
|
||||
}
|
||||
+45
-10
@@ -95,30 +95,65 @@ func validateOptionalURL(field, value string) (string, error) {
|
||||
return validateURL(field, value)
|
||||
}
|
||||
|
||||
func validateURL(field, value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
// SchemePrefixPattern matches a URL scheme prefix.
|
||||
var SchemePrefixPattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*://`)
|
||||
|
||||
var (
|
||||
errProxyURLEmpty = errors.New("URL is required")
|
||||
errProxyURLMalformed = errors.New("URL is malformed")
|
||||
errProxyURLHost = errors.New("URL must include a host")
|
||||
errProxyURLPort = errors.New("URL must include a port")
|
||||
)
|
||||
|
||||
// ParseProxyURL parses raw as a proxy URL, defaulting the scheme to http://
|
||||
// when none is present, and requires a non-empty host and port.
|
||||
func ParseProxyURL(raw string) (*url.URL, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("%s is required", field)
|
||||
return nil, errProxyURLEmpty
|
||||
}
|
||||
// A bare host:port (curl convention) defaults to http://. Prepending
|
||||
// before url.Parse avoids "localhost:8888" being read as scheme
|
||||
// "localhost".
|
||||
if !SchemePrefixPattern.MatchString(value) {
|
||||
value = "http://" + value
|
||||
}
|
||||
u, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s is malformed", field)
|
||||
return nil, errProxyURLMalformed
|
||||
}
|
||||
if u.Hostname() == "" {
|
||||
return nil, errProxyURLHost
|
||||
}
|
||||
if u.Port() == "" {
|
||||
return nil, errProxyURLPort
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func validateURL(field, value string) (string, error) {
|
||||
u, err := ParseProxyURL(value)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case errProxyURLEmpty:
|
||||
return "", fmt.Errorf("%s is required", field)
|
||||
case errProxyURLHost:
|
||||
return "", fmt.Errorf("%s must include a host", field)
|
||||
case errProxyURLPort:
|
||||
return "", fmt.Errorf("%s must include a port", field)
|
||||
default:
|
||||
return "", fmt.Errorf("%s is malformed", field)
|
||||
}
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "http", "https", "socks5", "socks5h":
|
||||
default:
|
||||
return "", fmt.Errorf("%s must use http, https, socks5, or socks5h", field)
|
||||
}
|
||||
if u.Hostname() == "" {
|
||||
return "", fmt.Errorf("%s must include a host", field)
|
||||
}
|
||||
if u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
|
||||
return "", fmt.Errorf("%s must not include a path, query, or fragment", field)
|
||||
}
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
return "", fmt.Errorf("%s must include a port", field)
|
||||
}
|
||||
n, err := strconv.Atoi(port)
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
return "", fmt.Errorf("%s port must be between 1 and 65535", field)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package profile
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateAndNormalizeDefaultsSchemeLessURLsToHTTP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input Profile
|
||||
want map[string]string
|
||||
}{
|
||||
{
|
||||
name: "bare single URL",
|
||||
input: Profile{Name: "office", SingleURL: "proxy.example:8080"},
|
||||
want: map[string]string{"single": "http://proxy.example:8080"},
|
||||
},
|
||||
{
|
||||
name: "bare single URL with userinfo",
|
||||
input: Profile{Name: "office", SingleURL: "alice:secret@proxy.example:8080"},
|
||||
want: map[string]string{"single": "http://alice:secret@proxy.example:8080"},
|
||||
},
|
||||
{
|
||||
name: "bare IPv6 single URL",
|
||||
input: Profile{Name: "office", SingleURL: "[::1]:8888"},
|
||||
want: map[string]string{"single": "http://[::1]:8888"},
|
||||
},
|
||||
{
|
||||
name: "bare advanced URLs",
|
||||
input: Profile{Name: "office", Mode: AdvancedMode,
|
||||
HTTPURL: "proxy.example:8080", HTTPSURL: "secure.example:8443", AllURL: "socks.example:1080"},
|
||||
want: map[string]string{
|
||||
"http": "http://proxy.example:8080", "https": "http://secure.example:8443", "all": "http://socks.example:1080"},
|
||||
},
|
||||
{
|
||||
name: "explicit schemes untouched",
|
||||
input: Profile{Name: "office", Mode: AdvancedMode,
|
||||
HTTPURL: "https://proxy.example:8443", AllURL: "socks5h://socks.example:1080"},
|
||||
want: map[string]string{"http": "https://proxy.example:8443", "all": "socks5h://socks.example:1080"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ValidateAndNormalize(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateAndNormalize returned error: %v", err)
|
||||
}
|
||||
values := map[string]string{
|
||||
"single": got.SingleURL, "http": got.HTTPURL, "https": got.HTTPSURL, "all": got.AllURL,
|
||||
}
|
||||
for field, want := range tt.want {
|
||||
if values[field] != want {
|
||||
t.Fatalf("%s = %q, want %q", field, values[field], want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAndNormalizeSchemeLessSingleURLExpandsToAllProtocols(t *testing.T) {
|
||||
got, err := ValidateAndNormalize(Profile{Name: "office", SingleURL: "localhost:8888"})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateAndNormalize returned error: %v", err)
|
||||
}
|
||||
if got.SingleURL != "http://localhost:8888" {
|
||||
t.Fatalf("SingleURL = %q, want http://localhost:8888", got.SingleURL)
|
||||
}
|
||||
if got.HTTPURL != got.SingleURL || got.HTTPSURL != got.SingleURL || got.AllURL != got.SingleURL {
|
||||
t.Fatalf("Single mode did not expand: %#v", got)
|
||||
}
|
||||
}
|
||||
+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