mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-08 05:36:09 +00:00
cc3303dd8c
A Host's Final Mask was merged into the JSON and Clash subscription outputs via applyHostStreamOverrides, but the raw link builders compute the fm param once from the inbound's own streamSettings.finalmask before the per-host fan-out, and the endpoint override path never read the host's mask. A Final Mask configured only on a host was silently dropped from vless/trojan/ss/vmess share links while an inbound-level mask worked everywhere. Merge the host mask into the fm param per endpoint with the same additive semantics as the JSON path (host tcp/udp masks appended to the inbound's, quicParams only when the inbound has none), for both the URL-param and the VMess object link forms. Closes #5831
158 lines
6.6 KiB
Go
158 lines
6.6 KiB
Go
package sub
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
// N1 — externalProxyToEndpoint maps the scalar fields and carries the source
|
|
// entry so delegated TLS application reproduces the legacy presence-tracked
|
|
// overrides (absent key never clobbers an upstream value).
|
|
func TestExternalProxyToEndpoint(t *testing.T) {
|
|
ep := map[string]any{
|
|
"forceTls": "tls",
|
|
"dest": "cdn.example.com",
|
|
"port": float64(8443),
|
|
"remark": "R",
|
|
"sni": "s.example.com",
|
|
}
|
|
e := externalProxyToEndpoint(ep)
|
|
if e.Address != "cdn.example.com" {
|
|
t.Fatalf("Address = %q, want cdn.example.com", e.Address)
|
|
}
|
|
if e.Port != 8443 {
|
|
t.Fatalf("Port = %d, want 8443", e.Port)
|
|
}
|
|
if e.ForceTls != "tls" {
|
|
t.Fatalf("ForceTls = %q, want tls", e.ForceTls)
|
|
}
|
|
if e.Remark != "R" {
|
|
t.Fatalf("Remark = %q, want R", e.Remark)
|
|
}
|
|
if e.ep == nil {
|
|
t.Fatalf("ep not carried; delegated TLS application would lose the source entry")
|
|
}
|
|
// Delegation preserves the sni override and does not invent absent fields.
|
|
params := map[string]string{}
|
|
applyEndpointTLSParams(e, params, "tls")
|
|
if params["sni"] != "s.example.com" {
|
|
t.Fatalf("delegated sni = %q, want s.example.com", params["sni"])
|
|
}
|
|
if _, ok := params["fp"]; ok {
|
|
t.Fatalf("absent fingerprint must not be set, got fp=%q", params["fp"])
|
|
}
|
|
}
|
|
|
|
// N2 — inboundDefaultEndpoint reproduces the no-externalProxy default: resolved
|
|
// address + inbound port, forceTls "same", empty remark, no source entry.
|
|
func TestInboundDefaultEndpoint(t *testing.T) {
|
|
in := &model.Inbound{Listen: "198.51.100.7", Port: 8080}
|
|
s := &SubService{}
|
|
e := s.inboundDefaultEndpoint(in)
|
|
if e.Address != "198.51.100.7" {
|
|
t.Fatalf("Address = %q, want 198.51.100.7", e.Address)
|
|
}
|
|
if e.Port != 8080 {
|
|
t.Fatalf("Port = %d, want 8080", e.Port)
|
|
}
|
|
if e.ForceTls != "same" {
|
|
t.Fatalf("ForceTls = %q, want same", e.ForceTls)
|
|
}
|
|
if e.Remark != "" {
|
|
t.Fatalf("Remark = %q, want empty", e.Remark)
|
|
}
|
|
if e.ep != nil {
|
|
t.Fatalf("default endpoint must not carry a source externalProxy entry")
|
|
}
|
|
}
|
|
|
|
// N3 — buildEndpointLinks renders the param-form path: one link per endpoint,
|
|
// TLS override applied for tls, fields stripped + security overridden for none,
|
|
// joined by "\n", in order.
|
|
func TestBuildEndpointLinks_ParamForm(t *testing.T) {
|
|
s := &SubService{}
|
|
in := &model.Inbound{Remark: "ib"}
|
|
params := map[string]string{"type": "tcp", "security": "tls", "sni": "base.sni", "fp": "chrome"}
|
|
eps := []ShareEndpoint{
|
|
externalProxyToEndpoint(map[string]any{"forceTls": "tls", "dest": "a.example.com", "port": float64(8443), "remark": "A", "sni": "a.sni"}),
|
|
externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
|
|
}
|
|
got := s.buildEndpointLinks(eps, params, "tls",
|
|
func(e ShareEndpoint) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(e.Address, e.Port)) },
|
|
func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark, "") },
|
|
)
|
|
want := "vless://uid@a.example.com:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A-user\n" +
|
|
"vless://uid@b.example.com:80?security=none&type=tcp#ib-B-user"
|
|
if got != want {
|
|
t.Fatalf("N3 mismatch.\n got: %q\nwant: %q", got, want)
|
|
}
|
|
}
|
|
|
|
// N4 — buildEndpointVmessLinks renders the object-form path: base obj cloned per
|
|
// endpoint, add/port/tls rewritten, sni override applied, none-strip honored.
|
|
func TestBuildEndpointVmessLinks(t *testing.T) {
|
|
s := &SubService{}
|
|
in := &model.Inbound{Remark: "ib"}
|
|
baseObj := map[string]any{
|
|
"v": "2", "add": "base.example.com", "port": 443, "type": "none",
|
|
"id": "uid", "scy": "auto", "net": "tcp",
|
|
"tls": "tls", "sni": "base.sni", "alpn": "h2", "fp": "chrome",
|
|
}
|
|
eps := []ShareEndpoint{
|
|
externalProxyToEndpoint(map[string]any{"forceTls": "same", "dest": "a.example.com", "port": float64(8443), "remark": "A", "sni": "a.sni"}),
|
|
externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
|
|
}
|
|
got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
|
|
want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEtdXNlciIsCiAgInNjeSI6ICJhdXRvIiwKICAic25pIjogImEuc25pIiwKICAidGxzIjogInRscyIsCiAgInR5cGUiOiAibm9uZSIsCiAgInYiOiAiMiIKfQ==\n" +
|
|
"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUItdXNlciIsCiAgInNjeSI6ICJhdXRvIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0="
|
|
if got != want {
|
|
t.Fatalf("N4 mismatch.\n got: %q\nwant: %q", got, want)
|
|
}
|
|
}
|
|
|
|
// N5 — a host's Final Mask is appended to the inbound's own fm param (#5831).
|
|
func TestBuildEndpointLinks_HostFinalMaskMerge(t *testing.T) {
|
|
s := &SubService{}
|
|
in := &model.Inbound{Remark: "ib"}
|
|
params := map[string]string{"type": "tcp", "security": "tls", "fm": `{"tcp":[{"type":"sudoku"}]}`}
|
|
eps := []ShareEndpoint{
|
|
externalProxyToEndpoint(map[string]any{"forceTls": "same", "dest": "a.example.com", "port": float64(8443), "remark": "A", "isHost": true, "finalMask": `{"tcp":[{"type":"fragment"}]}`}),
|
|
}
|
|
got := s.buildEndpointLinks(eps, params, "tls",
|
|
func(e ShareEndpoint) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(e.Address, e.Port)) },
|
|
func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark, "") },
|
|
)
|
|
wantFm := "fm=" + url.QueryEscape(`{"tcp":[{"type":"sudoku"},{"type":"fragment"}]}`)
|
|
if !strings.Contains(got, wantFm) {
|
|
t.Fatalf("host finalMask not merged into the fm param.\n got: %q\nwant substring: %q", got, wantFm)
|
|
}
|
|
}
|
|
|
|
// N6 — same for the VMess object form: the host mask lands in obj["fm"].
|
|
func TestBuildEndpointVmessLinks_HostFinalMask(t *testing.T) {
|
|
s := &SubService{}
|
|
in := &model.Inbound{Remark: "ib"}
|
|
baseObj := map[string]any{"v": "2", "add": "base.example.com", "port": 443, "type": "none", "id": "uid", "scy": "auto", "net": "tcp", "tls": "tls"}
|
|
eps := []ShareEndpoint{
|
|
externalProxyToEndpoint(map[string]any{"forceTls": "same", "dest": "a.example.com", "port": float64(8443), "remark": "A", "isHost": true, "finalMask": `{"udp":[{"type":"salamander"}]}`}),
|
|
}
|
|
got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
|
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(got, "vmess://"))
|
|
if err != nil {
|
|
t.Fatalf("decode vmess link: %v", err)
|
|
}
|
|
var obj map[string]any
|
|
if err := json.Unmarshal(raw, &obj); err != nil {
|
|
t.Fatalf("unmarshal vmess obj: %v", err)
|
|
}
|
|
if fm, _ := obj["fm"].(string); fm != `{"udp":[{"type":"salamander"}]}` {
|
|
t.Fatalf("vmess fm = %q, want the host mask", obj["fm"])
|
|
}
|
|
}
|