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
+45 -10
View File
@@ -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)
+69
View File
@@ -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)
}
}