CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Docs CI / build (push) Canceled after 0s
Docs Deploy (GitHub Pages) / 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
Deploy Smoke Tests / noninteractive-install (ubuntu-24.04-arm) (push) Canceled after 0s
Deploy Smoke Tests / noninteractive-install (ubuntu-latest) (push) Canceled after 0s
Deploy Smoke Tests / release-tag-install (ubuntu-24.04-arm) (push) Canceled after 0s
Deploy Smoke Tests / release-tag-install (ubuntu-latest) (push) Canceled after 0s
Docs Deploy (GitHub Pages) / deploy (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
351 lines
11 KiB
Go
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")
|
|
}
|