Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions core/singbox/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,11 @@ 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; 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
Expand All @@ -473,6 +478,18 @@ 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":
// 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)
}
}
switch n.Protocol {
case model.Shadowsocks:
if n.Method == "" || n.Password == "" {
Expand Down
126 changes: 126 additions & 0 deletions core/singbox/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,132 @@ 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")
}
}

// 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 {
Expand Down
5 changes: 4 additions & 1 deletion core/singbox/outbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 != "" {
Expand Down
Loading