diff --git a/backend/internal/node/uri_anytls_test.go b/backend/internal/node/uri_anytls_test.go new file mode 100644 index 0000000..22efc19 --- /dev/null +++ b/backend/internal/node/uri_anytls_test.go @@ -0,0 +1,63 @@ +package node + +import ( + "strings" + "testing" +) + +func TestAnyTLSURIMatchesOfficialScheme(t *testing.T) { + cfg := map[string]interface{}{ + "users": map[string]interface{}{ + "alice": "letmein", + }, + "sni": "real.example.com", + "skip-cert-verify": true, + } + uris, err := anytlsURIs("My Node", "example.com", "443", cfg) + if err != nil { + t.Fatal(err) + } + if len(uris) != 1 { + t.Fatalf("want 1 uri, got %#v", uris) + } + u := uris[0] + if !strings.HasPrefix(u, "anytls://letmein@example.com:443?") && !strings.HasPrefix(u, "anytls://letmein@example.com:443#") { + // password may be encoded; still must be anytls scheme with @host + if !strings.Contains(u, "anytls://") || !strings.Contains(u, "@example.com:443") { + t.Fatalf("unexpected uri: %s", u) + } + } + if !strings.Contains(u, "sni=real.example.com") { + t.Fatalf("missing sni: %s", u) + } + if !strings.Contains(u, "insecure=1") { + t.Fatalf("missing insecure=1: %s", u) + } + if strings.Contains(u, "allowInsecure") || strings.Contains(u, "fp=") { + t.Fatalf("non-official query keys should not appear: %s", u) + } + if !strings.Contains(u, "#") { + t.Fatalf("missing fragment name: %s", u) + } +} + +func TestAnyTLSURIPasswordEncoding(t *testing.T) { + cfg := map[string]interface{}{ + "users": map[string]interface{}{ + "u": "p@ss:word/x", + }, + "sni": "example.com", + } + uris, err := anytlsURIs("n", "1.2.3.4", "8443", cfg) + if err != nil { + t.Fatal(err) + } + u := uris[0] + // Special chars in password must be percent-encoded in userinfo. + if strings.Contains(u, "p@ss:word/x@") { + t.Fatalf("password not encoded: %s", u) + } + if !strings.Contains(u, "@1.2.3.4:8443") { + t.Fatalf("host missing: %s", u) + } +} diff --git a/backend/internal/node/uri_builders.go b/backend/internal/node/uri_builders.go index 810c1ee..8e788d4 100644 --- a/backend/internal/node/uri_builders.go +++ b/backend/internal/node/uri_builders.go @@ -411,43 +411,49 @@ func tuicURIs(name, host, port string, cfg map[string]interface{}) ([]string, er return result, nil } +// anytlsURIs builds share links per official AnyTLS URI scheme: +// +// anytls://[password@]hostname[:port]/?sni=...&insecure=1#name +// +// https://github.com/anytls/anytls-go/blob/main/docs/uri_scheme.md func anytlsURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) { users := userMap(cfg) if len(users) == 0 { return nil, fmt.Errorf("anytls listener requires at least one user for URI export") } + sni := "" + if v, ok := cfg["sni"].(string); ok && strings.TrimSpace(v) != "" { + sni = strings.TrimSpace(v) + } else if v, ok := cfg["servername"].(string); ok && strings.TrimSpace(v) != "" { + sni = strings.TrimSpace(v) + } + insecure := clientSkipCert(cfg, host) result := make([]string, 0, len(users)) for username, raw := range users { password, ok := raw.(string) - if !ok || password == "" { + if !ok || strings.TrimSpace(password) == "" { return nil, fmt.Errorf("anytls user %q has empty password", username) } - params := map[string]string{} - if v, ok := cfg["sni"].(string); ok && v != "" { - params["sni"] = v - } - if v, ok := cfg["client-fingerprint"].(string); ok && v != "" { - params["fp"] = v - } - if clientSkipCert(cfg, host) { - params["insecure"] = "1" - params["allowInsecure"] = "1" - } - if v, ok := cfg["idle-session-check-interval"].(string); ok && v != "" { - params["idle_session_check_interval"] = v - } - if v, ok := cfg["idle-session-timeout"].(string); ok && v != "" { - params["idle_session_timeout"] = v - } - if v, ok := cfg["min-idle-session"].(string); ok && v != "" { - params["min_idle_session"] = v - } - _ = username - result = append(result, addName(query("anytls://"+url.PathEscape(password)+"@"+netutil.JoinHostPort(host, port), params), name)) + result = append(result, buildAnyTLSURI(strings.TrimSpace(password), host, port, sni, name, insecure)) } return result, nil } +// buildAnyTLSURI encodes password as URI userinfo and only official query keys. +func buildAnyTLSURI(password, host, port, sni, name string, insecure bool) string { + // Auth is the password in the standard URI username position (percent-encoded). + userinfo := url.User(password).String() + base := "anytls://" + userinfo + "@" + netutil.JoinHostPort(host, port) + params := map[string]string{} + if strings.TrimSpace(sni) != "" { + params["sni"] = strings.TrimSpace(sni) + } + if insecure { + params["insecure"] = "1" + } + return addName(query(base, params), name) +} + func realityPublicKey(cfg map[string]interface{}) (string, error) { if public, ok := cfg["public-key"].(string); ok && strings.TrimSpace(public) != "" { return public, nil diff --git a/backend/internal/protocol/share_modules.go b/backend/internal/protocol/share_modules.go index 6174d81..3cd44eb 100644 --- a/backend/internal/protocol/share_modules.go +++ b/backend/internal/protocol/share_modules.go @@ -448,6 +448,8 @@ func (g GenericCompiler) BuildShare(in ShareInput) (Share, error) { // --- AnyTLS https://wiki.metacubex.one/config/proxies/anytls/ --- func (AnyTLSCompiler) BuildShare(in ShareInput) (Share, error) { + // URI: https://github.com/anytls/anytls-go/blob/main/docs/uri_scheme.md + // anytls://[password@]host[:port]/?sni=...&insecure=0|1#name host, port, err := shareHostPort(in.Node, "") if err != nil { return Share{}, err @@ -463,17 +465,44 @@ func (AnyTLSCompiler) BuildShare(in ShareInput) (Share, error) { if pass == "" { return Share{}, fmt.Errorf("anytls share requires password") } - extra := map[string]interface{}{"password": pass, "udp": true} sni := strings.TrimSpace(in.Node.AccessSNI) if sni == "" { sni = strFrom(cfg, "sni", "servername") } - if sni == "" { - sni = host + // Official: omit port → 443. We always include explicit port when known. + insecure := true + if b, ok := cfg["skip-cert-verify"].(bool); ok { + insecure = b + } else if b, ok := cfg["allow-insecure"].(bool); ok { + insecure = b + } + // Legitimate cert + domain connect host → prefer secure unless explicitly skipped. + if !insecure { + // keep false + } else if cert, _ := cfg["certificate"].(string); strings.TrimSpace(cert) != "" { + // panel may still want skip for self-signed; default true for share convenience + insecure = true + } + + params := map[string]string{} + if sni != "" { + params["sni"] = sni + } + if insecure { + params["insecure"] = "1" + } + userinfo := url.User(pass).String() + uri := shareName( + shareQuery("anytls://"+userinfo+"@"+netutil.JoinHostPort(host, port), params), + in.Node.Name, + ) + + extra := map[string]interface{}{"password": pass, "udp": true} + if sni != "" { + extra["sni"] = sni } - extra["sni"] = sni extra["client-fingerprint"] = strOr(strings.TrimSpace(in.Node.Fingerprint), "chrome") - extra["skip-cert-verify"] = true + extra["skip-cert-verify"] = insecure if alpn := stringListFrom(cfg, "alpn"); len(alpn) > 0 { extra["alpn"] = alpn } @@ -481,7 +510,7 @@ func (AnyTLSCompiler) BuildShare(in ShareInput) (Share, error) { if err != nil { return Share{}, err } - return Share{ClientYAML: yamlOut}, nil + return Share{URI: uri, QRContent: uri, ClientYAML: yamlOut}, nil } // --- ShadowQUIC https://wiki.metacubex.one/config/proxies/shadowquic/ ---