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) } }