|
@@ -0,0 +1,351 @@
|
|
|
|
|
+package service
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "crypto/ecdsa"
|
|
|
|
|
+ "crypto/elliptic"
|
|
|
|
|
+ "crypto/rand"
|
|
|
|
|
+ "crypto/x509"
|
|
|
|
|
+ "crypto/x509/pkix"
|
|
|
|
|
+ "encoding/json"
|
|
|
|
|
+ "encoding/pem"
|
|
|
|
|
+ "math/big"
|
|
|
|
|
+ "os"
|
|
|
|
|
+ "path/filepath"
|
|
|
|
|
+ "sort"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "testing"
|
|
|
|
|
+ "time"
|
|
|
|
|
+
|
|
|
|
|
+ "github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
|
|
|
+
|
|
|
|
|
+ "github.com/xtls/xray-core/infra/conf"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// The frontend's golden fixtures are the panel's model of an xray config: the
|
|
|
|
|
+// Zod snapshots pin what the forms parse and emit. Parsing proves the panel
|
|
|
|
|
+// agrees with itself, not that xray-core would accept the result, so every
|
|
|
|
|
+// fixture is also built here through the very config builders the panel hands
|
|
|
|
|
+// its config to — conf.InboundDetourConfig for the full-config and AddInbound
|
|
|
|
|
+// paths, conf.RouterConfig for ApplyRoutingConfig, conf.DNSConfig for the dns
|
|
|
|
|
+// section. A fixture xray-core refuses is a config the panel would let an
|
|
|
|
|
+// admin save and then fail to start the core with, taking every inbound down.
|
|
|
|
|
+//
|
|
|
|
|
+// mtproto is excluded: it is served by the bundled mtg-multi sidecar, not by
|
|
|
|
|
+// xray, so xray-core has no config id for it.
|
|
|
|
|
+
|
|
|
|
|
+func goldenFixtureDir(t *testing.T, category string) string {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ dir, err := filepath.Abs(filepath.Join("..", "..", "..", "frontend", "src", "test", "golden", "fixtures", category))
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("resolve fixture dir: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return dir
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// goldenFixtures returns every fixture in a category as name -> decoded object.
|
|
|
|
|
+func goldenFixtures(t *testing.T, category string) map[string]map[string]any {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ dir := goldenFixtureDir(t, category)
|
|
|
|
|
+ entries, err := os.ReadDir(dir)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("read %s: %v", dir, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ out := make(map[string]map[string]any)
|
|
|
|
|
+ names := make([]string, 0, len(entries))
|
|
|
|
|
+ for _, entry := range entries {
|
|
|
|
|
+ if filepath.Ext(entry.Name()) != ".json" {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ names = append(names, entry.Name())
|
|
|
|
|
+ }
|
|
|
|
|
+ sort.Strings(names)
|
|
|
|
|
+ if len(names) == 0 {
|
|
|
|
|
+ t.Fatalf("no fixtures under %s", dir)
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, name := range names {
|
|
|
|
|
+ raw, err := os.ReadFile(filepath.Join(dir, name))
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("read %s: %v", name, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ var obj map[string]any
|
|
|
|
|
+ if err := json.Unmarshal(raw, &obj); err != nil {
|
|
|
|
|
+ t.Fatalf("unmarshal %s: %v", name, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ out[strings.TrimSuffix(name, ".json")] = obj
|
|
|
|
|
+ }
|
|
|
|
|
+ return out
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// writeTestCertificate writes a self-signed certificate and key, returning both
|
|
|
|
|
+// paths. The TLS fixtures point certificateFile/keyFile at deployment paths
|
|
|
|
|
+// that do not exist here, and xray-core reads them while building.
|
|
|
|
|
+func writeTestCertificate(t *testing.T) (certPath, keyPath string) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("generate key: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ template := x509.Certificate{
|
|
|
|
|
+ SerialNumber: big.NewInt(1),
|
|
|
|
|
+ Subject: pkix.Name{CommonName: "golden-fixture.test"},
|
|
|
|
|
+ NotBefore: time.Now().Add(-time.Hour),
|
|
|
|
|
+ NotAfter: time.Now().Add(24 * time.Hour),
|
|
|
|
|
+ DNSNames: []string{"golden-fixture.test"},
|
|
|
|
|
+ }
|
|
|
|
|
+ der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("create certificate: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ keyDER, err := x509.MarshalECPrivateKey(key)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("marshal key: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ dir := t.TempDir()
|
|
|
|
|
+ certPath = filepath.Join(dir, "fixture.crt")
|
|
|
|
|
+ keyPath = filepath.Join(dir, "fixture.key")
|
|
|
|
|
+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
|
|
|
|
+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
|
|
|
|
+ if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
|
|
|
|
|
+ t.Fatalf("write certificate: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
|
|
|
|
+ t.Fatalf("write key: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return certPath, keyPath
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// repointCertificateFiles rewrites every certificateFile/keyFile reference to
|
|
|
|
|
+// the generated pair, so a fixture is judged on its shape rather than on paths
|
|
|
|
|
+// that only exist on a deployed server.
|
|
|
|
|
+func repointCertificateFiles(node any, certPath, keyPath string) {
|
|
|
|
|
+ switch value := node.(type) {
|
|
|
|
|
+ case map[string]any:
|
|
|
|
|
+ for key, child := range value {
|
|
|
|
|
+ switch key {
|
|
|
|
|
+ case "certificateFile":
|
|
|
|
|
+ if _, ok := child.(string); ok {
|
|
|
|
|
+ value[key] = certPath
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ case "keyFile":
|
|
|
|
|
+ if _, ok := child.(string); ok {
|
|
|
|
|
+ value[key] = keyPath
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ repointCertificateFiles(child, certPath, keyPath)
|
|
|
|
|
+ }
|
|
|
|
|
+ case []any:
|
|
|
|
|
+ for _, child := range value {
|
|
|
|
|
+ repointCertificateFiles(child, certPath, keyPath)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// assertXrayAccepts fails unless xray-core built the fixture. Fixtures naming
|
|
|
|
|
+// geoip:/geosite: need the dat files the panel ships next to the xray binary;
|
|
|
|
|
+// where they are absent the loader fails on the missing file rather than on the
|
|
|
|
|
+// fixture, so those are skipped instead of reported as broken.
|
|
|
|
|
+func assertXrayAccepts(t *testing.T, subject string, err error) {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ if err == nil {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if isMissingGeoAssetErr(err) {
|
|
|
|
|
+ t.Skipf("geo data files not available, cannot judge %s: %v", subject, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ t.Fatalf("xray-core refuses %s: %v", subject, err)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func buildGoldenInbound(t *testing.T, inbound map[string]any) error {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ certPath, keyPath := writeTestCertificate(t)
|
|
|
|
|
+ repointCertificateFiles(inbound, certPath, keyPath)
|
|
|
|
|
+
|
|
|
|
|
+ raw, err := json.Marshal(inbound)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("marshal inbound: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ detour := new(conf.InboundDetourConfig)
|
|
|
|
|
+ if err := json.Unmarshal(raw, detour); err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ _, err = detour.Build()
|
|
|
|
|
+ return err
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestGoldenInboundFixturesBuildInXray wraps each protocol fixture in a minimal
|
|
|
|
|
+// inbound and builds it.
|
|
|
|
|
+func TestGoldenInboundFixturesBuildInXray(t *testing.T) {
|
|
|
|
|
+ for name, fixture := range goldenFixtures(t, "inbound") {
|
|
|
|
|
+ if protocol, _ := fixture["protocol"].(string); protocol == string(model.MTProto) {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ t.Run(name, func(t *testing.T) {
|
|
|
|
|
+ inbound := map[string]any{
|
|
|
|
|
+ "tag": "golden-in",
|
|
|
|
|
+ "listen": "127.0.0.1",
|
|
|
|
|
+ "port": 8443,
|
|
|
|
|
+ "protocol": fixture["protocol"],
|
|
|
|
|
+ "settings": fixture["settings"],
|
|
|
|
|
+ }
|
|
|
|
|
+ assertXrayAccepts(t, "this fixture", buildGoldenInbound(t, inbound))
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestGoldenInboundFullFixturesBuildInXray runs the complete panel Inbound
|
|
|
|
|
+// model through GenXrayInboundConfig, the conversion both the full-config and
|
|
|
|
|
+// the live AddInbound paths use, and builds the result.
|
|
|
|
|
+func TestGoldenInboundFullFixturesBuildInXray(t *testing.T) {
|
|
|
|
|
+ for name, fixture := range goldenFixtures(t, "inbound-full") {
|
|
|
|
|
+ protocol, _ := fixture["protocol"].(string)
|
|
|
|
|
+ if protocol == string(model.MTProto) {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ t.Run(name, func(t *testing.T) {
|
|
|
|
|
+ section := func(key string) string {
|
|
|
|
|
+ value, ok := fixture[key]
|
|
|
|
|
+ if !ok || value == nil {
|
|
|
|
|
+ return ""
|
|
|
|
|
+ }
|
|
|
|
|
+ raw, err := json.Marshal(value)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("marshal %s: %v", key, err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return string(raw)
|
|
|
|
|
+ }
|
|
|
|
|
+ port := 0
|
|
|
|
|
+ if p, ok := fixture["port"].(float64); ok {
|
|
|
|
|
+ port = int(p)
|
|
|
|
|
+ }
|
|
|
|
|
+ listen, _ := fixture["listen"].(string)
|
|
|
|
|
+ tag, _ := fixture["tag"].(string)
|
|
|
|
|
+
|
|
|
|
|
+ ib := &model.Inbound{
|
|
|
|
|
+ Protocol: model.Protocol(protocol),
|
|
|
|
|
+ Port: port,
|
|
|
|
|
+ Listen: listen,
|
|
|
|
|
+ Tag: tag,
|
|
|
|
|
+ Settings: section("settings"),
|
|
|
|
|
+ StreamSettings: section("streamSettings"),
|
|
|
|
|
+ Sniffing: section("sniffing"),
|
|
|
|
|
+ }
|
|
|
|
|
+ raw, err := json.Marshal(ib.GenXrayInboundConfig())
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("marshal generated inbound: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ var generated map[string]any
|
|
|
|
|
+ if err := json.Unmarshal(raw, &generated); err != nil {
|
|
|
|
|
+ t.Fatalf("decode generated inbound: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ assertXrayAccepts(t, "the generated inbound", buildGoldenInbound(t, generated))
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestGoldenStreamFixturesBuildInXray attaches the transport fragments — whole
|
|
|
|
|
+// stream sections, the security block, sockopt and finalmask — to an inbound.
|
|
|
|
|
+func TestGoldenStreamFixturesBuildInXray(t *testing.T) {
|
|
|
|
|
+ for _, category := range []string{"stream", "security", "sockopt", "finalmask"} {
|
|
|
|
|
+ for name, fixture := range goldenFixtures(t, category) {
|
|
|
|
|
+ t.Run(category+"/"+name, func(t *testing.T) {
|
|
|
|
|
+ stream := map[string]any{"network": "tcp"}
|
|
|
|
|
+ switch category {
|
|
|
|
|
+ case "stream":
|
|
|
|
|
+ stream = fixture
|
|
|
|
|
+ case "security":
|
|
|
|
|
+ for key, value := range fixture {
|
|
|
|
|
+ stream[key] = value
|
|
|
|
|
+ }
|
|
|
|
|
+ case "sockopt":
|
|
|
|
|
+ stream["sockopt"] = fixture
|
|
|
|
|
+ case "finalmask":
|
|
|
|
|
+ stream["finalmask"] = fixture
|
|
|
|
|
+ }
|
|
|
|
|
+ inbound := map[string]any{
|
|
|
|
|
+ "tag": "golden-in", "listen": "127.0.0.1", "port": 8443, "protocol": "vless",
|
|
|
|
|
+ "settings": map[string]any{
|
|
|
|
|
+ "clients": []any{map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30811", "email": "golden"}},
|
|
|
|
|
+ "decryption": "none",
|
|
|
|
|
+ },
|
|
|
|
|
+ "streamSettings": stream,
|
|
|
|
|
+ }
|
|
|
|
|
+ assertXrayAccepts(t, "this fixture", buildGoldenInbound(t, inbound))
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func buildGoldenRouting(routing map[string]any) error {
|
|
|
|
|
+ raw, err := json.Marshal(routing)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ router := new(conf.RouterConfig)
|
|
|
|
|
+ if err := json.Unmarshal(raw, router); err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ _, err = router.Build()
|
|
|
|
|
+ return err
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestGoldenRoutingFixturesBuildInXray builds the rule and balancer fixtures
|
|
|
|
|
+// through the router config ApplyRoutingConfig hands to the running core.
|
|
|
|
|
+func TestGoldenRoutingFixturesBuildInXray(t *testing.T) {
|
|
|
|
|
+ for name, rule := range goldenFixtures(t, "rule") {
|
|
|
|
|
+ t.Run("rule/"+name, func(t *testing.T) {
|
|
|
|
|
+ assertXrayAccepts(t, "this rule", buildGoldenRouting(map[string]any{
|
|
|
|
|
+ "domainStrategy": "AsIs",
|
|
|
|
|
+ "rules": []any{rule},
|
|
|
|
|
+ "balancers": []any{map[string]any{"tag": "balancer-load", "selector": []any{"proxy-"}}},
|
|
|
|
|
+ }))
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for name, balancer := range goldenFixtures(t, "balancer") {
|
|
|
|
|
+ t.Run("balancer/"+name, func(t *testing.T) {
|
|
|
|
|
+ assertXrayAccepts(t, "this balancer", buildGoldenRouting(map[string]any{
|
|
|
|
|
+ "domainStrategy": "AsIs",
|
|
|
|
|
+ "balancers": []any{balancer},
|
|
|
|
|
+ "rules": []any{map[string]any{
|
|
|
|
|
+ "type": "field", "port": "443", "balancerTag": balancer["tag"],
|
|
|
|
|
+ }},
|
|
|
|
|
+ }))
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TestGoldenDNSFixturesBuildInXray builds the dns section, and each dns-server
|
|
|
|
|
+// fixture inside one.
|
|
|
|
|
+func TestGoldenDNSFixturesBuildInXray(t *testing.T) {
|
|
|
|
|
+ build := func(dns map[string]any) error {
|
|
|
|
|
+ raw, err := json.Marshal(dns)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ dnsConf := new(conf.DNSConfig)
|
|
|
|
|
+ if err := json.Unmarshal(raw, dnsConf); err != nil {
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+ _, err = dnsConf.Build()
|
|
|
|
|
+ return err
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for name, fixture := range goldenFixtures(t, "dns") {
|
|
|
|
|
+ t.Run("dns/"+name, func(t *testing.T) {
|
|
|
|
|
+ assertXrayAccepts(t, "this dns section", build(fixture))
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ for name, server := range goldenFixtures(t, "dns-server") {
|
|
|
|
|
+ t.Run("dns-server/"+name, func(t *testing.T) {
|
|
|
|
|
+ assertXrayAccepts(t, "this dns server", build(map[string]any{"servers": []any{server}}))
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func isMissingGeoAssetErr(err error) bool {
|
|
|
|
|
+ msg := err.Error()
|
|
|
|
|
+ return strings.Contains(msg, "geoip.dat") || strings.Contains(msg, "geosite.dat")
|
|
|
|
|
+}
|