From 0abb9f88a89a74b8c69e549d0a762fbb5427312e Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:11:35 +0300 Subject: [PATCH 1/2] fix: skip unsupported stream transports --- core/singbox/builder.go | 11 +++++ core/singbox/builder_test.go | 91 ++++++++++++++++++++++++++++++++++++ core/singbox/outbound.go | 5 +- 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/core/singbox/builder.go b/core/singbox/builder.go index 4acb6c47..5551cf51 100644 --- a/core/singbox/builder.go +++ b/core/singbox/builder.go @@ -458,6 +458,9 @@ func SelectorTags(nodes []model.Node) []NodeTag { // logged-skip rather than a fatal so one bad node can't poison the shared // config. The checks mirror sing-box's own decode-time requirements: // - port must be a valid uint16 (1..65535), for every protocol; +// - stream transports are limited to the variants bundled sing-box supports +// and transportObject renders safely; an unknown type must be skipped +// instead of being passed through and poisoning the shared config; // - Shadowsocks needs a method AND a password, and must carry no transport // plugin — shadowsocksOutbound emits none of sing-box's plugin/plugin_opts // fields, so a plugin node would build a plain outbound that fails its @@ -473,6 +476,14 @@ func validateNode(n model.Node) error { if n.TLS != nil && n.TLS.Reality != nil && n.TLS.Reality.PublicKey == "" { return fmt.Errorf("node %q: reality without public_key", n.Name) } + if n.Transport != nil { + switch n.Transport.Type { + case "", "ws", "grpc", "http", "httpupgrade", "quic": + // These are the stream transports transportObject renders natively. + default: + return fmt.Errorf("node %q: transport %q is not supported", n.Name, n.Transport.Type) + } + } switch n.Protocol { case model.Shadowsocks: if n.Method == "" || n.Password == "" { diff --git a/core/singbox/builder_test.go b/core/singbox/builder_test.go index da51f4ce..a845d920 100644 --- a/core/singbox/builder_test.go +++ b/core/singbox/builder_test.go @@ -702,6 +702,97 @@ func TestShadowsocksPluginSkipped(t *testing.T) { } } +// TestUnsupportedTransportSkipped pins the subscription-level failure from +// Tenebra 0.6.0: one VLESS+xhttp node made sing-box reject the shared config, +// taking every otherwise healthy node down with it. Unsupported stream +// transports must be excluded from both the config and selector while the +// healthy nodes in the same profile remain usable. +func TestUnsupportedTransportSkipped(t *testing.T) { + xhttp := model.Node{ + Protocol: model.VLESS, + Name: "xhttp", + Server: "xhttp.example.test", + Port: 443, + UUID: "44444444-4444-4444-4444-444444444444", + TLS: &model.TLS{Enabled: true, ServerName: "xhttp.example.test"}, + Transport: &model.Transport{ + Type: "xhttp", + Path: "/split-http", + }, + } + + cfg, err := Build([]model.Node{xhttp, goodSSNode("plain")}, "xhttp", + routing.Options{Mode: routing.ModeGlobal}, TunOptions{}) + if err != nil { + t.Fatalf("unsupported transport should be skipped, not poison the profile: %v", err) + } + by := outboundsByTag(t, cfg) + if _, has := by["xhttp"]; has { + t.Error("node with unsupported xhttp transport must not appear in outbounds") + } + if _, has := by["plain"]; !has { + t.Errorf("healthy node must survive; tags %v", keys(by)) + } + + selector := by[proxyTag] + members, ok := selector["outbounds"].([]string) + if !ok { + t.Fatalf("selector outbounds type %T", selector["outbounds"]) + } + if contains(members, "xhttp") { + t.Errorf("unsupported transport leaked into selector members %v", members) + } + if !contains(members, "plain") { + t.Errorf("healthy node missing from selector members %v", members) + } + if selector["default"] != "plain" { + t.Errorf("selector default = %v, want surviving node plain", selector["default"]) + } + + if err := validateNode(xhttp); err == nil || !strings.Contains(err.Error(), "xhttp") { + t.Errorf("validateNode(xhttp) = %v, want an error naming xhttp", err) + } + if ValidateNode(xhttp) { + t.Error("ValidateNode should reject an unsupported xhttp transport") + } +} + +// TestSupportedQUICTransportSurvives guards the allowlist against rejecting a +// transport bundled sing-box understands. QUIC has no per-transport options in +// sing-box 1.13, so unrelated link fields must not leak into its config object. +func TestSupportedQUICTransportSurvives(t *testing.T) { + quic := model.Node{ + Protocol: model.VLESS, + Name: "quic", + Server: "quic.example.test", + Port: 443, + UUID: "55555555-5555-5555-5555-555555555555", + TLS: &model.TLS{Enabled: true, ServerName: "quic.example.test"}, + Transport: &model.Transport{ + Type: "quic", + Path: "/ignored", + Host: "ignored.example.test", + }, + } + + cfg, err := Build([]model.Node{quic}, "quic", + routing.Options{Mode: routing.ModeGlobal}, TunOptions{}) + if err != nil { + t.Fatalf("supported quic transport was rejected: %v", err) + } + outbound := outboundsByTag(t, cfg)["quic"] + transport, ok := outbound["transport"].(map[string]any) + if !ok { + t.Fatalf("quic transport type %T, want object", outbound["transport"]) + } + if len(transport) != 1 || transport["type"] != "quic" { + t.Errorf("quic transport = %v, want only type=quic", transport) + } + if !ValidateNode(quic) { + t.Error("ValidateNode should accept a supported quic transport") + } +} + func TestNoUsableNodes(t *testing.T) { _, err := Build(nil, "", routing.Options{Mode: routing.ModeSmart}, TunOptions{}) if err == nil { diff --git a/core/singbox/outbound.go b/core/singbox/outbound.go index b0da56ff..f67a6d1c 100644 --- a/core/singbox/outbound.go +++ b/core/singbox/outbound.go @@ -209,7 +209,8 @@ const fragmentFallbackDelay = "500ms" // transportObject renders a model.Transport into the sing-box transport // sub-object, or nil for raw TCP (empty type). Only ws and grpc are commonly -// used; http/httpupgrade pass through with their path. +// used; http/httpupgrade carry their path and host. QUIC has no options in the +// bundled sing-box schema, so only its type is emitted. func transportObject(t *model.Transport) map[string]any { if t == nil || t.Type == "" { return nil @@ -230,6 +231,8 @@ func transportObject(t *model.Transport) map[string]any { o["service_name"] = t.ServiceName } return o + case "quic": + return map[string]any{"type": "quic"} case "http", "httpupgrade": o := map[string]any{"type": t.Type} if t.Path != "" { From f42cb3c32602870e6ce975d765bf4c6a1c7693ff Mon Sep 17 00:00:00 2001 From: DivanMe <48186011+Divaaaan@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:13:14 +0300 Subject: [PATCH 2/2] fix: require tls for v2ray quic --- core/singbox/builder.go | 10 ++++++++-- core/singbox/builder_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/core/singbox/builder.go b/core/singbox/builder.go index 5551cf51..853a635c 100644 --- a/core/singbox/builder.go +++ b/core/singbox/builder.go @@ -460,7 +460,9 @@ func SelectorTags(nodes []model.Node) []NodeTag { // - port must be a valid uint16 (1..65535), for every protocol; // - stream transports are limited to the variants bundled sing-box supports // and transportObject renders safely; an unknown type must be skipped -// instead of being passed through and poisoning the shared config; +// instead of being passed through and poisoning the shared config; VLESS +// and VMess QUIC additionally require TLS because sing-box's QUIC client +// cannot be constructed without a TLS config; // - Shadowsocks needs a method AND a password, and must carry no transport // plugin — shadowsocksOutbound emits none of sing-box's plugin/plugin_opts // fields, so a plugin node would build a plain outbound that fails its @@ -478,8 +480,12 @@ func validateNode(n model.Node) error { } if n.Transport != nil { switch n.Transport.Type { - case "", "ws", "grpc", "http", "httpupgrade", "quic": + case "", "ws", "grpc", "http", "httpupgrade": // These are the stream transports transportObject renders natively. + case "quic": + if (n.Protocol == model.VLESS || n.Protocol == model.VMess) && (n.TLS == nil || !n.TLS.Enabled) { + return fmt.Errorf("node %q: quic transport requires tls for %s", n.Name, n.Protocol) + } default: return fmt.Errorf("node %q: transport %q is not supported", n.Name, n.Transport.Type) } diff --git a/core/singbox/builder_test.go b/core/singbox/builder_test.go index a845d920..9c6372c8 100644 --- a/core/singbox/builder_test.go +++ b/core/singbox/builder_test.go @@ -793,6 +793,41 @@ func TestSupportedQUICTransportSurvives(t *testing.T) { } } +// TestQUICTransportWithoutTLSSkipped covers malformed VLESS/VMess links such +// as type=quic without security=tls. Bundled sing-box's QUIC client requires a +// TLS config, so these nodes must not reach the shared config and sink their +// healthy profile neighbours. +func TestQUICTransportWithoutTLSSkipped(t *testing.T) { + for _, protocol := range []model.Protocol{model.VLESS, model.VMess} { + t.Run(string(protocol), func(t *testing.T) { + bad := model.Node{ + Protocol: protocol, + Name: "quic-no-tls", + Server: "quic.example.test", + Port: 443, + UUID: "66666666-6666-6666-6666-666666666666", + Transport: &model.Transport{Type: "quic"}, + } + + cfg, err := Build([]model.Node{bad, goodSSNode("plain")}, "quic-no-tls", + routing.Options{Mode: routing.ModeGlobal}, TunOptions{}) + if err != nil { + t.Fatalf("malformed quic node should be skipped, not poison the profile: %v", err) + } + by := outboundsByTag(t, cfg) + if _, has := by["quic-no-tls"]; has { + t.Error("quic transport without TLS must not appear in outbounds") + } + if _, has := by["plain"]; !has { + t.Errorf("healthy node must survive; tags %v", keys(by)) + } + if ValidateNode(bad) { + t.Error("ValidateNode should reject quic without TLS") + } + }) + } +} + func TestNoUsableNodes(t *testing.T) { _, err := Build(nil, "", routing.Options{Mode: routing.ModeSmart}, TunOptions{}) if err == nil {