| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408 |
- package sub
- import (
- "encoding/json"
- "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 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)
- }
- }
- 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 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)
- }
- }
- 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: 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)
- }
- }
|