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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user