mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-23 06:15:01 +03:00
61b129d5b3
Bump xtls/xray-core to 1b1647cd9ddf (v26.10.21) and move the three binary pins (DockerInit.sh, both release.yml fetches) in lockstep. The v26.10.10 release was pulled upstream, so the old pins already 404. XDRIVE (#7182): the core now runs XDRIVE only for VLESS inside VLESS encryption, with no flow, and an outbound only over Mux; it refuses the whole config otherwise. The panel offers XDRIVE only for VLESS, refuses to save an inbound without decryption, never emits a client flow on an XDRIVE stream (config, JSON subscription, forms), and turns Mux on when an outbound picks XDRIVE. An inbound or template outbound stored before the upgrade that the core would refuse is left out of the generated config with a warning, so it no longer stops every other inbound. The service "Google Drive" is now "google-drive" (old name read as a deprecated alias); saves, the schema and both form adapters store the new name. XDRIVE and MASQUE gain a client xmux; both schemas keep it instead of stripping it on an inbound save. Lua scripts (#7124, #7136): the core refuses a routing script beside rules or domainStrategy, and a dns script beside the fallback/parallel switches or a domain-scoped server. The panel always keeps its api routing rule, so a template save now refuses any routing script and a dns script in a refused combination, and a stored one is dropped from the generated config so the core still starts. Noise masks gain a Lua script that the loader compiles inside Build: the in-process build (template save check, hot add) now resolves a relative script the way the xray child does, beside the xray binary, instead of failing it. The mask editor gets a Script field that replaces the noise list. XDNS (#7140): a resolver takes https:// DoH addresses with worker, tlsConfig and quicConfig. The mask editor registered only addrs, so a save dropped the rest; it now edits all four. Finalmask filter (#7206): a new TCP/UDP mask screens an inbound's source IPs. The inbound mask editor offers it with its allow and block lists; it is a no-op on a dialer and an older client core refuses the type, so the share link and JSON subscription drop it like the Go link already does.
588 lines
22 KiB
Go
588 lines
22 KiB
Go
package sub
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/url"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
|
)
|
|
|
|
func hasDirectOutOutbound(svc *SubJsonService) bool {
|
|
for _, raw := range svc.defaultOutbounds {
|
|
var outbound map[string]any
|
|
if err := json.Unmarshal(raw, &outbound); err != nil {
|
|
continue
|
|
}
|
|
if outbound["tag"] == "direct_out" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func outboundSettings(t *testing.T, raw []byte) map[string]any {
|
|
t.Helper()
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
t.Fatalf("failed to unmarshal outbound: %v", err)
|
|
}
|
|
settings, _ := parsed["settings"].(map[string]any)
|
|
if settings == nil {
|
|
t.Fatal("outbound has no settings")
|
|
}
|
|
return settings
|
|
}
|
|
|
|
func TestDefaultJSONUsesCompatibleLocalInbounds(t *testing.T) {
|
|
svc := NewSubJsonService("", "", "", "", nil)
|
|
inbounds, ok := svc.configJson["inbounds"].([]any)
|
|
if !ok {
|
|
t.Fatalf("default JSON inbounds = %#v, want array", svc.configJson["inbounds"])
|
|
}
|
|
|
|
byPort := make(map[float64]map[string]any, len(inbounds))
|
|
for _, raw := range inbounds {
|
|
inbound, ok := raw.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("default JSON inbound = %#v, want object", raw)
|
|
}
|
|
port, ok := inbound["port"].(float64)
|
|
if !ok {
|
|
t.Fatalf("default JSON inbound port = %#v, want number", inbound["port"])
|
|
}
|
|
byPort[port] = inbound
|
|
}
|
|
|
|
socks := byPort[10808]
|
|
if socks == nil {
|
|
t.Fatal("default JSON is missing the local inbound on port 10808")
|
|
}
|
|
if socks["protocol"] != "socks" || socks["tag"] != "mixed" {
|
|
t.Fatalf("port 10808 protocol/tag = %v/%v, want socks/mixed", socks["protocol"], socks["tag"])
|
|
}
|
|
settings, _ := socks["settings"].(map[string]any)
|
|
if settings == nil || settings["udp"] != true {
|
|
t.Fatalf("port 10808 settings = %#v, want udp enabled", socks["settings"])
|
|
}
|
|
if socks["listen"] != "127.0.0.1" {
|
|
t.Fatalf("port 10808 listen = %#v, want 127.0.0.1 (an unbound local inbound is exposed to the LAN and is not reachable by iOS packet tunnels)", socks["listen"])
|
|
}
|
|
|
|
http := byPort[10809]
|
|
if http == nil || http["protocol"] != "http" {
|
|
t.Fatalf("port 10809 inbound = %#v, want http protocol", http)
|
|
}
|
|
if http["listen"] != "127.0.0.1" {
|
|
t.Fatalf("port 10809 listen = %#v, want 127.0.0.1", http["listen"])
|
|
}
|
|
}
|
|
|
|
func TestSubJsonServiceVisionFlowDisablesTCPMuxOnly(t *testing.T) {
|
|
globalMux := `{"enabled":true,"concurrency":8,"xudpConcurrency":16,"xudpProxyUDP443":"reject"}`
|
|
svc := NewSubJsonService(globalMux, "", "", "", nil)
|
|
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
|
|
|
|
decode := func(raw []byte) map[string]any {
|
|
t.Helper()
|
|
var ob map[string]any
|
|
if err := json.Unmarshal(raw, &ob); err != nil {
|
|
t.Fatalf("unmarshal outbound: %v", err)
|
|
}
|
|
return ob
|
|
}
|
|
|
|
vision := decode([]byte(svc.genVless(&SubService{}, inbound, nil, model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}, globalMux)))
|
|
mux, _ := vision["mux"].(map[string]any)
|
|
if mux == nil {
|
|
t.Fatalf("vision outbound must keep its mux object for the XUDP keys, got %#v", vision["mux"])
|
|
}
|
|
if mux["concurrency"] != float64(-1) {
|
|
t.Fatalf("vision outbound mux.concurrency = %v, want -1 (TCP mux.cool is rejected by XTLS flows)", mux["concurrency"])
|
|
}
|
|
if mux["enabled"] != true || mux["xudpConcurrency"] != float64(16) || mux["xudpProxyUDP443"] != "reject" {
|
|
t.Fatalf("vision outbound lost its XUDP settings: %#v", mux)
|
|
}
|
|
|
|
plain := decode([]byte(svc.genVless(&SubService{}, inbound, nil, model.Client{ID: "uuid-1"}, globalMux)))
|
|
mux, _ = plain["mux"].(map[string]any)
|
|
if mux == nil || mux["concurrency"] != float64(8) {
|
|
t.Fatalf("flow-less outbound must keep the global mux unchanged, got %#v", plain["mux"])
|
|
}
|
|
|
|
noMux := decode([]byte(svc.genVless(&SubService{}, inbound, nil, model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}, "")))
|
|
if _, has := noMux["mux"]; has {
|
|
t.Fatalf("no global mux must still mean no mux key, got %#v", noMux["mux"])
|
|
}
|
|
}
|
|
|
|
func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
|
|
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
|
|
svc := NewSubJsonService("", "", finalMask, "", nil)
|
|
|
|
if hasDirectOutOutbound(svc) {
|
|
t.Fatal("direct_out outbound must never be emitted")
|
|
}
|
|
|
|
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
|
|
if _, ok := stream["sockopt"]; ok {
|
|
t.Fatal("legacy direct_out dialerProxy sockopt must never be set")
|
|
}
|
|
|
|
finalmask, _ := stream["finalmask"].(map[string]any)
|
|
if finalmask == nil {
|
|
t.Fatal("streamSettings is missing finalmask")
|
|
}
|
|
|
|
tcp, _ := finalmask["tcp"].([]any)
|
|
if len(tcp) != 1 {
|
|
t.Fatalf("tcp masks len = %d, want 1", len(tcp))
|
|
}
|
|
if first, _ := tcp[0].(map[string]any); first["type"] != "fragment" {
|
|
t.Fatalf("tcp[0] type = %v, want fragment", first["type"])
|
|
}
|
|
|
|
udp, _ := finalmask["udp"].([]any)
|
|
if len(udp) != 1 {
|
|
t.Fatalf("udp masks len = %d, want 1", len(udp))
|
|
}
|
|
|
|
quic, _ := finalmask["quicParams"].(map[string]any)
|
|
if quic == nil || quic["congestion"] != "bbr" {
|
|
t.Fatalf("quicParams missing/wrong: %#v", finalmask["quicParams"])
|
|
}
|
|
}
|
|
|
|
func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
|
|
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
|
|
svc := NewSubJsonService("", "", finalMask, "", nil)
|
|
|
|
stream := svc.streamData(`{
|
|
"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
|
|
"finalmask":{"tcp":[{"type":"sudoku"}]}
|
|
}`, "")
|
|
|
|
finalmask, _ := stream["finalmask"].(map[string]any)
|
|
tcp, _ := finalmask["tcp"].([]any)
|
|
if len(tcp) != 2 {
|
|
t.Fatalf("tcp masks len = %d, want 2 (existing + global)", len(tcp))
|
|
}
|
|
a, _ := tcp[0].(map[string]any)
|
|
b, _ := tcp[1].(map[string]any)
|
|
if a["type"] != "sudoku" || b["type"] != "fragment" {
|
|
t.Fatalf("tcp masks = %#v, want existing sudoku then global fragment", tcp)
|
|
}
|
|
}
|
|
|
|
// The inbound "filter" mask (xray-core 26.10.21) screens the listener's source IPs: it would hand
|
|
// clients the server's IP policy, and a client core older than it refuses the unknown type.
|
|
func TestSubJsonServiceDropsTheServerOnlyFilterMask(t *testing.T) {
|
|
svc := NewSubJsonService("", "", "", "", nil)
|
|
stream := svc.streamData(`{"network":"kcp","security":"none","finalmask":{
|
|
"tcp":[{"type":"filter","settings":{"allowedSourceIPs":["10.0.0.0/8"]}}],
|
|
"udp":[{"type":"filter","settings":{"blockedSourceIPs":["geoip:cn"]}},{"type":"mkcp-legacy","settings":{"header":""}}]}}`, "")
|
|
|
|
finalmask, _ := stream["finalmask"].(map[string]any)
|
|
if _, ok := finalmask["tcp"]; ok {
|
|
t.Fatalf("finalmask.tcp = %#v, want it gone with its only mask", finalmask["tcp"])
|
|
}
|
|
udp, _ := finalmask["udp"].([]any)
|
|
if len(udp) != 1 {
|
|
t.Fatalf("finalmask.udp = %#v, want only the mkcp-legacy mask", udp)
|
|
}
|
|
if mask, _ := udp[0].(map[string]any); mask["type"] != "mkcp-legacy" {
|
|
t.Fatalf("finalmask.udp[0] = %#v, want mkcp-legacy", mask)
|
|
}
|
|
}
|
|
|
|
func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
|
|
svc := NewSubJsonService("", "", "", "", nil)
|
|
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
|
|
if _, ok := stream["finalmask"]; ok {
|
|
t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
|
|
}
|
|
if _, ok := stream["sockopt"]; ok {
|
|
t.Fatal("legacy direct_out sockopt must never be set")
|
|
}
|
|
}
|
|
|
|
// xray-core parses tlsSettings.pinnedPeerCertSha256 as a comma-separated string;
|
|
// the JSON subscription must emit that form, not an array, or v2ray clients fail
|
|
// to import the config (#5401).
|
|
func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) {
|
|
svc := NewSubJsonService("", "", "", "", nil)
|
|
stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`, "")
|
|
|
|
tls, _ := stream["tlsSettings"].(map[string]any)
|
|
if tls == nil {
|
|
t.Fatalf("tlsSettings missing: %#v", stream)
|
|
}
|
|
if got := tls["pinnedPeerCertSha256"]; got != "aa11,bb22" {
|
|
t.Fatalf("pinnedPeerCertSha256 = %#v, want comma-separated string \"aa11,bb22\"", got)
|
|
}
|
|
}
|
|
|
|
func TestSubJsonServiceTLSCipherSuitesForwarded(t *testing.T) {
|
|
svc := NewSubJsonService("", "", "", "", nil)
|
|
stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","cipherSuites":"TLS_AES_256_GCM_SHA384","settings":{}}}`, "")
|
|
|
|
tls, _ := stream["tlsSettings"].(map[string]any)
|
|
if got := tls["cipherSuites"]; got != "TLS_AES_256_GCM_SHA384" {
|
|
t.Fatalf("cipherSuites = %#v, want %q", got, "TLS_AES_256_GCM_SHA384")
|
|
}
|
|
|
|
stream = svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","cipherSuites":"","settings":{}}}`, "")
|
|
tls, _ = stream["tlsSettings"].(map[string]any)
|
|
if _, present := tls["cipherSuites"]; present {
|
|
t.Fatalf("empty cipherSuites must be omitted, got %#v", tls["cipherSuites"])
|
|
}
|
|
}
|
|
|
|
func TestSubJsonServiceVlessFlattened(t *testing.T) {
|
|
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
|
|
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
|
|
|
|
settings := outboundSettings(t, NewSubJsonService("", "", "", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
|
|
if _, ok := settings["vnext"]; ok {
|
|
t.Fatal("vless outbound must not use vnext")
|
|
}
|
|
if settings["address"] != "1.2.3.4" || settings["id"] != "uuid-1" || settings["encryption"] != "none" || settings["flow"] != "xtls-rprx-vision" {
|
|
t.Fatalf("flat vless settings wrong: %#v", settings)
|
|
}
|
|
}
|
|
|
|
// VLESS inbounds stored without settings.encryption (pre-2.6.8 rows, API adds)
|
|
// must still give clients "none": xray refuses a VLESS outbound with "".
|
|
func TestSubVlessEncryptionDefaultsToNone(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
settings string
|
|
want string
|
|
}{
|
|
{"missing", `{"clients":[{"id":"uuid-1","email":"user"}],"decryption":"none"}`, "none"},
|
|
{"empty", `{"clients":[{"id":"uuid-1","email":"user"}],"decryption":"none","encryption":""}`, "none"},
|
|
{"vlessenc kept", `{"clients":[{"id":"uuid-1","email":"user"}],"decryption":"mlkem768x25519plus.native.600s.d","encryption":"mlkem768x25519plus.native.0rtt.e"}`, "mlkem768x25519plus.native.0rtt.e"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: tc.settings, StreamSettings: `{"network":"tcp","security":"none"}`}
|
|
|
|
settings := outboundSettings(t, NewSubJsonService("", "", "", "", nil).genVless(&SubService{}, inbound, nil, model.Client{ID: "uuid-1"}, ""))
|
|
if settings["encryption"] != tc.want {
|
|
t.Fatalf("json outbound encryption = %#v, want %q", settings["encryption"], tc.want)
|
|
}
|
|
|
|
link, err := url.Parse((&SubService{}).genVlessLink(inbound, "user"))
|
|
if err != nil {
|
|
t.Fatalf("parse link: %v", err)
|
|
}
|
|
if got := link.Query().Get("encryption"); got != tc.want {
|
|
t.Fatalf("share link encryption = %q, want %q (%s)", got, tc.want, link)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSubJsonServiceVlessFlowSuppressedByDisableFlow(t *testing.T) {
|
|
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`, DisableFlow: true}
|
|
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
|
|
|
|
settings := outboundSettings(t, NewSubJsonService("", "", "", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
|
|
if _, ok := settings["flow"]; ok {
|
|
t.Fatalf("DisableFlow inbound must not carry a flow in the JSON outbound: %#v", settings)
|
|
}
|
|
}
|
|
|
|
func TestSubJsonServiceVmessFlattened(t *testing.T) {
|
|
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`}
|
|
client := model.Client{ID: "uuid-2"}
|
|
|
|
settings := outboundSettings(t, NewSubJsonService("", "", "", "", nil).genVnext(inbound, nil, client, ""))
|
|
if _, ok := settings["vnext"]; ok {
|
|
t.Fatal("vmess outbound must not use vnext")
|
|
}
|
|
if settings["id"] != "uuid-2" || settings["security"] != "auto" {
|
|
t.Fatalf("flat vmess settings wrong: %#v", settings)
|
|
}
|
|
}
|
|
|
|
// Shadowsocks/Trojan outbounds must use the standard "servers" array so older
|
|
// bundled xray-cores (e.g. v2rayN) parse them; the flat top-level form only
|
|
// works on very recent xray-core.
|
|
func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
|
|
trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
|
|
client := model.Client{Password: "p4ss"}
|
|
|
|
settings := outboundSettings(t, NewSubJsonService("", "", "", "", nil).genServer(&SubService{}, trojan, nil, client, ""))
|
|
server := firstServer(settings)
|
|
if server == nil {
|
|
t.Fatalf("trojan outbound must use a servers array, got: %#v", settings)
|
|
}
|
|
if server["password"] != "p4ss" || server["address"] != "1.2.3.4" {
|
|
t.Fatalf("trojan server entry wrong: %#v", server)
|
|
}
|
|
if _, ok := server["method"]; ok {
|
|
t.Fatalf("trojan must not carry method: %#v", server)
|
|
}
|
|
|
|
ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
|
|
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", "", nil).genServer(&SubService{}, ss, nil, client, ""))
|
|
ssServer := firstServer(ssSettings)
|
|
if ssServer == nil {
|
|
t.Fatalf("shadowsocks outbound must use a servers array, got: %#v", ssSettings)
|
|
}
|
|
if ssServer["method"] != "aes-256-gcm" {
|
|
t.Fatalf("shadowsocks server entry must carry method: %#v", ssServer)
|
|
}
|
|
}
|
|
|
|
func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
|
|
globalMux := `{"enabled":true,"concurrency":8}`
|
|
svc := NewSubJsonService(globalMux, "", "", "", nil)
|
|
|
|
// When xmux is present in xhttpSettings, the per-inbound xmux handles
|
|
// multiplexing and the legacy outbound.Mux must NOT be set.
|
|
stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up","xmux":{"maxConcurrency":"16-32"}}}`
|
|
parsed := svc.streamData(stream, "")
|
|
|
|
mux := globalMux
|
|
if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok {
|
|
if _, hasXmux := xhttp["xmux"]; hasXmux {
|
|
mux = ""
|
|
}
|
|
}
|
|
|
|
streamSettings, _ := json.Marshal(parsed)
|
|
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
|
|
client := model.Client{ID: "uuid-1"}
|
|
|
|
raw := svc.genVless(&SubService{}, inbound, streamSettings, client, mux)
|
|
var ob map[string]any
|
|
if err := json.Unmarshal(raw, &ob); err != nil {
|
|
t.Fatalf("unmarshal outbound: %v", err)
|
|
}
|
|
if _, has := ob["mux"]; has {
|
|
t.Fatal("outbound.Mux must NOT be set when per-inbound xmux is present")
|
|
}
|
|
|
|
// Verify xmux is still inside xhttpSettings in streamSettings.
|
|
ss, _ := ob["streamSettings"].(map[string]any)
|
|
if ss == nil {
|
|
t.Fatal("streamSettings missing from outbound")
|
|
}
|
|
xhttp, _ := ss["xhttpSettings"].(map[string]any)
|
|
if xhttp == nil {
|
|
t.Fatal("xhttpSettings missing from streamSettings")
|
|
}
|
|
xmux, _ := xhttp["xmux"].(map[string]any)
|
|
if xmux == nil {
|
|
t.Fatal("xmux missing from xhttpSettings — per-inbound xmux must survive streamData()")
|
|
}
|
|
if xmux["maxConcurrency"] != "16-32" {
|
|
t.Fatalf("xmux.maxConcurrency = %v, want 16-32", xmux["maxConcurrency"])
|
|
}
|
|
}
|
|
|
|
func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
|
|
globalMux := `{"enabled":true,"concurrency":8}`
|
|
svc := NewSubJsonService(globalMux, "", "", "", nil)
|
|
|
|
// When no xmux is present, the global subJsonMux should be used.
|
|
stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up"}}`
|
|
parsed := svc.streamData(stream, "")
|
|
|
|
mux := globalMux
|
|
if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok {
|
|
if _, hasXmux := xhttp["xmux"]; hasXmux {
|
|
mux = ""
|
|
}
|
|
}
|
|
|
|
streamSettings, _ := json.Marshal(parsed)
|
|
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
|
|
client := model.Client{ID: "uuid-1"}
|
|
|
|
raw := svc.genVless(&SubService{}, inbound, streamSettings, client, mux)
|
|
var ob map[string]any
|
|
if err := json.Unmarshal(raw, &ob); err != nil {
|
|
t.Fatalf("unmarshal outbound: %v", err)
|
|
}
|
|
m, has := ob["mux"]
|
|
if !has {
|
|
t.Fatal("outbound.Mux must be set when global subJsonMux is configured and no per-inbound xmux")
|
|
}
|
|
mm, _ := m.(map[string]any)
|
|
if mm["enabled"] != true || mm["concurrency"] != float64(8) {
|
|
t.Fatalf("mux payload wrong: %#v", m)
|
|
}
|
|
}
|
|
|
|
func realitySpiderXFromStream(t *testing.T, svc *SubJsonService, clientKey string) string {
|
|
t.Helper()
|
|
stream := svc.streamData(`{
|
|
"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
|
|
"realitySettings":{
|
|
"serverNames":["reality.example.com"],
|
|
"shortIds":["ab12cd"],
|
|
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/seed"}
|
|
}
|
|
}`, clientKey)
|
|
rlty, _ := stream["realitySettings"].(map[string]any)
|
|
if rlty == nil {
|
|
t.Fatal("streamData dropped realitySettings")
|
|
}
|
|
spx, _ := rlty["spiderX"].(string)
|
|
if len(spx) != 16 || spx[0] != '/' {
|
|
t.Fatalf("spiderX = %q, want a 16-char /-prefixed value", spx)
|
|
}
|
|
return spx
|
|
}
|
|
|
|
func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) {
|
|
svc := NewSubJsonService("", "", "", "", nil)
|
|
|
|
alice := realitySpiderXFromStream(t, svc, "subAlice")
|
|
if again := realitySpiderXFromStream(t, svc, "subAlice"); again != alice {
|
|
t.Fatalf("spiderX not stable for the same client: %q vs %q", alice, again)
|
|
}
|
|
if bob := realitySpiderXFromStream(t, svc, "subBob"); bob == alice {
|
|
t.Fatalf("spiderX identical across clients (fingerprintable): %q", alice)
|
|
}
|
|
}
|
|
|
|
// streamData must tolerate malformed stored inbounds: unparseable stream JSON
|
|
// (with a finalMask configured, which writes into the map) and tls/reality
|
|
// security whose settings key is missing or null previously panicked the
|
|
// subscription request.
|
|
func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
|
|
withMask := NewSubJsonService("", "", `{"tcp":[{"type":"fragment"}]}`, "", nil)
|
|
stream := withMask.streamData("not-json", "clientKey")
|
|
if _, ok := stream["finalmask"]; !ok {
|
|
t.Fatal("finalMask must still apply when stream settings fail to parse")
|
|
}
|
|
|
|
svc := NewSubJsonService("", "", "", "", nil)
|
|
noReality := svc.streamData(`{"network":"tcp","security":"reality"}`, "clientKey")
|
|
if v, ok := noReality["realitySettings"]; ok {
|
|
t.Fatalf("missing realitySettings must stay absent, got %v", v)
|
|
}
|
|
nullTls := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":null}`, "")
|
|
if v, ok := nullTls["tlsSettings"]; ok {
|
|
t.Fatalf("null tlsSettings must be dropped, got %v", v)
|
|
}
|
|
}
|
|
|
|
func TestSubJsonServiceRealityDataSpiderXFallsBackWhenNoClientKey(t *testing.T) {
|
|
svc := NewSubJsonService("", "", "", "", nil)
|
|
|
|
stream := svc.streamData(`{
|
|
"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
|
|
"realitySettings":{
|
|
"serverNames":["reality.example.com"],
|
|
"shortIds":["ab12cd"],
|
|
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
|
|
}
|
|
}`, "")
|
|
|
|
rlty, _ := stream["realitySettings"].(map[string]any)
|
|
if rlty == nil {
|
|
t.Fatal("streamData dropped realitySettings")
|
|
}
|
|
spx, _ := rlty["spiderX"].(string)
|
|
if len(spx) != 16 || spx[0] != '/' {
|
|
t.Fatalf("spiderX fallback = %q, want random 16-char /-prefixed value", spx)
|
|
}
|
|
}
|
|
|
|
func TestSubJsonServiceWireguard(t *testing.T) {
|
|
serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
|
|
if err != nil {
|
|
t.Fatalf("server keypair: %v", err)
|
|
}
|
|
clientPriv, _, err := wgutil.GenerateWireguardKeypair()
|
|
if err != nil {
|
|
t.Fatalf("client keypair: %v", err)
|
|
}
|
|
|
|
inbound := &model.Inbound{
|
|
Listen: "203.0.113.9",
|
|
Port: 51820,
|
|
Protocol: model.WireGuard,
|
|
Settings: `{"secretKey":"` + serverPriv + `","mtu":1420}`,
|
|
}
|
|
client := model.Client{
|
|
Email: "user",
|
|
PrivateKey: clientPriv,
|
|
PreSharedKey: "psk-value",
|
|
KeepAlive: model.KeepAlivePtr(25),
|
|
AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"},
|
|
}
|
|
|
|
raw := NewSubJsonService("", "", "", "", nil).genWireguard(inbound, client)
|
|
if raw == nil {
|
|
t.Fatal("genWireguard returned nil for a valid wireguard client")
|
|
}
|
|
settings := outboundSettings(t, raw)
|
|
|
|
if settings["secretKey"] != clientPriv {
|
|
t.Fatalf("secretKey = %v, want client private key", settings["secretKey"])
|
|
}
|
|
address, _ := settings["address"].([]any)
|
|
if len(address) != 2 || address[0] != "10.0.0.2/32" || address[1] != "fd00::2/128" {
|
|
t.Fatalf("address = %v, want client tunnel addresses", settings["address"])
|
|
}
|
|
if settings["mtu"] != float64(1420) {
|
|
t.Fatalf("mtu = %v, want 1420", settings["mtu"])
|
|
}
|
|
|
|
peers, _ := settings["peers"].([]any)
|
|
if len(peers) != 1 {
|
|
t.Fatalf("peers len = %d, want 1", len(peers))
|
|
}
|
|
peer, _ := peers[0].(map[string]any)
|
|
if peer["publicKey"] != serverPub {
|
|
t.Fatalf("peer publicKey = %v, want %v (derived from inbound secretKey)", peer["publicKey"], serverPub)
|
|
}
|
|
if peer["endpoint"] != "203.0.113.9:51820" {
|
|
t.Fatalf("peer endpoint = %v, want 203.0.113.9:51820", peer["endpoint"])
|
|
}
|
|
if peer["preSharedKey"] != "psk-value" {
|
|
t.Fatalf("peer preSharedKey = %v, want psk-value", peer["preSharedKey"])
|
|
}
|
|
if peer["keepAlive"] != float64(25) {
|
|
t.Fatalf("peer keepAlive = %v, want 25", peer["keepAlive"])
|
|
}
|
|
allowed, _ := peer["allowedIPs"].([]any)
|
|
if !reflect.DeepEqual(allowed, []any{"0.0.0.0/0", "::/0"}) {
|
|
t.Fatalf("peer allowedIPs = %v, want full tunnel", peer["allowedIPs"])
|
|
}
|
|
}
|
|
|
|
func TestSubJsonServiceWireguardNoKey(t *testing.T) {
|
|
inbound := &model.Inbound{Listen: "203.0.113.9", Port: 51820, Protocol: model.WireGuard, Settings: `{}`}
|
|
client := model.Client{Email: "user"}
|
|
|
|
if raw := NewSubJsonService("", "", "", "", nil).genWireguard(inbound, client); raw != nil {
|
|
t.Fatalf("genWireguard = %s, want nil for a keyless wireguard client", raw)
|
|
}
|
|
}
|
|
|
|
func TestSubJsonServiceSkipsAmneziaWG(t *testing.T) {
|
|
if got := NewSubJsonService("", "", "", "", nil).getConfig(&SubService{address: "sub.example.com"}, &model.Inbound{Listen: "203.0.113.8", Port: 51820, Protocol: model.AmneziaWG}, model.Client{}, "sub.example.com"); len(got) != 0 {
|
|
t.Fatalf("getConfig emitted %d unsupported AmneziaWG Xray config(s)", len(got))
|
|
}
|
|
}
|
|
|
|
func TestSubJsonServiceSkipsTUIC(t *testing.T) {
|
|
if got := NewSubJsonService("", "", "", "", nil).getConfig(
|
|
&SubService{address: "sub.example.com"},
|
|
&model.Inbound{Listen: "203.0.113.8", Port: 8443, Protocol: model.TUIC},
|
|
model.Client{},
|
|
"sub.example.com",
|
|
); len(got) != 0 {
|
|
t.Fatalf("getConfig emitted %d unsupported TUIC Xray config(s)", len(got))
|
|
}
|
|
}
|