- 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.
154 lines
5.0 KiB
Go
154 lines
5.0 KiB
Go
// 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
|
|
}
|