mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 15:25:01 +03:00
61b129d5b3
Bump xtls/xray-core to 1b1647cd9ddf (v26.10.21) and move the three binary pins (DockerInit.sh, both release.yml fetches) in lockstep. The v26.10.10 release was pulled upstream, so the old pins already 404. XDRIVE (#7182): the core now runs XDRIVE only for VLESS inside VLESS encryption, with no flow, and an outbound only over Mux; it refuses the whole config otherwise. The panel offers XDRIVE only for VLESS, refuses to save an inbound without decryption, never emits a client flow on an XDRIVE stream (config, JSON subscription, forms), and turns Mux on when an outbound picks XDRIVE. An inbound or template outbound stored before the upgrade that the core would refuse is left out of the generated config with a warning, so it no longer stops every other inbound. The service "Google Drive" is now "google-drive" (old name read as a deprecated alias); saves, the schema and both form adapters store the new name. XDRIVE and MASQUE gain a client xmux; both schemas keep it instead of stripping it on an inbound save. Lua scripts (#7124, #7136): the core refuses a routing script beside rules or domainStrategy, and a dns script beside the fallback/parallel switches or a domain-scoped server. The panel always keeps its api routing rule, so a template save now refuses any routing script and a dns script in a refused combination, and a stored one is dropped from the generated config so the core still starts. Noise masks gain a Lua script that the loader compiles inside Build: the in-process build (template save check, hot add) now resolves a relative script the way the xray child does, beside the xray binary, instead of failing it. The mask editor gets a Script field that replaces the noise list. XDNS (#7140): a resolver takes https:// DoH addresses with worker, tlsConfig and quicConfig. The mask editor registered only addrs, so a save dropped the rest; it now edits all four. Finalmask filter (#7206): a new TCP/UDP mask screens an inbound's source IPs. The inbound mask editor offers it with its allow and block lists; it is a no-op on a dialer and an older client core refuses the type, so the share link and JSON subscription drop it like the Go link already does.
358 lines
11 KiB
Go
358 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.
|
|
// goldenVlessDecryption carries an XDRIVE fixture, which xray-core 26.10.17 runs only inside VLESS encryption.
|
|
const goldenVlessDecryption = "mlkem768x25519plus.native.600s.mMFxPe7lz5xoq2qBk22cQYefu5fpc_2dGR8lMOKem0E"
|
|
|
|
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
|
|
}
|
|
decryption := "none"
|
|
if stream["network"] == "xdrive" {
|
|
decryption = goldenVlessDecryption
|
|
}
|
|
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": decryption,
|
|
},
|
|
"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")
|
|
}
|