Files
3x-ui-agent/internal/singbox/convert_test.go
T
Muse 39ad8b8a2e ITEM6 B1: sing-box data model + one-shot migration
New stored model (legacy columns untouched, read paths unchanged
until B2):
- inbounds.singbox_config (converted sing-box inbound/endpoint
  fragment JSON; empty = blocked or external-runtime) and
  inbounds.singbox_preserved (unmapped items verbatim + panel-side
  material + notes)
- agent_nodes.singbox_outbounds / singbox_routing_rules

Converter (internal/singbox, pure functions, ITEM6-CONVERSION-SPEC):
all panel protocols mapped; unmapped constructs are preserved and
registered, never dropped. B0 golden fixtures: 26/26 semantic match
(fragment + unmapped concept/pending sets + disposition).

One-shot migrator (internal/database, InitDB hook after initModels):
marker-gated and idempotent; SQLite file backup before any write
(failure refuses migration/startup); stats switches migrate out of
the template BEFORE the xrayTemplateConfig row is discarded
(SCOPE 4.3.1 order); identity layer is the only client truth with
spec 7-1 mirror reconciliation (adopt/fill/conflict all reported);
fallbacks merged from table + embedded settings, deduped; agent
overrides converted with per-entry issues; everything in one
transaction, marker written last; JSON report written next to the
DB. Malformed rows/templates are hard failures (rollback, old DB
untouched).

settings.trafficStatsSwitches added with factory defaults
(inbound on, outbound off) in defaultValueMap.

Validation: synthetic agent-era DB (mirror drift, fallbacks, xhttp,
agent geo rule, non-default stats) converts as specified; old
production backup (25 inbounds) migrates with credentials, Reality
pbk derivation, traffic counters and reset cycles byte-identical;
idempotent re-run is a no-op; touched packages show zero new test
failures vs the 9-red baseline.
2026-10-22 19:32:15 +00:00

170 lines
6.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")
}
}
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)
}
}