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,26 @@
|
|||||||
|
# Protocol Probe
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Probe semantics**. The Protocol Probe is a user-initiated handshake check against the Single proxy URL endpoint. It reports which proxy protocols the endpoint speaks (HTTP, SOCKS5, or both). It never forwards traffic through the proxy and never contacts anything beyond the endpoint itself.
|
||||||
|
|
||||||
|
2. **Trigger**. Ctrl+D starts a probe only when:
|
||||||
|
- the Profile form is in Single mode, and
|
||||||
|
- the active field is the Single proxy URL field.
|
||||||
|
It does not run on the Name or Bypass fields, and it does not run when saving with Ctrl+S.
|
||||||
|
|
||||||
|
3. **Bare host:port default**. A typed endpoint without a scheme is treated as `http://` for parsing and dialing, matching the curl convention used elsewhere in the app.
|
||||||
|
|
||||||
|
4. **Mapping table**. After detection, the Single proxy URL field is rewritten as follows:
|
||||||
|
- HTTP detected (alone or with SOCKS5): `http://<host:port>`
|
||||||
|
- SOCKS5 only: `socks5h://<host:port>`
|
||||||
|
- Unrecognized protocol: status message only; the field is left untouched.
|
||||||
|
- Dial failure: status message only; the field is left untouched.
|
||||||
|
|
||||||
|
5. **Userinfo preservation**. The exact bytes of any typed `user:pass@` userinfo are preserved, including special characters such as `@` and spaces. Only the scheme is replaced.
|
||||||
|
|
||||||
|
6. **Stale-result safety**. A probe started on the form is tagged with a generation ID. If the user leaves the form (Esc or a successful Ctrl+S) before the probe completes, the result is ignored and cannot mutate the abandoned form or the profiles page status.
|
||||||
|
|
||||||
|
7. **Failure UX**. A failed or unrecognized probe shows a status message and leaves the typed field unchanged so the user can correct the input and still save the Profile.
|
||||||
|
|
||||||
|
8. **Documentation**. README and CONTEXT already describe the Protocol Probe, its privacy boundary, and the `socks5h` GNOME caveat.
|
||||||
@@ -31,3 +31,7 @@ _Avoid_: Broken Target, partially enabled Target
|
|||||||
**External Target**:
|
**External Target**:
|
||||||
A Target for which a recognized proxy configuration exists outside this tool's ownership; absence of detected external configuration is treated as Disabled.
|
A Target for which a recognized proxy configuration exists outside this tool's ownership; absence of detected external configuration is treated as Disabled.
|
||||||
_Avoid_: Unknown Target, unmanaged error
|
_Avoid_: Unknown Target, unmanaged error
|
||||||
|
|
||||||
|
**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 or contacts anything beyond the endpoint itself.
|
||||||
|
_Avoid_: Connectivity test, latency check
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ Start the Target panel with no arguments:
|
|||||||
proxyctl
|
proxyctl
|
||||||
```
|
```
|
||||||
|
|
||||||
Press `p` for Proxy Profile management, `n` to create a Profile, enter a name and proxy URL, and press Ctrl+S to save. Single mode applies one URL to HTTP, HTTPS, and ALL/SOCKS. Ctrl+T selects advanced mode for separate values. Bypass accepts hostnames, domain suffixes, IPv4, IPv6, and CIDR entries. Select the Profile and press `a` or Enter to make it the Active Profile.
|
Press `p` for Proxy Profile management, `n` to create a Profile, enter a name and proxy URL, and press Ctrl+S to save. Single mode applies one URL to HTTP, HTTPS, and ALL/SOCKS. Ctrl+T selects advanced mode for separate values. A bare `host:port` is accepted and normalized to `http://`. With the cursor in the Single proxy URL field, Ctrl+D runs the Protocol Probe, which detects whether the endpoint speaks HTTP or SOCKS5 and fills in the scheme. Bypass accepts hostnames, domain suffixes, IPv4, IPv6, and CIDR entries. Select the Profile and press `a` or Enter to make it the Active Profile.
|
||||||
|
|
||||||
The main panel shows the Active Profile. Use arrows or `j`/`k` to select an Available Target, Space to toggle it, `r` to refresh detection, `?` for details, and `q` or Escape to leave. A Target cannot be enabled without an Active Profile, but previously managed configuration can still be disabled.
|
The main panel shows the Active Profile. Use arrows or `j`/`k` to select an Available Target, Space to toggle it, `r` to refresh detection, `?` for details, and `q` or Escape to leave. A Target cannot be enabled without an Active Profile, but previously managed configuration can still be disabled.
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ Fish and other Shells are intentionally unsupported in v1.
|
|||||||
2. **APT** — persistent APT package operations across Shell and privilege boundaries through a tool-owned `apt.conf.d` overlay. It does not configure other package managers and requires no service restart.
|
2. **APT** — persistent APT package operations across Shell and privilege boundaries through a tool-owned `apt.conf.d` overlay. It does not configure other package managers and requires no service restart.
|
||||||
3. **Docker daemon** — daemon operations such as registry access. It does not set container networking or container/build defaults. Every change reloads systemd and immediately restarts Docker; confirm only when disruption to running work is acceptable.
|
3. **Docker daemon** — daemon operations such as registry access. It does not set container networking or container/build defaults. Every change reloads systemd and immediately restarts Docker; confirm only when disruption to running work is acceptable.
|
||||||
4. **Docker containers/builds** — proxy variables and build arguments for future containers and builds via the owning user's Docker client configuration. It does not affect existing containers, the Docker CLI connection, or the daemon.
|
4. **Docker containers/builds** — proxy variables and build arguments for future containers and builds via the owning user's Docker client configuration. It does not affect existing containers, the Docker CLI connection, or the daemon.
|
||||||
5. **GNOME** — GNOME system proxy settings used by compatible desktop applications. It does not rewrite browser-specific profiles or configure non-GNOME desktops.
|
5. **GNOME** — GNOME system proxy settings used by compatible desktop applications. It does not rewrite browser-specific profiles or configure non-GNOME desktops. GNOME settings cannot express `socks5h` remote-DNS semantics, and the Protocol Probe fills `socks5h` for SOCKS-only endpoints; switch to advanced mode and enter `socks5` when a SOCKS endpoint must work through the GNOME Target.
|
||||||
|
|
||||||
proxyctl prefers isolated, tool-owned overlays. Where that is impossible it records original values and a last-applied fingerprint so disable/reset can restore prior state safely.
|
proxyctl prefers isolated, tool-owned overlays. Where that is impossible it records original values and a last-applied fingerprint so disable/reset can restore prior state safely.
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ Inspect failures and rerun until reset reports no failed managed Target. Then re
|
|||||||
|
|
||||||
## Privacy and safety boundaries
|
## Privacy and safety boundaries
|
||||||
|
|
||||||
proxyctl has no telemetry, automatic update checks, or automatic proxy-connectivity tests. It does not contact the network on its own. Local diagnostic logs are credential-redacted and bounded in size. Proxy credentials remain present where a selected Target requires them, so normal local file security still matters.
|
proxyctl has no telemetry, automatic update checks, or automatic proxy-connectivity tests. It does not contact the network on its own; the only network traffic it generates is the user-initiated Protocol Probe (Ctrl+D in the Profile form), a handshake check that never forwards traffic through the proxy and never contacts anything beyond the endpoint itself. Local diagnostic logs are credential-redacted and bounded in size. Proxy credentials remain present where a selected Target requires them, so normal local file security still matters.
|
||||||
|
|
||||||
proxyctl does **not** manage or supervise proxy processes such as Clash, sing-box, or Xray. It does not manage subscriptions, nodes, latency, routing rules, transparent proxying, TUN/iptables/nftables, arbitrary services or commands, plugins, or a complete headless Profile/Target CLI.
|
proxyctl does **not** manage or supervise proxy processes such as Clash, sing-box, or Xray. It does not manage subscriptions, nodes, latency, routing rules, transparent proxying, TUN/iptables/nftables, arbitrary services or commands, plugins, or a complete headless Profile/Target CLI.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
return validateURL(field, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateURL(field, value string) (string, error) {
|
// SchemePrefixPattern matches a URL scheme prefix.
|
||||||
value = strings.TrimSpace(value)
|
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 == "" {
|
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)
|
u, err := url.Parse(value)
|
||||||
if err != nil {
|
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) {
|
switch strings.ToLower(u.Scheme) {
|
||||||
case "http", "https", "socks5", "socks5h":
|
case "http", "https", "socks5", "socks5h":
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("%s must use http, https, socks5, or socks5h", field)
|
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 != "" {
|
if u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
|
||||||
return "", fmt.Errorf("%s must not include a path, query, or fragment", field)
|
return "", fmt.Errorf("%s must not include a path, query, or fragment", field)
|
||||||
}
|
}
|
||||||
port := u.Port()
|
port := u.Port()
|
||||||
if port == "" {
|
|
||||||
return "", fmt.Errorf("%s must include a port", field)
|
|
||||||
}
|
|
||||||
n, err := strconv.Atoi(port)
|
n, err := strconv.Atoi(port)
|
||||||
if err != nil || n < 1 || n > 65535 {
|
if err != nil || n < 1 || n > 65535 {
|
||||||
return "", fmt.Errorf("%s port must be between 1 and 65535", field)
|
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
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
"proxyctl/internal/app"
|
"proxyctl/internal/app"
|
||||||
|
"proxyctl/internal/probe"
|
||||||
"proxyctl/internal/profile"
|
"proxyctl/internal/profile"
|
||||||
"proxyctl/internal/target"
|
"proxyctl/internal/target"
|
||||||
)
|
)
|
||||||
@@ -24,6 +27,8 @@ type formState struct {
|
|||||||
originalName string
|
originalName string
|
||||||
profile profile.Profile
|
profile profile.Profile
|
||||||
field int
|
field int
|
||||||
|
probing bool
|
||||||
|
probeGen uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
@@ -42,6 +47,7 @@ type Model struct {
|
|||||||
profileTransition bool
|
profileTransition bool
|
||||||
progressTarget string
|
progressTarget string
|
||||||
quitRequested bool
|
quitRequested bool
|
||||||
|
detect func(context.Context, string) (probe.Capabilities, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type targetOperationDone struct {
|
type targetOperationDone struct {
|
||||||
@@ -57,6 +63,17 @@ type profileOperationProgress struct {
|
|||||||
updates <-chan app.Result
|
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 {
|
func waitForProfileProgress(updates <-chan app.Result) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
result, ok := <-updates
|
result, ok := <-updates
|
||||||
@@ -68,7 +85,7 @@ func waitForProfileProgress(updates <-chan app.Result) tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewModel(application app.Module) Model {
|
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 }
|
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()
|
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)
|
key, ok := message.(tea.KeyMsg)
|
||||||
if !ok {
|
if !ok {
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -351,6 +393,7 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "esc":
|
case "esc":
|
||||||
m.page = profilesPage
|
m.page = profilesPage
|
||||||
m.status = ""
|
m.status = ""
|
||||||
|
m.form = formState{}
|
||||||
return m, nil
|
return m, nil
|
||||||
case "ctrl+s":
|
case "ctrl+s":
|
||||||
kind := app.CreateProfile
|
kind := app.CreateProfile
|
||||||
@@ -362,12 +405,37 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.snapshot = result.Snapshot
|
m.snapshot = result.Snapshot
|
||||||
if result.Err != nil {
|
if result.Err != nil {
|
||||||
m.status = "Error: " + result.Err.Error()
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
m.status = result.Notice
|
m.status = result.Notice
|
||||||
m.page = profilesPage
|
m.page = profilesPage
|
||||||
m.selectName(intent.Profile.Name)
|
m.selectName(intent.Profile.Name)
|
||||||
|
m.form = formState{}
|
||||||
return m, nil
|
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":
|
case "ctrl+t":
|
||||||
if m.form.profile.Mode == profile.SingleMode {
|
if m.form.profile.Mode == profile.SingleMode {
|
||||||
m.form.profile.Mode = profile.AdvancedMode
|
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) {
|
func (m Model) selectedProfile() (profile.Profile, bool) {
|
||||||
if m.selected < 0 || m.selected >= len(m.snapshot.Profiles) {
|
if m.selected < 0 || m.selected >= len(m.snapshot.Profiles) {
|
||||||
return profile.Profile{}, false
|
return profile.Profile{}, false
|
||||||
@@ -592,7 +671,7 @@ func (m Model) formView() string {
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "%s%s: %s\n", cursor, label, value)
|
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 != "" {
|
if m.status != "" {
|
||||||
fmt.Fprintf(&b, "\n%s\n", m.status)
|
fmt.Fprintf(&b, "\n%s\n", m.status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
"proxyctl/internal/app"
|
"proxyctl/internal/app"
|
||||||
|
"proxyctl/internal/probe"
|
||||||
"proxyctl/internal/profile"
|
"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 {
|
func updateKey(model Model, key string) Model {
|
||||||
msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
|
msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
|
||||||
switch key {
|
switch key {
|
||||||
|
|||||||
Reference in New Issue
Block a user