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:
+45
-10
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user