mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
05abd00bf6
joinHostPort wrote an endpoint address into the authority of every URL share link unencoded, and genVlessLink alone skipped encodeUserinfo for the client id. Both can come from a node's sync (an externalProxy dest, an adopted host row, the client id), the finished link is never re-parsed, and the raw subscription is one link per line: a line break in either added lines of the node's choosing to the master's subscription, and an id carrying "@" moved the client's VLESS links on other inbounds to another server. Whitespace, control characters and URI delimiters in the host are now percent-encoded, which leaves host names, IP literals and zone ids unchanged, and the VLESS id is encoded as userinfo like every other protocol's credential.
234 lines
9.9 KiB
Go
234 lines
9.9 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)
|
|
}
|
|
}
|
|
|
|
// happ.su documents serverDescription for VMess as a JSON object key
|
|
// {"add":"...","ps":"...","serverDescription":"Happ the best"}.
|
|
func TestBuildEndpointVmessLinks_HostServerDescription(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": "none"}
|
|
host := &model.Host{Address: "a.example.com", Port: 8443, ServerDescription: "Berlin premium"}
|
|
eps := []ShareEndpoint{externalProxyToEndpoint(hostToExternalProxyMap(host, "a.example.com", 8443))}
|
|
|
|
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 object: %v", err)
|
|
}
|
|
if obj["add"] != "a.example.com" {
|
|
t.Fatalf("host endpoint not applied: add = %v", obj["add"])
|
|
}
|
|
if obj["serverDescription"] != "Berlin premium" {
|
|
t.Fatalf("VMess object missing serverDescription: got %v, want Berlin premium", obj["serverDescription"])
|
|
}
|
|
}
|
|
|
|
// 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"])
|
|
}
|
|
}
|
|
|
|
// An endpoint address is mirrored from the node as reported: whatever it holds, one
|
|
// endpoint stays one line of the subscription body, inside its own link's authority.
|
|
func TestSub_EndpointAddressCannotAddLines(t *testing.T) {
|
|
seedSubDB(t)
|
|
dest, _ := json.Marshal("::1]:443#a\nincy://routing/onadd/e30=\r\nvless://x@[::2")
|
|
seedSubInbound(t, "s1", "n", 4490, 1, fmt.Sprintf(
|
|
`{"network":"tcp","security":"none","externalProxy":[{"forceTls":"same","dest":%s,"port":443,"remark":""}]}`, dest))
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
want := "vless://11111111-2222-4333-8444-000000004490@" +
|
|
"[::1%5D:443%23a%0Aincy:%2F%2Frouting%2Fonadd%2Fe30=%0D%0Avless:%2F%2Fx%40%5B::2]:443" +
|
|
"?encryption=none&security=none&type=tcp#n-n%40e"
|
|
if len(links) != 1 || links[0] != want {
|
|
t.Fatalf("links = %q, want [%q]", links, want)
|
|
}
|
|
}
|
|
|
|
func TestJoinHostPortEscapesNonHostCharacters(t *testing.T) {
|
|
for host, want := range map[string]string{
|
|
"cdn.example.com": "cdn.example.com:443",
|
|
"203.0.113.5": "203.0.113.5:443",
|
|
"[2001:db8::1]": "[2001:db8::1]:443",
|
|
"fe80::1%eth0": "[fe80::1%eth0]:443",
|
|
"пример.рф": "пример.рф:443",
|
|
"a b\tc": "a%20b%09c:443",
|
|
"a\u0085b\u2028c": "a%C2%85b%E2%80%A8c:443",
|
|
"h?x=1#f": "h%3Fx=1%23f:443",
|
|
"u@h/p": "u%40h%2Fp:443",
|
|
} {
|
|
if got := joinHostPort(host, 443); got != want {
|
|
t.Errorf("joinHostPort(%q) = %q, want %q", host, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The VLESS id is the userinfo of its link and is escaped like every other protocol's.
|
|
func TestGenVlessLinkEscapesID(t *testing.T) {
|
|
in := &model.Inbound{
|
|
Listen: "203.0.113.5", Port: 443, Protocol: model.VLESS, Remark: "n",
|
|
Settings: `{"clients":[{"id":"a@b#c\nd","email":"user"}],"decryption":"none"}`,
|
|
StreamSettings: `{"network":"tcp","security":"none"}`,
|
|
}
|
|
got := NewSubService("").genVlessLink(in, "user")
|
|
if !strings.HasPrefix(got, "vless://a%40b%23c%0Ad@203.0.113.5:443?") {
|
|
t.Fatalf("vless id not escaped: %q", got)
|
|
}
|
|
}
|