fix(sub): give every host endpoint its own deep copy of the stream

cloneStreamForExternalProxy deep-copied only tlsSettings, so the hosts of
one inbound shared realitySettings and wsSettings/xhttpSettings within a
request. applyHostStreamOverrides writes SNI, fingerprint, Host and path
into those maps in place, so a host that leaves one blank inherited the
previous host's value in Clash and JSON while its vless:// link kept the
inbound's. Deep-copy the whole stream per endpoint.
This commit is contained in:
Toximiner 2026-10-12 22:43:23 +03:00
parent 3660650c4b
commit 0aaba17d59
2 changed files with 136 additions and 14 deletions

View file

@ -550,6 +550,114 @@ func TestSub_HostRealityFingerprintJSONAndClash(t *testing.T) {
}
}
// Hosts of one inbound render from per-host copies of the same stream; a host
// that leaves an override blank must get the inbound's value, not the previous host's.
func TestSub_HostBlankOverridesDoNotLeakBetweenHosts(t *testing.T) {
for _, tc := range []struct {
name string
stream string
first model.Host
clashWant map[string]string
jsonField func(stream map[string]any) any
jsonWant any
}{
{
name: "reality fingerprint and sni",
stream: mihomoRealityStream,
first: model.Host{Security: "reality", Sni: "a.reality.com", Fingerprint: "firefox"},
clashWant: map[string]string{"client-fingerprint": "chrome", "servername": "base.reality.com"},
jsonField: func(stream map[string]any) any {
rs, _ := stream["realitySettings"].(map[string]any)
return rs["fingerprint"]
},
jsonWant: "chrome",
},
{
name: "ws host header and path",
stream: wsTLSStream,
first: model.Host{Security: "tls", HostHeader: "a.host", Path: "/a"},
clashWant: map[string]string{"ws-opts.path": "/base", "ws-opts.headers.Host": "base.host"},
jsonField: func(stream map[string]any) any {
ws, _ := stream["wsSettings"].(map[string]any)
return ws["path"]
},
jsonWant: "/base",
},
} {
t.Run(tc.name, func(t *testing.T) {
seedSubDB(t)
ib := seedSubInbound(t, "s1", "lk", 4494, 1, tc.stream)
first := tc.first
first.InboundId, first.SortOrder, first.Remark, first.Address, first.Port = ib.Id, 0, "A", "a.cdn.com", 443
seedHost(t, &first)
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "B", Address: "b.cdn.com", Port: 443, Security: tc.first.Security})
out, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("s1", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
second := clashProxyByServer(t, out, "b.cdn.com")
for field, want := range tc.clashWant {
if got := lookupPath(second, field); got != want {
t.Errorf("host B clash %s = %v, want the inbound's %q", field, got, want)
}
}
out, _, err = NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
var configs []map[string]any
if err := json.Unmarshal([]byte(out), &configs); err != nil || len(configs) != 2 {
t.Fatalf("want one JSON config per host, got err=%v:\n%s", err, out)
}
if got := tc.jsonField(proxyStreamSettings(t, configs[1])); got != tc.jsonWant {
t.Errorf("host B json value = %v, want the inbound's %v", got, tc.jsonWant)
}
})
}
}
func clashProxyByServer(t *testing.T, out, server string) map[string]any {
t.Helper()
var config struct {
Proxies []map[string]any `yaml:"proxies"`
}
if err := yaml.Unmarshal([]byte(out), &config); err != nil {
t.Fatalf("decode Clash YAML: %v\n%s", err, out)
}
for _, proxy := range config.Proxies {
if proxy["server"] == server {
return proxy
}
}
t.Fatalf("no clash proxy for %s in:\n%s", server, out)
return nil
}
func lookupPath(m map[string]any, path string) any {
var cur any = m
for _, key := range strings.Split(path, ".") {
next, _ := cur.(map[string]any)
cur = next[key]
}
return cur
}
func proxyStreamSettings(t *testing.T, doc map[string]any) map[string]any {
t.Helper()
outbounds, _ := doc["outbounds"].([]any)
for _, ob := range outbounds {
outbound, _ := ob.(map[string]any)
if outbound["tag"] == "proxy" {
stream, _ := outbound["streamSettings"].(map[string]any)
return stream
}
}
t.Fatalf("no proxy outbound in %v", doc)
return nil
}
const mihomoRealityStream = `{"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},"realitySettings":{"serverNames":["base.reality.com"],"shortIds":["abcd"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}}}`
// seedMihomoX25519Hosts puts a default host and a "Mihomo X25519" host on one

View file

@ -2118,24 +2118,38 @@ func applyExternalProxyHysteriaParams(ep map[string]any, params map[string]strin
}
}
// cloneStreamForExternalProxy returns a shallow clone of stream with
// tlsSettings (and its nested settings map) deep-copied. The external
// proxy loop mutates tlsSettings per iteration, so without isolating
// those maps each proxy's SNI/fingerprint/ALPN would leak into the next.
// cloneStreamForExternalProxy deep-copies stream: every endpoint overrides nested
// maps in place, so a shared map leaks one host's SNI/fp/host/path into the next.
func cloneStreamForExternalProxy(stream map[string]any) map[string]any {
out := cloneMap(stream)
ts, ok := out["tlsSettings"].(map[string]any)
if !ok || ts == nil {
return out
}
clonedTs := cloneMap(ts)
if inner, ok := clonedTs["settings"].(map[string]any); ok && inner != nil {
clonedTs["settings"] = cloneMap(inner)
}
out["tlsSettings"] = clonedTs
out, _ := deepCopyJSONValue(stream).(map[string]any)
return out
}
func deepCopyJSONValue(v any) any {
switch val := v.(type) {
case map[string]any:
if val == nil {
return val
}
out := make(map[string]any, len(val))
for k, item := range val {
out[k] = deepCopyJSONValue(item)
}
return out
case []any:
if val == nil {
return val
}
out := make([]any, len(val))
for i, item := range val {
out[i] = deepCopyJSONValue(item)
}
return out
default:
return v
}
}
func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, security string) {
if security != "tls" {
return