Files
3x-ui-agent/internal/singbox/convert_test.go
T
Muse 277838d7ca ITEM6 B4: frontend reshaping — xray page retired, stats switches to settings, template read paths closed
- Delete the xray settings page (routes/menu/title), geodata UI/service,
  XraySettingController/Service and routing-sync write paths; the stored
  xrayTemplateConfig row loses its last readers (GetXrayConfigTemplate now
  returns the embedded factory template; B1 already deleted the row).
- Settings GeneralTab gains the four trafficStatsSwitches switches
  (settings.trafficStatsSwitches, B1) with the dual warning copy; entity
  AllSetting carries the key end to end (TestTrafficStatsSwitchesPlumbing).
- Tag-directory consumers (panelOutbound, mtproto egress, node/host
  outbound pickers) move from POST /xray/ to GET /server/outboundDirectory.
- Shared Mux/Sockopt forms and balancer helpers relocated to lib/xray;
  converter preserves sniffing ips/domains exclusions into the panel
  sidecar instead of dropping them (B2 gap closed by retention).
- Freedom final-rules DB fix seeders retired; getDefaultJsonConfig
  endpoint removed (no in-repo consumer); locales pruned to referenced
  keys (pages.xray 331 -> 68) plus the five new settings keys.
2026-10-22 23:40:54 +00:00

213 lines
8.0 KiB
Go

