Files
3x-ui-agent/internal/web/service/golden_fixtures_xray_test.go
T
Sanaei f727d04f65
Release 3X-UI for Docker / build (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
v3.7.0
2026-08-24 15:07:15 +02:00

351 lines
11 KiB
Go

package service
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"maps"
"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":
maps.Copy(stream, fixture)
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")
}