2026-06-04 12:20:28 +00:00
|
|
|
package sub
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"testing"
|
2026-06-04 21:16:43 +00:00
|
|
|
|
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
2026-06-04 12:20:28 +00:00
|
|
|
)
|
|
|
|
|
|
2026-06-04 21:16:43 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-06-04 12:20:28 +00:00
|
|
|
|
2026-06-04 21:16:43 +00:00
|
|
|
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)
|
2026-06-04 12:20:28 +00:00
|
|
|
}
|
2026-06-04 21:16:43 +00:00
|
|
|
settings, _ := parsed["settings"].(map[string]any)
|
|
|
|
|
if settings == nil {
|
|
|
|
|
t.Fatal("outbound has no settings")
|
2026-06-04 12:20:28 +00:00
|
|
|
}
|
2026-06-04 21:16:43 +00:00
|
|
|
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")
|
2026-06-04 12:20:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
|
2026-06-04 21:16:43 +00:00
|
|
|
if _, ok := stream["sockopt"]; ok {
|
|
|
|
|
t.Fatal("legacy direct_out dialerProxy sockopt must never be set")
|
2026-06-04 12:20:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
finalmask, _ := stream["finalmask"].(map[string]any)
|
|
|
|
|
if finalmask == nil {
|
|
|
|
|
t.Fatal("streamSettings is missing finalmask")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 21:16:43 +00:00
|
|
|
tcp, _ := finalmask["tcp"].([]any)
|
|
|
|
|
if len(tcp) != 1 {
|
|
|
|
|
t.Fatalf("tcp masks len = %d, want 1", len(tcp))
|
2026-06-04 12:20:28 +00:00
|
|
|
}
|
2026-06-04 21:16:43 +00:00
|
|
|
if first, _ := tcp[0].(map[string]any); first["type"] != "fragment" {
|
|
|
|
|
t.Fatalf("tcp[0] type = %v, want fragment", first["type"])
|
2026-06-04 12:20:28 +00:00
|
|
|
}
|
2026-06-04 21:16:43 +00:00
|
|
|
|
|
|
|
|
udp, _ := finalmask["udp"].([]any)
|
|
|
|
|
if len(udp) != 1 {
|
|
|
|
|
t.Fatalf("udp masks len = %d, want 1", len(udp))
|
2026-06-04 12:20:28 +00:00
|
|
|
}
|
2026-06-04 21:16:43 +00:00
|
|
|
|
|
|
|
|
quic, _ := finalmask["quicParams"].(map[string]any)
|
|
|
|
|
if quic == nil || quic["congestion"] != "bbr" {
|
|
|
|
|
t.Fatalf("quicParams missing/wrong: %#v", finalmask["quicParams"])
|
2026-06-04 12:20:28 +00:00
|
|
|
}
|
2026-06-04 21:16:43 +00:00
|
|
|
}
|
2026-06-04 12:20:28 +00:00
|
|
|
|
2026-06-04 21:16:43 +00:00
|
|
|
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))
|
2026-06-04 12:20:28 +00:00
|
|
|
}
|
2026-06-04 21:16:43 +00:00
|
|
|
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)
|
2026-06-04 12:20:28 +00:00
|
|
|
}
|
2026-06-04 21:16:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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")
|
2026-06-04 12:20:28 +00:00
|
|
|
}
|
2026-06-04 21:16:43 +00:00
|
|
|
if _, ok := stream["sockopt"]; ok {
|
|
|
|
|
t.Fatal("legacy direct_out sockopt must never be set")
|
2026-06-04 12:20:28 +00:00
|
|
|
}
|
2026-06-04 21:16:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(inbound, nil, client))
|
|
|
|
|
if _, ok := settings["vnext"]; ok {
|
|
|
|
|
t.Fatal("vless outbound must not use vnext")
|
2026-06-04 12:20:28 +00:00
|
|
|
}
|
2026-06-04 21:16:43 +00:00
|
|
|
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)
|
2026-06-04 12:20:28 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 21:16:43 +00:00
|
|
|
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"}
|
2026-06-04 12:20:28 +00:00
|
|
|
|
2026-06-04 21:16:43 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-04 12:20:28 +00:00
|
|
|
|
2026-06-04 21:16:43 +00:00
|
|
|
func TestSubJsonServiceServerFlattened(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(trojan, nil, client))
|
|
|
|
|
if _, ok := settings["servers"]; ok {
|
|
|
|
|
t.Fatal("trojan outbound must not use servers array")
|
2026-06-04 12:20:28 +00:00
|
|
|
}
|
2026-06-04 21:16:43 +00:00
|
|
|
if settings["password"] != "p4ss" || settings["address"] != "1.2.3.4" {
|
|
|
|
|
t.Fatalf("flat trojan settings wrong: %#v", settings)
|
2026-06-04 12:20:28 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-04 21:16:43 +00:00
|
|
|
ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
|
|
|
|
|
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(ss, nil, client))
|
|
|
|
|
if ssSettings["method"] != "aes-256-gcm" {
|
|
|
|
|
t.Fatalf("flat shadowsocks must carry method: %#v", ssSettings)
|
2026-06-04 12:20:28 +00:00
|
|
|
}
|
|
|
|
|
}
|