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.
170 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|