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
+153
View File
@@ -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
}
+245
View File
@@ -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)
}
}