package singbox
import (
"encoding/json"
"strings"
"testing"
)
func mustConvert(t *testing.T, in Inbound) Result {
t.Helper()
res, err := ConvertInbound(in)
if err != nil {
t.Fatalf("ConvertInbound: %v", err)
}
return res
}
func TestConvert_MaxTimediffBecomesDuration(t *testing.T) {
stream := `{"network":"tcp","security":"reality","realitySettings":{"target":"example.com:443","serverNames":["example.com"],"privateKey":"PK","shortIds":["ab"],"maxTimediff":300000,"settings":{}}}`
res := mustConvert(t, Inbound{
Port: 443, Protocol: "vless",
Settings: json.RawMessage(`{"clients":[]}`),
StreamSettings: json.RawMessage(stream),
Clients: []Client{{Email: "a@b.c", UUID: "u-1"}},
})
tlsBlk := res.Fragment["tls"].(map[string]any)
reality := tlsBlk["reality"].(map[string]any)
if reality["max_time_difference"] != "5m0s" {
t.Fatalf("max_time_difference = %v, want 5m0s", reality["max_time_difference"])
}
}
func TestConvert_CipherSuitesSplitAndInlineCerts(t *testing.T) {
stream := `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"s.example.com","cipherSuites":"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256:TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256","certificates":[{"certificate":["-----BEGIN CERTIFICATE-----","MIIB","-----END CERTIFICATE-----"],"key":["-----BEGIN KEY-----","MIIB","-----END KEY-----"]}]}}`
res := mustConvert(t, Inbound{
Port: 443, Protocol: "trojan",
Settings: json.RawMessage(`{"clients":[]}`),
StreamSettings: json.RawMessage(stream),
Clients: []Client{{Email: "a@b.c", Password: "pw"}},
})
tlsBlk := res.Fragment["tls"].(map[string]any)
suites := tlsBlk["cipher_suites"].([]string)
if len(suites) != 2 || !strings.HasPrefix(suites[0], "TLS_ECDHE_ECDSA") {
t.Fatalf("cipher_suites = %v", suites)
}
if !strings.Contains(tlsBlk["certificate"].(string), "\n") {
t.Fatalf("inline certificate should be joined with newlines: %q", tlsBlk["certificate"])
}
if _, ok := tlsBlk["certificate_path"]; ok {
t.Fatalf("inline certs must not produce certificate_path")
}
}
// ITEM6 B4: sniffing ipsExcluded/domainsExcluded have no sing-box
// counterpart; the converter must preserve them verbatim in the panel
// sidecar instead of dropping them — whether or not sniffing is on.
func TestConvert_SniffingExclusionsPreserved(t *testing.T) {
sniffing := `{"enabled":true,"destOverride":["http","tls"],"ipsExcluded":["10.0.0.0/8","geoip:private"],"domainsExcluded":["example.com",".internal"]}`
res := mustConvert(t, Inbound{
Port: 443, Protocol: "vless",
Settings: json.RawMessage(`{"clients":[]}`),
StreamSettings: json.RawMessage(`{"network":"tcp","security":"none"}`),
Sniffing: json.RawMessage(sniffing),
Clients: []Client{{Email: "a@b.c", UUID: "u-1"}},
})
if res.Fragment["sniff"] != true {
t.Fatalf("sniff = %v, want true", res.Fragment["sniff"])
}
ips, ok := res.Panel["sniffingIpsExcluded"].([]string)
if !ok || len(ips) != 2 || ips[0] != "10.0.0.0/8" || ips[1] != "geoip:private" {
t.Fatalf("sniffingIpsExcluded = %v", res.Panel["sniffingIpsExcluded"])
}
domains, ok := res.Panel["sniffingDomainsExcluded"].([]string)
if !ok || len(domains) != 2 || domains[0] != "example.com" || domains[1] != ".internal" {
t.Fatalf("sniffingDomainsExcluded = %v", res.Panel["sniffingDomainsExcluded"])
}
// Sniffing disabled: the lists are still user data and survive.
res = mustConvert(t, Inbound{
Port: 443, Protocol: "vless",
Settings: json.RawMessage(`{"clients":[]}`),
StreamSettings: json.RawMessage(`{"network":"tcp","security":"none"}`),
Sniffing: json.RawMessage(`{"enabled":false,"ipsExcluded":["192.168.0.0/16"]}`),
Clients: []Client{{Email: "a@b.c", UUID: "u-1"}},
})
if _, ok := res.Fragment["sniff"]; ok {
t.Fatalf("sniff must stay off when disabled")
}
if ips, ok := res.Panel["sniffingIpsExcluded"].([]string); !ok || len(ips) != 1 {
t.Fatalf("disabled sniffing lost exclusions: %v", res.Panel["sniffingIpsExcluded"])
}
if _, ok := res.Panel["sniffingDomainsExcluded"]; ok {
t.Fatalf("empty domainsExcluded must not be recorded")
}
}
func TestConvert_DeriveWireguardAddress(t *testing.T) {
cases := []struct {
subnet string
cidr int
want string
}{
{"10.0.0.0", 24, "10.0.0.1/24"},
{"10.9.8.0", 24, "10.9.8.1/24"},
{"172.16.0.0", 16, "172.16.0.1/16"},
{"", 24, ""},
{"not-an-ip", 24, ""},
}
for _, tc := range cases {
m := map[string]any{"subnetIp": tc.subnet, "subnetCidr": float64(tc.cidr)}
if got := deriveWireguardAddress(m); got != tc.want {
t.Errorf("derive(%q,%d) = %q, want %q", tc.subnet, tc.cidr, got, tc.want)
}
}
}
func TestConvert_V6OnlyWildcardListen(t *testing.T) {
stream := `{"network":"tcp","security":"none","sockopt":{"V6Only":true}}`
res := mustConvert(t, Inbound{
Port: 443, Protocol: "vless", Listen: "",
Settings: json.RawMessage(`{"clients":[]}`),
StreamSettings: json.RawMessage(stream),
})
if res.Fragment["listen"] != "::" {
t.Fatalf("listen = %v, want ::", res.Fragment["listen"])
}
}
func TestConvert_SockoptMappingsAndRemainder(t *testing.T) {
stream := `{"network":"tcp","security":"none","sockopt":{"mark":42,"tcpFastOpen":true,"tcpcongestion":"bbr"}}`
res := mustConvert(t, Inbound{
Port: 443, Protocol: "vless",
Settings: json.RawMessage(`{"clients":[]}`),
StreamSettings: json.RawMessage(stream),
})
if res.Fragment["routing_mark"] != 42 || res.Fragment["tcp_fast_open"] != true {
t.Fatalf("sockopt mappings wrong: %v", res.Fragment)
}
found := false
for _, u := range res.Unmapped {
if strings.Contains(u.Concept, "sockopt") && strings.Contains(string(u.Input), "bbr") {
found = true
}
}
if !found {
t.Fatalf("tcpcongestion should be registered unmapped, got %+v", res.Unmapped)
}
}
func TestConvert_DisableFlowClearsUserFlow(t *testing.T) {
res := mustConvert(t, Inbound{
Port: 443, Protocol: "vless", DisableFlow: true,
Settings: json.RawMessage(`{"clients":[]}`),
StreamSettings: json.RawMessage(`{"network":"tcp","security":"none"}`),
Clients: []Client{{Email: "a@b.c", UUID: "u", Flow: "xtls-rprx-vision"}},
})
users := res.Fragment["users"].([]any)
if _, ok := users[0].(map[string]any)["flow"]; ok {
t.Fatalf("flow must be cleared when DisableFlow is set: %v", users[0])
}
}
func TestConvert_UDP443FlowNormalized(t *testing.T) {
res := mustConvert(t, Inbound{
Port: 443, Protocol: "vless",
Settings: json.RawMessage(`{"clients":[]}`),
StreamSettings: json.RawMessage(`{"network":"tcp","security":"none"}`),
Clients: []Client{{Email: "a@b.c", UUID: "u", Flow: "xtls-rprx-vision-udp443"}},
})
users := res.Fragment["users"].([]any)
if users[0].(map[string]any)["flow"] != "xtls-rprx-vision" {
t.Fatalf("flow = %v", users[0])
}
}
func TestConvert_MalformedSettingsIsHardError(t *testing.T) {
_, err := ConvertInbound(Inbound{Port: 1, Protocol: "vless", Settings: json.RawMessage(`{"clients":`)})
if err == nil {
t.Fatalf("malformed settings must be a hard error")
}
}
func TestConvertAgentOutbounds(t *testing.T) {
raw := `[{"tag":"direct","protocol":"freedom","settings":{}},{"tag":"warp","protocol":"wireguard","settings":{}}]`
out, issues, err := ConvertAgentOutbounds(raw)
if err != nil {
t.Fatalf("convert: %v", err)
}
if !strings.Contains(out, `"type":"direct"`) || strings.Contains(out, "warp") {
t.Fatalf("outbounds = %s", out)
}
if len(issues) != 1 || issues[0].Kind != "wireguard" {
t.Fatalf("issues = %+v", issues)
}
if out2, issues2, _ := ConvertAgentOutbounds(""); out2 != "" || issues2 != nil {
t.Fatalf("empty input should produce empty output, got %q %v", out2, issues2)
}
}
func TestConvertAgentRoutingRules_GeoRejected(t *testing.T) {
raw := `[{"type":"field","outboundTag":"block","ip":["geoip:cn"]},{"type":"field","outboundTag":"direct","port":53,"network":"udp"}]`
out, issues, err := ConvertAgentRoutingRules(raw)
if err != nil {
t.Fatalf("convert: %v", err)
}
if len(issues) != 1 {
t.Fatalf("issues = %+v", issues)
}
if !strings.Contains(out, `"port":53`) || strings.Contains(out, "geoip") {
t.Fatalf("rules = %s", out)
}
}