- 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.
213 lines
8.0 KiB
Go
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)
|
|
}
|
|
}
|