Merge remote-tracking branch 'upstream/main' into sync-3.6.0

# Conflicts:
#	.github/workflows/claude-bot.yml
#	.github/workflows/release.yml
#	DockerInit.sh
#	frontend/package-lock.json
#	frontend/package.json
#	frontend/src/hooks/useClients.ts
#	frontend/src/layouts/AppSidebar.tsx
#	frontend/src/main.tsx
#	internal/config/version
#	internal/database/model/model.go
#	internal/web/service/client_wireguard.go
#	internal/web/service/inbound.go
This commit is contained in:
Kuzz007
2026-08-01 21:59:29 +03:00
250 changed files with 12904 additions and 5650 deletions
+1 -1
View File
@@ -1 +1 @@
3.5.0-awg.5
3.6.0-awg.1
+186
View File
@@ -0,0 +1,186 @@
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestBackupSQLiteProducesValidSnapshotDuringWrites(t *testing.T) {
t.Setenv("XUI_DB_JOURNAL_MODE", "")
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
seed := make([]model.Setting, 128)
value := strings.Repeat("x", 1024)
for i := range seed {
seed[i] = model.Setting{Key: fmt.Sprintf("backup-seed-%d", i), Value: value}
}
if err := db.Create(&seed).Error; err != nil {
t.Fatalf("seed database: %v", err)
}
stop := make(chan struct{})
firstWrite := make(chan error, 1)
writesDone := make(chan error, 1)
go func() {
for i := range 128 {
if err := db.Create(&model.Setting{Key: fmt.Sprintf("backup-write-%d", i), Value: value}).Error; err != nil {
if i == 0 {
firstWrite <- err
}
writesDone <- err
return
}
if i == 0 {
firstWrite <- nil
}
select {
case <-stop:
writesDone <- nil
return
default:
}
}
writesDone <- nil
}()
if err := <-firstWrite; err != nil {
t.Fatalf("first concurrent write: %v", err)
}
backupPath := filepath.Join(t.TempDir(), "backup.db")
if err := BackupSQLite(backupPath); err != nil {
close(stop)
<-writesDone
t.Fatalf("BackupSQLite: %v", err)
}
close(stop)
if err := <-writesDone; err != nil {
t.Fatalf("concurrent write: %v", err)
}
if err := ValidateSQLiteDB(backupPath); err != nil {
t.Fatalf("validate backup: %v", err)
}
backup, err := sql.Open("sqlite3", backupPath)
if err != nil {
t.Fatalf("open backup: %v", err)
}
defer backup.Close()
var seedCount int
if err := backup.QueryRow("SELECT count(*) FROM settings WHERE key LIKE 'backup-seed-%'").Scan(&seedCount); err != nil {
t.Fatalf("count seeded rows: %v", err)
}
if seedCount != 128 {
t.Fatalf("seeded row count = %d, want 128", seedCount)
}
var firstWriteCount int
if err := backup.QueryRow("SELECT count(*) FROM settings WHERE key = 'backup-write-0'").Scan(&firstWriteCount); err != nil {
t.Fatalf("count first concurrent write: %v", err)
}
if firstWriteCount != 1 {
t.Fatalf("first concurrent write count = %d, want 1", firstWriteCount)
}
}
func TestBackupSQLiteTimesOutWaitingForSourceConnection(t *testing.T) {
t.Setenv("XUI_DB_JOURNAL_MODE", "")
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("get database connection pool: %v", err)
}
sqlDB.SetMaxOpenConns(1)
held, err := sqlDB.Conn(context.Background())
if err != nil {
t.Fatalf("hold source connection: %v", err)
}
defer held.Close()
previousTimeout := backupSQLiteTimeout
backupSQLiteTimeout = 20 * time.Millisecond
t.Cleanup(func() { backupSQLiteTimeout = previousTimeout })
err = BackupSQLite(filepath.Join(t.TempDir(), "backup.db"))
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("BackupSQLite error = %v, want context deadline exceeded", err)
}
}
func TestBackupSQLiteRefusesExistingDestination(t *testing.T) {
t.Setenv("XUI_DB_JOURNAL_MODE", "")
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
backupPath := filepath.Join(t.TempDir(), "backup.db")
if err := os.WriteFile(backupPath, []byte("existing backup"), 0o600); err != nil {
t.Fatalf("create existing destination: %v", err)
}
err := BackupSQLite(backupPath)
want := fmt.Sprintf("sqlite backup destination already exists: %s", backupPath)
if err == nil || err.Error() != want {
t.Fatalf("BackupSQLite error = %v, want %q", err, want)
}
data, err := os.ReadFile(backupPath)
if err != nil {
t.Fatalf("read existing destination: %v", err)
}
if string(data) != "existing backup" {
t.Fatalf("existing destination = %q, want %q", data, "existing backup")
}
}
func TestBackupSQLiteStepPages(t *testing.T) {
t.Setenv("XUI_DB_JOURNAL_MODE", "")
if got := backupSQLiteStepPages(); got != -1 {
t.Fatalf("WAL backup step pages = %d, want -1", got)
}
t.Setenv("XUI_DB_JOURNAL_MODE", "DELETE")
if got := backupSQLiteStepPages(); got != 128 {
t.Fatalf("DELETE backup step pages = %d, want 128", got)
}
}
func TestInitDBCleansBackupDirectories(t *testing.T) {
t.Setenv("XUI_DB_JOURNAL_MODE", "")
dbDir := t.TempDir()
orphanDir := filepath.Join(dbDir, sqliteBackupDirPrefix+"orphan")
if err := os.Mkdir(orphanDir, 0o700); err != nil {
t.Fatalf("create orphan backup directory: %v", err)
}
if err := os.WriteFile(filepath.Join(orphanDir, "backup.db"), []byte("backup"), 0o600); err != nil {
t.Fatalf("write orphan backup: %v", err)
}
regularDir := filepath.Join(dbDir, ".x-ui-keep")
if err := os.Mkdir(regularDir, 0o700); err != nil {
t.Fatalf("create regular directory: %v", err)
}
if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
if _, err := os.Stat(orphanDir); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("orphan backup directory error = %v, want not exist", err)
}
if info, err := os.Stat(regularDir); err != nil || !info.IsDir() {
t.Fatalf("regular directory info = %v, %v; want existing directory", info, err)
}
}
+139 -5
View File
@@ -3,6 +3,7 @@ package database
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
@@ -12,6 +13,7 @@ import (
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"slices"
"strconv"
@@ -24,6 +26,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/mattn/go-sqlite3"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
@@ -32,6 +35,8 @@ import (
var db *gorm.DB
var backupSQLiteTimeout = 2 * time.Minute
const (
DialectSQLite = "sqlite"
DialectPostgres = "postgres"
@@ -52,8 +57,9 @@ func Dialect() string {
}
const (
defaultUsername = "admin"
defaultPassword = "admin"
defaultUsername = "admin"
defaultPassword = "admin"
sqliteBackupDirPrefix = ".x-ui-backup-"
)
func allModels() []any {
@@ -131,6 +137,12 @@ func initModels() error {
if err := migrateVmessRemovedSecurities(); err != nil {
return err
}
if err := migrateTgIDIndex(); err != nil {
return err
}
if err := migrateSyncOrphanColumns(); err != nil {
return err
}
if IsPostgres() {
if err := resyncPostgresSequences(db, models); err != nil {
log.Printf("Error resyncing postgres sequences: %v", err)
@@ -288,6 +300,15 @@ func rebuildInboundsWithoutInlineUniquePort() error {
})
}
// AutoMigrate adds the column; this only backfills the NULLs an older SQLite
// ALTER TABLE leaves behind, so the reaper's predicate never compares to NULL.
func migrateSyncOrphanColumns() error {
if !db.Migrator().HasColumn(&model.ClientRecord{}, "sync_orphaned_at") {
return nil
}
return db.Exec("UPDATE clients SET sync_orphaned_at = 0 WHERE sync_orphaned_at IS NULL").Error
}
func migrateHostVerifyPeerCertByNameColumn() error {
if !db.Migrator().HasColumn(&model.Host{}, "verify_peer_cert_by_name") {
return nil
@@ -884,6 +905,17 @@ func migrateVmessRemovedSecurities() error {
return nil
}
// migrateTgIDIndex creates an index on the clients.tg_id column so that
// lookups by Telegram ID do not require a full table scan. The index tag
// on the struct field already causes AutoMigrate to create it on new
// installations; the explicit migration ensures existing databases get it.
func migrateTgIDIndex() error {
if db.Migrator().HasIndex(&model.ClientRecord{}, "idx_clients_tg_id") {
return nil
}
return db.Migrator().CreateIndex(&model.ClientRecord{}, "TgID")
}
// normalizeInboundSubSortIndex lifts sub_sort_index values below the 1-based
// minimum (rows written by builds that defaulted the column to 0, or by nodes
// predating the field) so they cannot sort ahead of explicitly ranked inbounds.
@@ -1930,6 +1962,9 @@ func InitDB(dbPath string) error {
if err = os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err = cleanupSQLiteBackupDirs(filepath.Dir(dbPath)); err != nil {
log.Printf("clean SQLite backup directories: %v", err)
}
sync := sqliteSynchronous()
journal := sqliteJournalMode()
@@ -2031,6 +2066,31 @@ func sqliteJournalMode() string {
}
}
func backupSQLiteStepPages() int {
if sqliteJournalMode() == "DELETE" {
return 128
}
return -1
}
func cleanupSQLiteBackupDirs(dir string) error {
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() && strings.HasPrefix(entry.Name(), sqliteBackupDirPrefix) {
if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil {
return err
}
}
}
return nil
}
func sqliteSynchronous() string {
switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_SYNCHRONOUS"))) {
case "OFF":
@@ -2085,11 +2145,85 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
return bytes.Equal(buf, signature), nil
}
func Checkpoint() error {
func BackupSQLite(dstPath string) (err error) {
if IsPostgres() {
return nil
return errors.New("sqlite backup is unavailable for PostgreSQL")
}
return db.Exec("PRAGMA wal_checkpoint(TRUNCATE);").Error
if db == nil {
return errors.New("database is not initialized")
}
if _, err := os.Lstat(dstPath); err == nil {
return fmt.Errorf("sqlite backup destination already exists: %s", dstPath)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
defer func() {
if err != nil {
_ = os.Remove(dstPath)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), backupSQLiteTimeout)
defer cancel()
sourceDB, err := db.DB()
if err != nil {
return err
}
sourceConn, err := sourceDB.Conn(ctx)
if err != nil {
return err
}
defer sourceConn.Close()
destinationDB, err := sql.Open("sqlite3", dstPath)
if err != nil {
return err
}
defer destinationDB.Close()
destinationConn, err := destinationDB.Conn(ctx)
if err != nil {
return err
}
defer destinationConn.Close()
return sourceConn.Raw(func(sourceDriver any) error {
source, ok := sourceDriver.(*sqlite3.SQLiteConn)
if !ok {
return fmt.Errorf("unexpected SQLite source connection type %T", sourceDriver)
}
return destinationConn.Raw(func(destinationDriver any) error {
destination, ok := destinationDriver.(*sqlite3.SQLiteConn)
if !ok {
return fmt.Errorf("unexpected SQLite destination connection type %T", destinationDriver)
}
backup, err := destination.Backup("main", source, "main")
if err != nil {
return err
}
finished := false
defer func() {
if !finished {
_ = backup.Finish()
}
}()
for {
done, err := backup.Step(backupSQLiteStepPages())
if err != nil {
return err
}
if done {
finished = true
return backup.Finish()
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(10 * time.Millisecond):
}
}
})
})
}
func ValidateSQLiteDB(dbPath string) error {
-41
View File
@@ -1,12 +1,8 @@
package database
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func journalModeOf(t *testing.T) string {
@@ -43,40 +39,3 @@ func TestSqliteJournalModeEnvOverrideDelete(t *testing.T) {
t.Fatalf("journal_mode = %q, want delete", got)
}
}
func TestWalCheckpointMakesRawFileBackupComplete(t *testing.T) {
t.Setenv("XUI_DB_JOURNAL_MODE", "")
dbDir := t.TempDir()
dbPath := filepath.Join(dbDir, "x-ui.db")
if err := InitDB(dbPath); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
if err := db.Create(&model.Setting{Key: "walBackupProbe", Value: "42"}).Error; err != nil {
t.Fatalf("write setting: %v", err)
}
if err := Checkpoint(); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
raw, err := os.ReadFile(dbPath)
if err != nil {
t.Fatalf("read db file: %v", err)
}
copyPath := filepath.Join(t.TempDir(), "copy.db")
if err := os.WriteFile(copyPath, raw, 0o600); err != nil {
t.Fatalf("write copy: %v", err)
}
if err := ValidateSQLiteDB(copyPath); err != nil {
t.Fatalf("checkpointed raw copy must be a valid sqlite db: %v", err)
}
dump, err := DumpSQLiteToBytes(copyPath)
if err != nil {
t.Fatalf("dump copy: %v", err)
}
if !bytes.Contains(dump, []byte("walBackupProbe")) {
t.Fatal("raw-file backup taken after Checkpoint must contain the latest write")
}
}
@@ -0,0 +1,151 @@
package model
import (
"encoding/json"
"strings"
"testing"
)
func settingsVersion(t *testing.T, settings string) any {
t.Helper()
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
t.Fatalf("unmarshal settings: %v", err)
}
return parsed["version"]
}
func TestHealHysteriaVersion(t *testing.T) {
tests := []struct {
name string
settings string
wantChanged bool
wantVersion any
}{
{
name: "legacy v1 row",
settings: `{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`,
wantChanged: true,
wantVersion: float64(2),
},
{
name: "no version at all",
settings: `{"clients":[{"auth":"tok","email":"a@x"}]}`,
wantChanged: true,
wantVersion: float64(2),
},
{
name: "already v2",
settings: `{"version":2,"clients":[]}`,
wantChanged: false,
wantVersion: float64(2),
},
{
name: "version as a string",
settings: `{"version":"1","clients":[]}`,
wantChanged: true,
wantVersion: float64(2),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
healed, changed := HealHysteriaVersion(tt.settings)
if changed != tt.wantChanged {
t.Fatalf("changed = %v, want %v", changed, tt.wantChanged)
}
if got := settingsVersion(t, healed); got != tt.wantVersion {
t.Fatalf("version = %#v, want %#v", got, tt.wantVersion)
}
})
}
}
func TestHealHysteriaVersionKeepsClients(t *testing.T) {
healed, changed := HealHysteriaVersion(`{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`)
if !changed {
t.Fatal("a v1 row must be healed")
}
if !strings.Contains(healed, `"auth": "tok"`) || !strings.Contains(healed, `"email": "a@x"`) {
t.Fatalf("healing dropped client data: %s", healed)
}
}
func TestHealHysteriaVersionLeavesUnparsableSettings(t *testing.T) {
const broken = `{"version":1,`
healed, changed := HealHysteriaVersion(broken)
if changed || healed != broken {
t.Fatalf("unparsable settings must be left alone, got changed=%v %q", changed, healed)
}
if healed, changed := HealHysteriaVersion(""); changed || healed != "" {
t.Fatalf("empty settings must be left alone, got changed=%v %q", changed, healed)
}
}
func TestHealHysteriaStreamVersion(t *testing.T) {
healed, changed := HealHysteriaStreamVersion(`{"network":"hysteria","hysteriaSettings":{"version":1,"udpIdleTimeout":60}}`)
if !changed {
t.Fatal("a v1 transport must be healed")
}
var parsed map[string]any
if err := json.Unmarshal([]byte(healed), &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
hysteria, _ := parsed["hysteriaSettings"].(map[string]any)
if hysteria["version"] != float64(2) {
t.Fatalf("version = %#v, want 2", hysteria["version"])
}
if hysteria["udpIdleTimeout"] != float64(60) {
t.Fatalf("healing dropped transport settings: %#v", hysteria)
}
}
func TestHealHysteriaStreamVersionWithoutHysteriaSettings(t *testing.T) {
const stream = `{"network":"tcp","tcpSettings":{}}`
healed, changed := HealHysteriaStreamVersion(stream)
if changed || healed != stream {
t.Fatalf("a stream without hysteriaSettings must be left alone, got changed=%v %q", changed, healed)
}
}
// TestGenXrayInboundConfigHealsHysteriaVersion is the regression for a stored
// v1 row: xray-core answers "version != 2" and rejects the whole config, so
// every other inbound on the server stays offline until the row is fixed.
func TestGenXrayInboundConfigHealsHysteriaVersion(t *testing.T) {
in := Inbound{
Protocol: Hysteria,
Port: 36715,
Listen: "127.0.0.1",
Tag: "in-hysteria",
Settings: `{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`,
StreamSettings: `{"network":"hysteria","hysteriaSettings":{"version":1,"udpIdleTimeout":60}}`,
}
cfg := in.GenXrayInboundConfig()
if got := settingsVersion(t, string(cfg.Settings)); got != float64(2) {
t.Fatalf("generated settings.version = %#v, want 2", got)
}
var stream map[string]any
if err := json.Unmarshal(cfg.StreamSettings, &stream); err != nil {
t.Fatalf("unmarshal generated streamSettings: %v", err)
}
hysteria, _ := stream["hysteriaSettings"].(map[string]any)
if hysteria["version"] != float64(2) {
t.Fatalf("generated hysteriaSettings.version = %#v, want 2", hysteria["version"])
}
if !strings.Contains(in.Settings, `"version":1`) {
t.Fatal("the stored row must keep its own value; only the generated config is healed")
}
}
func TestGenXrayInboundConfigLeavesOtherProtocolsAlone(t *testing.T) {
in := Inbound{
Protocol: VLESS,
Port: 443,
Tag: "in-vless",
Settings: `{"clients":[],"decryption":"none"}`,
}
if got := settingsVersion(t, string(in.GenXrayInboundConfig().Settings)); got != nil {
t.Fatalf("a non-hysteria inbound must not gain a version key, got %#v", got)
}
}
+65 -1
View File
@@ -55,6 +55,7 @@ type Inbound struct {
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"` // Whether the inbound is enabled
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
TrafficResetDay int `json:"trafficResetDay" form:"trafficResetDay" gorm:"default:1" validate:"omitempty,gte=1,lte=31" example:"1"` // Day of month for monthly traffic resets
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
@@ -230,6 +231,57 @@ func jsonStringFieldFromRaw(r json.RawMessage) string {
return string(trimmed)
}
// hysteriaConfigVersion is the only hysteria version xray-core builds. Both
// the protocol settings and the transport settings answer anything else with
// "version != 2", and that error rejects the whole config — every other
// inbound on the server goes down with it, not just the hysteria one.
const hysteriaConfigVersion = 2
// HealHysteriaVersion pins a hysteria inbound's settings.version to the
// version xray-core accepts. Rows written before the panel settled on v2, or
// through the API and the raw JSON editor, can still carry the legacy 1 or no
// version at all, either of which stops the core from starting.
func HealHysteriaVersion(settings string) (string, bool) {
return healVersionField(settings, nil)
}
// HealHysteriaStreamVersion does the same for the transport half,
// streamSettings.hysteriaSettings.version, which xray-core validates
// separately. An absent hysteriaSettings object is left alone.
func HealHysteriaStreamVersion(streamSettings string) (string, bool) {
return healVersionField(streamSettings, []string{"hysteriaSettings"})
}
// healVersionField rewrites the "version" key of the object reached by path to
// hysteriaConfigVersion, reporting whether anything changed. A path that does
// not resolve to an object leaves the input untouched.
func healVersionField(raw string, path []string) (string, bool) {
if raw == "" {
return raw, false
}
var parsed map[string]any
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return raw, false
}
target := parsed
for _, key := range path {
next, ok := target[key].(map[string]any)
if !ok {
return raw, false
}
target = next
}
if version, ok := target["version"].(float64); ok && version == hysteriaConfigVersion {
return raw, false
}
target["version"] = hysteriaConfigVersion
out, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return raw, false
}
return string(out), true
}
// StripInboundXhttpClientFields removes xHTTP knobs that belong on the
// client dialer and subscription share-link extras only. xray-core's XHTTP
// inbound listener does not consume them; the panel still stores them on
@@ -299,11 +351,20 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
if converted, ok := WireguardClientsToPeers(settings); ok {
settings = converted
}
case Hysteria:
if healed, ok := HealHysteriaVersion(settings); ok {
settings = healed
}
}
streamSettings := i.StreamSettings
if stripped, ok := StripInboundXhttpClientFields(streamSettings); ok {
streamSettings = stripped
}
if i.Protocol == Hysteria {
if healed, ok := HealHysteriaStreamVersion(streamSettings); ok {
streamSettings = healed
}
}
return &xray.InboundConfig{
Listen: json_util.RawMessage(listen),
Port: i.Port,
@@ -847,12 +908,15 @@ type ClientRecord struct {
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
Enable bool `json:"enable" gorm:"default:true"`
TgID int64 `json:"tgId" gorm:"column:tg_id"`
TgID int64 `json:"tgId" gorm:"column:tg_id;index:idx_clients_tg_id"`
Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
Comment string `json:"comment"`
Reset int `json:"reset" gorm:"default:0"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
// Owned solely by the node-snapshot sweep, which soft-orphans instead of
// deleting; orphans from any other cause stay at zero and are never reaped.
SyncOrphanedAt int64 `json:"-" gorm:"column:sync_orphaned_at;default:0"`
}
func (ClientRecord) TableName() string { return "clients" }
+1 -1
View File
@@ -197,7 +197,7 @@ func TestBusSubscriberRunsSerially(t *testing.T) {
wg.Done()
})
for i := 0; i < n; i++ {
for range n {
b.Publish(Event{Type: EventXrayCrash})
}
+10
View File
@@ -21,6 +21,16 @@ func TestMain(m *testing.M) {
fmt.Fprintf(f, "%d\n", os.Getpid())
f.Close()
}
if exitFile := os.Getenv("MTG_FAKE_EXIT_FILE"); exitFile != "" {
for {
if _, err := os.Stat(exitFile); err == nil {
os.Exit(1)
} else if !os.IsNotExist(err) {
os.Exit(2)
}
time.Sleep(time.Millisecond)
}
}
select {}
}
os.Exit(m.Run())
+50 -29
View File
@@ -109,6 +109,7 @@ func (w *procLogWriter) LastLine() string {
// Process wraps a single mtg process invocation for one mtproto inbound.
type Process struct {
mu sync.RWMutex
cmd *exec.Cmd
done chan struct{}
configPath string
@@ -126,20 +127,20 @@ func newProcess(configPath, label string) *Process {
// IsRunning reports whether the mtg process is currently running.
func (p *Process) IsRunning() bool {
if p.cmd == nil || p.cmd.Process == nil {
p.mu.RLock()
cmd, done := p.cmd, p.done
p.mu.RUnlock()
if cmd == nil || cmd.Process == nil {
return false
}
if p.done != nil {
if done != nil {
select {
case <-p.done:
case <-done:
return false
default:
}
}
if p.cmd.ProcessState == nil {
return true
}
return false
return true
}
// GetResult returns the last log line or the exit error from the mtg process.
@@ -147,8 +148,11 @@ func (p *Process) GetResult() string {
if line := p.logWriter.LastLine(); line != "" {
return line
}
if p.exitErr != nil {
return p.exitErr.Error()
p.mu.RLock()
exitErr := p.exitErr
p.mu.RUnlock()
if exitErr != nil {
return exitErr.Error()
}
return ""
}
@@ -161,22 +165,27 @@ func (p *Process) Start() error {
cmd := exec.CommandContext(context.Background(), GetBinaryPath(), "run", p.configPath)
cmd.Stdout = p.logWriter
cmd.Stderr = p.logWriter
done := make(chan struct{})
p.mu.Lock()
p.cmd = cmd
p.done = make(chan struct{})
p.done = done
p.exitErr = nil
p.mu.Unlock()
p.intentionalStop.Store(false)
if err := cmd.Start(); err != nil {
close(p.done)
close(done)
p.mu.Lock()
p.cmd = nil
p.mu.Unlock()
return err
}
attachChildLifetime(cmd)
go p.wait(cmd)
go p.wait(cmd, done)
return nil
}
func (p *Process) wait(cmd *exec.Cmd) {
defer close(p.done)
func (p *Process) wait(cmd *exec.Cmd, done chan struct{}) {
defer close(done)
err := cmd.Wait()
p.logWriter.Flush()
if err == nil || p.intentionalStop.Load() {
@@ -184,12 +193,18 @@ func (p *Process) wait(cmd *exec.Cmd) {
}
if runtime.GOOS == "windows" {
if strings.Contains(strings.ToLower(err.Error()), "exit status 1") {
p.exitErr = err
p.setExitErr(err)
return
}
}
logger.Errorf("mtproto: mtg process exited: %v", err)
p.setExitErr(err)
}
func (p *Process) setExitErr(err error) {
p.mu.Lock()
p.exitErr = err
p.mu.Unlock()
}
// Stop terminates the running mtg process gracefully, falling back to a kill.
@@ -198,40 +213,46 @@ func (p *Process) Stop() error {
return errors.New("mtg is not running")
}
p.intentionalStop.Store(true)
if runtime.GOOS == "windows" {
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
return err
}
return p.waitForExit(forceStopTimeout)
p.mu.RLock()
cmd, done := p.cmd, p.done
p.mu.RUnlock()
if cmd == nil || cmd.Process == nil {
return errors.New("mtg is not running")
}
if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
if runtime.GOOS == "windows" {
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
return err
}
return waitForExit(done, forceStopTimeout)
}
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
if errors.Is(err, os.ErrProcessDone) {
return p.waitForExit(forceStopTimeout)
return waitForExit(done, forceStopTimeout)
}
return err
}
if err := p.waitForExit(gracefulStopTimeout); err == nil {
if err := waitForExit(done, gracefulStopTimeout); err == nil {
return nil
}
logger.Warning("mtproto: mtg did not stop after SIGTERM, killing process")
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
return err
}
return p.waitForExit(forceStopTimeout)
return waitForExit(done, forceStopTimeout)
}
func (p *Process) waitForExit(timeout time.Duration) error {
if p.done == nil {
func waitForExit(done <-chan struct{}, timeout time.Duration) error {
if done == nil {
return nil
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-p.done:
case <-done:
return nil
case <-timer.C:
return fmt.Errorf("timed out waiting for mtg process to stop after %s", timeout)
+106
View File
@@ -0,0 +1,106 @@
package mtproto
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func TestProcessLifecycleFieldsRaceSafe(t *testing.T) {
proc := newProcess("", "test")
stop := make(chan struct{})
var workers sync.WaitGroup
defer func() {
close(stop)
workers.Wait()
}()
workers.Go(func() {
for {
select {
case <-stop:
return
default:
}
proc.mu.Lock()
proc.cmd = &exec.Cmd{}
proc.done = make(chan struct{})
proc.mu.Unlock()
proc.setExitErr(errors.New("exit"))
}
})
for range 4 {
workers.Go(func() {
for {
select {
case <-stop:
return
default:
}
_ = proc.IsRunning()
_ = proc.GetResult()
}
})
}
time.Sleep(50 * time.Millisecond)
}
func TestProcessStatusDuringExit(t *testing.T) {
pidFile := installFakeMtg(t)
exitFile := filepath.Join(t.TempDir(), "exit")
t.Setenv("MTG_FAKE_EXIT_FILE", exitFile)
configPath := filepath.Join(t.TempDir(), "mtg.toml")
if err := os.WriteFile(configPath, nil, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
proc := newProcess(configPath, "test")
if err := proc.Start(); err != nil {
t.Fatalf("start process: %v", err)
}
t.Cleanup(func() {
_ = proc.Stop()
})
waitSpawnCount(t, pidFile, 1)
stopReads := make(chan struct{})
var readers sync.WaitGroup
defer func() {
close(stopReads)
readers.Wait()
}()
for range 4 {
readers.Go(func() {
for {
select {
case <-stopReads:
return
default:
_ = proc.IsRunning()
_ = proc.GetResult()
}
}
})
}
proc.mu.RLock()
done := proc.done
proc.mu.RUnlock()
if err := os.WriteFile(exitFile, nil, 0o600); err != nil {
t.Fatalf("trigger exit: %v", err)
}
if err := waitForExit(done, time.Second); err != nil {
t.Fatalf("wait for process exit: %v", err)
}
if proc.IsRunning() {
t.Fatal("process must not be running after exit")
}
if got := proc.GetResult(); !strings.Contains(got, "exit status 1") {
t.Fatalf("GetResult after an unexpected exit = %q, want exit status", got)
}
}
+2 -2
View File
@@ -103,7 +103,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
}
}
finalYAML, err := yaml.Marshal(config)
finalYAML, err := marshalClashYAML(config)
if err != nil {
return "", "", err
}
@@ -418,7 +418,7 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model
}
if dns, _ := inboundSettings["dns"].(string); dns != "" {
servers := make([]string, 0)
for _, server := range strings.Split(dns, ",") {
for server := range strings.SplitSeq(dns, ",") {
if server = strings.TrimSpace(server); server != "" {
servers = append(servers, server)
}
+89
View File
@@ -0,0 +1,89 @@
package sub
import (
"reflect"
"regexp"
"strings"
"github.com/goccy/go-yaml"
)
type yamlQuotedString string
func (s yamlQuotedString) MarshalYAML() ([]byte, error) {
return []byte("'" + strings.ReplaceAll(string(s), "'", "''") + "'"), nil
}
var yamlNonStringWords = map[string]bool{
"~": true, "null": true, "Null": true, "NULL": true,
"true": true, "True": true, "TRUE": true,
"false": true, "False": true, "FALSE": true,
"yes": true, "Yes": true, "YES": true,
"no": true, "No": true, "NO": true,
"on": true, "On": true, "ON": true,
"off": true, "Off": true, "OFF": true,
"y": true, "Y": true, "n": true, "N": true,
}
var yamlNonStringNumber = regexp.MustCompile(`^(?:` +
`[-+]?[0-9]+|` +
`0[oO]?[0-7]+|0[xX][0-9a-fA-F]+|` +
`[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+|` +
`[-+]?(?:[0-9]*\.[0-9]+|[0-9]+\.?[0-9]*)(?:[eE][-+]?[0-9]+)?|` +
`[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN)|` +
`[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}(?:[Tt ].*)?` +
`)$`)
func yamlScalarIsAmbiguous(s string) bool {
if s == "" {
return false
}
return yamlNonStringWords[s] || yamlNonStringNumber.MatchString(s)
}
func quoteAmbiguousYAMLScalars(v any) any {
if v == nil {
return nil
}
if s, ok := v.(string); ok {
if yamlScalarIsAmbiguous(s) {
return yamlQuotedString(s)
}
return s
}
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Map:
out := make(map[string]any, rv.Len())
iter := rv.MapRange()
for iter.Next() {
key, ok := iter.Key().Interface().(string)
if !ok {
return v
}
out[key] = quoteAmbiguousYAMLScalars(iter.Value().Interface())
}
return out
case reflect.Slice, reflect.Array:
if rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() == reflect.Uint8 {
return v
}
out := make([]any, rv.Len())
for i := range rv.Len() {
out[i] = quoteAmbiguousYAMLScalars(rv.Index(i).Interface())
}
return out
case reflect.Pointer, reflect.Interface:
if rv.IsNil() {
return v
}
return quoteAmbiguousYAMLScalars(rv.Elem().Interface())
default:
return v
}
}
func marshalClashYAML(config any) ([]byte, error) {
return yaml.Marshal(quoteAmbiguousYAMLScalars(config))
}
+129
View File
@@ -0,0 +1,129 @@
package sub
import (
"strings"
"testing"
"github.com/goccy/go-yaml"
)
func TestAmbiguousScalarsAreQuoted(t *testing.T) {
tests := []struct {
name string
value string
mustQuote bool
}{
{"reality short-id read as a float", "2351e1", true},
{"short exponent form", "0e1", true},
{"long exponent form", "12e34", true},
{"all digits", "123456", true},
{"leading zeros read as octal", "0177", true},
{"hex form", "0x1f", true},
{"decimal point", "1.5", true},
{"boolean word", "true", true},
{"single letter bool", "y", true},
{"null word", "null", true},
{"tilde", "~", true},
{"date", "2026-07-27", true},
{"sexagesimal", "12:30", true},
{"plain hex with letters", "6ba7b8", false},
{"letters only", "abcdef", false},
{"hostname", "example.com", false},
{"dotted quad", "1.2.3.4", false},
{"uuid", "6ba7b810-9dad-11d1-80b4-00c04fd430c8", false},
{"alpn token", "h2", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, err := marshalClashYAML(map[string]any{
"reality-opts": map[string]any{"short-id": tt.value},
})
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
got := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(string(out)), "reality-opts:"))
quoted := strings.Contains(got, "'"+tt.value+"'") || strings.Contains(got, `"`+tt.value+`"`)
if tt.mustQuote && !quoted {
t.Errorf("%q must be emitted quoted, got %q", tt.value, got)
}
if !tt.mustQuote && quoted {
t.Errorf("%q needs no quoting, got %q", tt.value, got)
}
var decoded map[string]any
if err := yaml.Unmarshal(out, &decoded); err != nil {
t.Fatalf("output must stay parseable: %v\n%s", err, out)
}
})
}
}
func TestClashShortIDIsQuotedInOutput(t *testing.T) {
out, err := marshalClashYAML(map[string]any{
"proxies": []any{map[string]any{
"name": "de-1",
"reality-opts": map[string]any{"short-id": "2351e1"},
}},
})
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
if !strings.Contains(string(out), `'2351e1'`) {
t.Errorf("short-id must be emitted as a quoted scalar, got:\n%s", out)
}
}
func TestAmbiguousPasswordIsQuoted(t *testing.T) {
out, err := marshalClashYAML(map[string]any{
"proxies": []any{map[string]any{
"name": "de-1",
"password": "80e12",
"obfs-password": "12345",
"pre-shared-key": "9e9",
}},
})
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
for _, want := range []string{`'80e12'`, `'12345'`, `'9e9'`} {
if !strings.Contains(string(out), want) {
t.Errorf("expected %s in output:\n%s", want, out)
}
}
}
func TestUnambiguousScalarsAreUnchanged(t *testing.T) {
config := map[string]any{
"port": 7890,
"mode": "rule",
"proxies": []any{map[string]any{"name": "de-1", "udp": true, "port": 443}},
"rules": []string{"MATCH,PROXY"},
"alpn": []string{"h2", "http/1.1"},
"nonempty": "example.com",
}
quoted, err := marshalClashYAML(config)
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
plain, err := yaml.Marshal(config)
if err != nil {
t.Fatalf("yaml.Marshal: %v", err)
}
if string(quoted) != string(plain) {
t.Errorf("unambiguous document changed:\n--- got ---\n%s\n--- want ---\n%s", quoted, plain)
}
}
func TestQuotedScalarEscapesQuotes(t *testing.T) {
out, err := marshalClashYAML(map[string]any{"password": "1e2'3"})
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
var decoded map[string]any
if err := yaml.Unmarshal(out, &decoded); err != nil {
t.Fatalf("output must stay parseable: %v\n%s", err, out)
}
if got := decoded["password"]; got != any("1e2'3") {
t.Errorf("password round-tripped to %#v\n%s", got, out)
}
}
+2 -2
View File
@@ -101,8 +101,8 @@ func linkDisplayName(rawLink string) string {
if rawLink == "" {
return ""
}
if strings.HasPrefix(rawLink, "vmess://") {
b64 := strings.TrimPrefix(rawLink, "vmess://")
if after, ok := strings.CutPrefix(rawLink, "vmess://"); ok {
b64 := after
raw, err := base64.StdEncoding.DecodeString(padBase64Sub(b64))
if err != nil {
raw, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(b64, "="))
+54 -9
View File
@@ -16,8 +16,9 @@ import (
// with a short timeout so a slow/dead provider can't stall a client's sub.
const (
subscriptionCacheTTL = 5 * time.Minute
subscriptionMaxBytes = 2 << 20 // 2 MiB
subscriptionCacheTTL = 5 * time.Minute
subscriptionMaxBytes = 2 << 20 // 2 MiB
subscriptionCacheCapacity = 256
)
var subscriptionHTTPClient = &http.Client{Timeout: 6 * time.Second}
@@ -27,10 +28,19 @@ type subscriptionCacheEntry struct {
fetchedAt time.Time
}
type subscriptionFetch struct {
done chan struct{}
links []string
}
var subscriptionCache = struct {
sync.Mutex
m map[string]subscriptionCacheEntry
}{m: make(map[string]subscriptionCacheEntry)}
m map[string]subscriptionCacheEntry
inflight map[string]*subscriptionFetch
}{
m: make(map[string]subscriptionCacheEntry),
inflight: make(map[string]*subscriptionFetch),
}
// fetchSubscriptionLinks returns the share links contained in a remote
// subscription URL, using a short-lived cache. On any failure it returns the
@@ -44,24 +54,59 @@ func fetchSubscriptionLinks(rawURL string) []string {
subscriptionCache.Lock()
cached, ok := subscriptionCache.m[rawURL]
subscriptionCache.Unlock()
if ok && time.Since(cached.fetchedAt) < subscriptionCacheTTL {
subscriptionCache.Unlock()
return cached.links
}
if fetch, waiting := subscriptionCache.inflight[rawURL]; waiting {
subscriptionCache.Unlock()
<-fetch.done
return fetch.links
}
fetch := &subscriptionFetch{done: make(chan struct{})}
subscriptionCache.inflight[rawURL] = fetch
subscriptionCache.Unlock()
defer func() {
subscriptionCache.Lock()
close(fetch.done)
delete(subscriptionCache.inflight, rawURL)
subscriptionCache.Unlock()
}()
links, err := doFetchSubscriptionLinks(rawURL)
if err != nil {
// Serve stale on error rather than dropping the client's configs.
if ok {
return cached.links
fetch.links = cached.links
}
return nil
return fetch.links
}
subscriptionCache.Lock()
subscriptionCache.m[rawURL] = subscriptionCacheEntry{links: links, fetchedAt: time.Now()}
trimSubscriptionCacheLocked(rawURL)
subscriptionCache.Unlock()
return links
fetch.links = links
return fetch.links
}
func trimSubscriptionCacheLocked(keep string) {
for len(subscriptionCache.m) > subscriptionCacheCapacity {
var oldestURL string
var oldest time.Time
for rawURL, entry := range subscriptionCache.m {
if rawURL == keep {
continue
}
if oldestURL == "" || entry.fetchedAt.Before(oldest) {
oldestURL = rawURL
oldest = entry.fetchedAt
}
}
if oldestURL == "" {
return
}
delete(subscriptionCache.m, oldestURL)
}
}
func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
+136
View File
@@ -4,10 +4,146 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func resetSubscriptionCache(t *testing.T) {
t.Helper()
subscriptionCache.Lock()
previousEntries := subscriptionCache.m
previousInflight := subscriptionCache.inflight
subscriptionCache.m = make(map[string]subscriptionCacheEntry)
subscriptionCache.inflight = make(map[string]*subscriptionFetch)
subscriptionCache.Unlock()
t.Cleanup(func() {
subscriptionCache.Lock()
subscriptionCache.m = previousEntries
subscriptionCache.inflight = previousInflight
subscriptionCache.Unlock()
})
}
func TestFetchSubscriptionLinksSharesConcurrentRefresh(t *testing.T) {
resetSubscriptionCache(t)
var requests atomic.Int32
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
<-release
_, _ = w.Write([]byte("vless://uuid@example.com:443"))
}))
defer srv.Close()
const callers = 16
results := make(chan []string, callers)
var wg sync.WaitGroup
for range callers {
wg.Go(func() {
results <- fetchSubscriptionLinks(srv.URL)
})
}
time.Sleep(100 * time.Millisecond)
close(release)
wg.Wait()
close(results)
for links := range results {
if len(links) != 1 || links[0] != "vless://uuid@example.com:443" {
t.Fatalf("links = %#v", links)
}
}
if got := requests.Load(); got != 1 {
t.Fatalf("requests = %d, want 1", got)
}
}
func TestFetchSubscriptionLinksBoundsCacheSize(t *testing.T) {
resetSubscriptionCache(t)
var requests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
_, _ = w.Write([]byte("vless://uuid@example.com:443"))
}))
defer srv.Close()
for i := range subscriptionCacheCapacity + 1 {
links := fetchSubscriptionLinks(srv.URL + "?id=" + strconv.Itoa(i))
if len(links) != 1 {
t.Fatalf("links at %d = %#v", i, links)
}
}
subscriptionCache.Lock()
entries := len(subscriptionCache.m)
subscriptionCache.Unlock()
if entries != subscriptionCacheCapacity {
t.Fatalf("cache entries = %d, want %d", entries, subscriptionCacheCapacity)
}
if got := requests.Load(); got != subscriptionCacheCapacity+1 {
t.Fatalf("requests = %d, want %d", got, subscriptionCacheCapacity+1)
}
}
func TestFetchSubscriptionLinksSharesStaleResultAfterRefreshFailure(t *testing.T) {
resetSubscriptionCache(t)
stale := []string{"vless://stale@example.com:443"}
release := make(chan struct{})
var staleRequests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/stale" {
staleRequests.Add(1)
<-release
w.WriteHeader(http.StatusBadGateway)
return
}
_, _ = w.Write([]byte("vless://fresh@example.com:443"))
}))
defer srv.Close()
staleURL := srv.URL + "/stale"
subscriptionCache.Lock()
subscriptionCache.m[staleURL] = subscriptionCacheEntry{
links: stale,
fetchedAt: time.Now().Add(-subscriptionCacheTTL),
}
for i := range subscriptionCacheCapacity - 1 {
subscriptionCache.m["cached-"+strconv.Itoa(i)] = subscriptionCacheEntry{fetchedAt: time.Now()}
}
subscriptionCache.Unlock()
const callers = 16
results := make(chan []string, callers)
var wg sync.WaitGroup
for range callers {
wg.Go(func() {
results <- fetchSubscriptionLinks(staleURL)
})
}
time.Sleep(100 * time.Millisecond)
if links := fetchSubscriptionLinks(srv.URL + "/fresh"); len(links) != 1 || links[0] != "vless://fresh@example.com:443" {
t.Fatalf("fresh links = %#v", links)
}
close(release)
wg.Wait()
close(results)
for links := range results {
if len(links) != 1 || links[0] != stale[0] {
t.Fatalf("links = %#v, want %#v", links, stale)
}
}
if got := staleRequests.Load(); got != 1 {
t.Fatalf("requests = %d, want 1", got)
}
}
func TestDoFetchSubscriptionLinks_RejectsOversizedBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(strings.Repeat("a", subscriptionMaxBytes+1)))
+92
View File
@@ -0,0 +1,92 @@
package sub
import (
"net"
"net/netip"
"strings"
"sync"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
var warnSuppressedForwardedOnce sync.Once
var forwardedHeaderNames = [...]string{"X-Forwarded-Host", "X-Forwarded-Proto", "X-Real-IP"}
func (s *SubService) forwardedHeadersTrusted(c *gin.Context) (trusted bool) {
if !hasForwardedHeaders(c) {
return true
}
trusted = true
defer func() {
_ = recover()
}()
configured, err := s.settingService.GetTrustedProxyCIDRs()
if err != nil {
return true
}
configured = strings.TrimSpace(configured)
if configured == "" || configured == service.DefaultTrustedProxyCIDRs {
return true
}
return remoteAddrInCIDRs(c.Request.RemoteAddr, configured)
}
func hasForwardedHeaders(c *gin.Context) bool {
for _, name := range forwardedHeaderNames {
if c.GetHeader(name) != "" {
return true
}
}
return false
}
func warnSuppressedForwardedHeaders(c *gin.Context) {
present := make([]string, 0, 3)
for _, name := range forwardedHeaderNames {
if c.GetHeader(name) != "" {
present = append(present, name)
}
}
if len(present) == 0 {
return
}
headers := strings.Join(present, ", ")
logger.Debugf("sub: ignoring %s from %s, which is outside trustedProxyCIDRs", headers, c.Request.RemoteAddr)
warnSuppressedForwardedOnce.Do(func() {
logger.Warningf("sub: ignoring %s from %s because it is outside trustedProxyCIDRs; subscription URLs will use the request host. Add the proxy to that setting, or set subURI, if the generated links look wrong.", headers, c.Request.RemoteAddr)
})
}
func remoteAddrInCIDRs(remoteAddr, cidrs string) bool {
host := remoteAddr
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
host = h
}
addr, err := netip.ParseAddr(strings.TrimSpace(host))
if err != nil {
return false
}
addr = addr.Unmap()
for value := range strings.SplitSeq(cidrs, ",") {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if prefix, err := netip.ParsePrefix(value); err == nil {
if prefix.Contains(addr) {
return true
}
continue
}
if proxyIP, err := netip.ParseAddr(value); err == nil && proxyIP.Unmap() == addr.Unmap() {
return true
}
}
return false
}
+201
View File
@@ -0,0 +1,201 @@
package sub
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
func requestFrom(t *testing.T, remoteAddr string, headers map[string]string) *gin.Context {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/sub/abc", nil)
req.Host = "panel.example.com:2096"
req.RemoteAddr = remoteAddr
for k, v := range headers {
req.Header.Set(k, v)
}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = req
return c
}
func setTrustedProxyCIDRs(t *testing.T, value string) {
t.Helper()
if err := database.GetDB().Create(&model.Setting{Key: "trustedProxyCIDRs", Value: value}).Error; err != nil {
t.Fatalf("set trustedProxyCIDRs: %v", err)
}
settingService := service.SettingService{}
stored, err := settingService.GetTrustedProxyCIDRs()
if err != nil {
t.Fatalf("read trustedProxyCIDRs back through SettingService: %v", err)
}
if stored != value {
t.Fatalf("SettingService reads trustedProxyCIDRs as %q, want %q — the key this helper writes has drifted", stored, value)
}
}
func TestResolveRequest_ForwardedHeaderTrust(t *testing.T) {
tests := []struct {
name string
stored *string
remoteAddr string
wantScheme string
wantHost string
wantHostWithPort string
wantHostHeader string
}{
{
name: "no stored row keeps trusting forwarded headers",
stored: nil,
remoteAddr: "203.0.113.9:51000",
wantScheme: "https",
wantHost: "sub.example.net",
wantHostWithPort: "sub.example.net",
wantHostHeader: "sub.example.net",
},
{
name: "empty stored value keeps trusting forwarded headers",
stored: new(""),
remoteAddr: "203.0.113.9:51000",
wantScheme: "https",
wantHost: "sub.example.net",
wantHostWithPort: "sub.example.net",
wantHostHeader: "sub.example.net",
},
{
name: "stored shipped default keeps trusting forwarded headers",
stored: new(service.DefaultTrustedProxyCIDRs),
remoteAddr: "203.0.113.9:51000",
wantScheme: "https",
wantHost: "sub.example.net",
wantHostWithPort: "sub.example.net",
wantHostHeader: "sub.example.net",
},
{
name: "declared boundary ignores an origin outside it",
stored: new("10.0.0.0/8"),
remoteAddr: "203.0.113.9:51000",
wantScheme: "http",
wantHost: "panel.example.com",
wantHostWithPort: "panel.example.com:2096",
wantHostHeader: "panel.example.com",
},
{
name: "declared boundary trusts an origin inside it",
stored: new("10.0.0.0/8"),
remoteAddr: "10.1.2.3:44000",
wantScheme: "https",
wantHost: "sub.example.net",
wantHostWithPort: "sub.example.net",
wantHostHeader: "sub.example.net",
},
{
name: "declared boundary ignores an unparsable origin",
stored: new("10.0.0.0/8"),
remoteAddr: "not-an-ip",
wantScheme: "http",
wantHost: "panel.example.com",
wantHostWithPort: "panel.example.com:2096",
wantHostHeader: "panel.example.com",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
initSubDB(t)
if tc.stored != nil {
setTrustedProxyCIDRs(t, *tc.stored)
}
s := &SubService{}
c := requestFrom(t, tc.remoteAddr, map[string]string{
"X-Forwarded-Host": "sub.example.net",
"X-Forwarded-Proto": "https",
})
scheme, host, hostWithPort, hostHeader := s.ResolveRequest(c)
if scheme != tc.wantScheme {
t.Errorf("scheme = %q, want %q", scheme, tc.wantScheme)
}
if host != tc.wantHost {
t.Errorf("host = %q, want %q", host, tc.wantHost)
}
if hostWithPort != tc.wantHostWithPort {
t.Errorf("hostWithPort = %q, want %q", hostWithPort, tc.wantHostWithPort)
}
if hostHeader != tc.wantHostHeader {
t.Errorf("hostHeader = %q, want %q", hostHeader, tc.wantHostHeader)
}
})
}
}
func TestResolveRequest_GatesRealIPFallback(t *testing.T) {
initSubDB(t)
setTrustedProxyCIDRs(t, "10.0.0.0/8")
s := &SubService{}
c := requestFrom(t, "203.0.113.9:51000", map[string]string{
"X-Real-IP": "198.51.100.7",
})
_, host, _, hostHeader := s.ResolveRequest(c)
if host != "panel.example.com" {
t.Errorf("host = %q, want the request host — X-Real-IP from an untrusted origin must be ignored", host)
}
if hostHeader != "panel.example.com" {
t.Errorf("hostHeader = %q, want the request host", hostHeader)
}
}
func TestHasForwardedHeaders(t *testing.T) {
tests := []struct {
name string
headers map[string]string
want bool
}{
{name: "no forwarded headers", want: false},
{name: "forwarded host", headers: map[string]string{"X-Forwarded-Host": "sub.example.net"}, want: true},
{name: "forwarded proto", headers: map[string]string{"X-Forwarded-Proto": "https"}, want: true},
{name: "real ip", headers: map[string]string{"X-Real-IP": "10.1.2.3"}, want: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := hasForwardedHeaders(requestFrom(t, "10.1.2.3:1234", tc.headers)); got != tc.want {
t.Errorf("hasForwardedHeaders() = %v, want %v", got, tc.want)
}
})
}
}
func TestRemoteAddrInCIDRs(t *testing.T) {
tests := []struct {
name string
remoteAddr string
cidrs string
want bool
}{
{name: "inside cidr", remoteAddr: "10.1.2.3:1234", cidrs: "10.0.0.0/8", want: true},
{name: "ipv4 mapped address inside cidr", remoteAddr: "[::ffff:10.1.2.3]:1234", cidrs: "10.0.0.0/8", want: true},
{name: "outside cidr", remoteAddr: "203.0.113.9:1234", cidrs: "10.0.0.0/8", want: false},
{name: "bare address entry", remoteAddr: "192.168.1.5:80", cidrs: "192.168.1.5", want: true},
{name: "ipv6 loopback", remoteAddr: "[::1]:8080", cidrs: "::1/128", want: true},
{name: "no port", remoteAddr: "10.1.2.3", cidrs: "10.0.0.0/8", want: true},
{name: "unparsable origin", remoteAddr: "not-an-ip", cidrs: "10.0.0.0/8", want: false},
{name: "empty entries skipped", remoteAddr: "10.1.2.3:1", cidrs: " , 10.0.0.0/8 , ", want: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := remoteAddrInCIDRs(tc.remoteAddr, tc.cidrs); got != tc.want {
t.Errorf("remoteAddrInCIDRs(%q, %q) = %v, want %v", tc.remoteAddr, tc.cidrs, got, tc.want)
}
})
}
}
+87
View File
@@ -0,0 +1,87 @@
package sub
import (
"fmt"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func seedFlowInbound(t *testing.T, subId, tag string, port int, stream string) *model.Inbound {
t.Helper()
db := database.GetDB()
uuid := "11111111-2222-4333-8444-" + fmt.Sprintf("%012d", port)
email := tag + "@e"
settings := fmt.Sprintf(
`{"clients":[{"id":%q,"email":%q,"subId":%q,"enable":true,"flow":"xtls-rprx-vision"}],"decryption":"none"}`,
uuid, email, subId)
ib := &model.Inbound{
UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
Protocol: model.VLESS, Remark: tag, Settings: settings, StreamSettings: stream,
SubSortIndex: 1,
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("seed inbound %s: %v", tag, err)
}
client := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
if err := db.Create(client).Error; err != nil {
t.Fatalf("seed client %s: %v", email, err)
}
link := &model.ClientInbound{ClientId: client.Id, InboundId: ib.Id, FlowOverride: "xtls-rprx-vision"}
if err := db.Create(link).Error; err != nil {
t.Fatalf("seed client_inbound %s: %v", email, err)
}
return ib
}
// A vision flow left on a client after its inbound moved to a transport Vision
// cannot use is stripped from the raw link and the Clash proxy; the JSON
// subscription must agree instead of emitting an outbound xray rejects.
func TestSub_JSONStripsFlowOnUnsupportedTransport(t *testing.T) {
seedSubDB(t)
seedFlowInbound(t, "s1", "wsflow", 4601, wsTLSStream)
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
if err != nil {
t.Fatalf("GetSubs: %v", err)
}
if joined := strings.Join(links, "\n"); strings.Contains(joined, "flow=") {
t.Fatalf("raw link must not carry a flow on ws+tls: %s", joined)
}
clash := NewSubClashService(false, "", NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
if strings.Contains(yaml, "flow:") {
t.Fatalf("clash proxy must not carry a flow on ws+tls:\n%s", yaml)
}
js := NewSubJsonService("", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if strings.Contains(out, `"flow"`) {
t.Fatalf("json outbound must not carry a flow on ws+tls:\n%s", out)
}
}
// The gate must not strip a flow the transport does support.
func TestSub_JSONKeepsFlowOnTcpTLS(t *testing.T) {
seedSubDB(t)
seedFlowInbound(t, "s1", "tcpflow", 4602,
`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
js := NewSubJsonService("", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if !strings.Contains(out, `"flow": "xtls-rprx-vision"`) {
t.Fatalf("json outbound must keep the vision flow on tcp+tls:\n%s", out)
}
}
+7
View File
@@ -218,6 +218,13 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
case "vless":
vc := client
vc.ID = applyVlessRoute(client.ID, hostVlessRoute(extPrxy))
// Same gate the raw link and the Clash proxy apply: a flow left
// over from a transport Vision supported produces an outbound
// xray refuses to start.
newNetwork, _ := newStream["network"].(string)
if vc.Flow != "" && !vlessFlowAllowed(newNetwork, security, subReq.linkSettings(inbound)) {
vc.Flow = ""
}
newOutbounds = append(newOutbounds, s.genVless(subReq, inbound, streamSettings, vc, jsonMux(mux, hostMux)))
case "trojan", "shadowsocks":
newOutbounds = append(newOutbounds, s.genServer(subReq, inbound, streamSettings, client, jsonMux(mux, hostMux)))
+79 -22
View File
@@ -40,6 +40,26 @@ func (ctx remarkContext) configName() string {
// underscores only, so ordinary braces in a remark are left untouched.
var remarkVarRe = regexp.MustCompile(`\{\{([A-Z_]+)\}\}`)
// remarkToken is one {{TOKEN}} occurrence: its name and the byte range it spans
// in the segment it was found in.
type remarkToken struct {
name string
start int
end int
}
// remarkTokens locates every {{TOKEN}} in seg. Both the template-level filter and
// the value-level expansion walk a segment through this, so they share one notion
// of where a token begins and ends and what the literal text between two of them is.
func remarkTokens(seg string) []remarkToken {
locs := remarkVarRe.FindAllStringSubmatchIndex(seg, -1)
tokens := make([]remarkToken, len(locs))
for i, loc := range locs {
tokens[i] = remarkToken{name: seg[loc[2]:loc[3]], start: loc[0], end: loc[1]}
}
return tokens
}
// unlimitedMark is the value the human-readable quota/expiry tokens render when
// the client has no limit. A segment built only around such a token carries no
// information, so it is dropped rather than printed as "∞" (see expandRemarkVars).
@@ -107,7 +127,9 @@ func translateUISingleBrackets(template string) string {
// value. Unknown tokens resolve to "" (never the literal text). The template is
// split on "|" into segments: a segment whose only value is an unlimited quota
// or expiry (∞) drops out whole — decoration and separator included — so an
// unlimited client gets "host" instead of "host|📊∞|⏳∞D".
// unlimited client gets "host" instead of "host|📊∞|⏳∞D". Inside a surviving
// segment expandSegment also elides a hyphen separator an empty token would
// leave dangling.
func expandRemarkVars(template string, ctx remarkContext) string {
template = translateUISingleBrackets(template)
if !strings.Contains(template, "{{") {
@@ -129,18 +151,43 @@ func expandRemarkVars(template string, ctx remarkContext) string {
// — so it leaves no stray "|" separator or dangling decoration. A segment mixing,
// say, {{EMAIL}} with {{TRAFFIC_LEFT}} is kept, and a pure-literal segment (no
// tokens) is always kept.
//
// A hyphen standing alone between two adjacent tokens is treated as their
// separator and elided when no token before it has produced a value yet or when
// the token after it resolves to nothing. "{{INBOUND}}-{{EMAIL}}" gives "john"
// for an inbound with no remark, "🌐{{INBOUND}}-{{EMAIL}}" gives "🌐john" so
// leading decoration does not keep the separator alive, and
// "{{EMAIL}}-{{INBOUND}}-{{EMAIL}}" keeps a single separator when the middle
// token is empty. A hyphen anywhere else in the segment is literal text and is
// kept as written.
func expandSegment(seg string, ctx remarkContext) (string, bool) {
hasToken, hasOtherValue := false, false
out := remarkVarRe.ReplaceAllStringFunc(seg, func(m string) string {
hasToken = true
token := m[2 : len(m)-2]
val := remarkVarValue(token, ctx)
if val != "" && (!unlimitedDropTokens[token] || val != unlimitedMark) {
tokens := remarkTokens(seg)
hasToken, hasOtherValue := len(tokens) > 0, false
values := make([]string, len(tokens))
for i, tok := range tokens {
val := remarkVarValue(tok.name, ctx)
values[i] = val
if val != "" && (!unlimitedDropTokens[tok.name] || val != unlimitedMark) {
hasOtherValue = true
}
return val
})
return out, hasToken && !hasOtherValue
}
var result strings.Builder
start, wroteValue := 0, false
for i, tok := range tokens {
result.WriteString(seg[start:tok.start])
result.WriteString(values[i])
wroteValue = wroteValue || values[i] != ""
start = tok.end
if i+1 < len(tokens) {
between := seg[start:tokens[i+1].start]
if strings.TrimSpace(between) == "-" && (!wroteValue || values[i+1] == "") {
start = tokens[i+1].start
}
}
}
result.WriteString(seg[start:])
return result.String(), hasToken && !hasOtherValue
}
func remarkVarValue(token string, ctx remarkContext) string {
@@ -511,11 +558,17 @@ func filterRemarkTemplate(template string, remove map[string]bool) string {
return strings.Join(kept, "|")
}
// filterRemarkSegment drops whole token categories from one segment while it is
// still a template, before any value is known. Literal text touching a removed
// token goes with it and the surviving runs rejoin with a space, so filtering the
// usage tokens out of "{{EMAIL}} 📊{{TRAFFIC_LEFT}}" leaves "{{EMAIL}}". This is
// the template-level counterpart to expandSegment, which works one layer later on
// tokens that survive here but resolve to an empty value.
func filterRemarkSegment(seg string, remove map[string]bool) string {
locs := remarkVarRe.FindAllStringSubmatchIndex(seg, -1)
tokens := remarkTokens(seg)
hasRemove := false
for _, loc := range locs {
if remove[seg[loc[2]:loc[3]]] {
for _, tok := range tokens {
if remove[tok.name] {
hasRemove = true
break
}
@@ -525,28 +578,28 @@ func filterRemarkSegment(seg string, remove map[string]bool) string {
}
runs := make([]string, 0, 2)
runStart, leftRemoved := 0, false
for _, loc := range locs {
if !remove[seg[loc[2]:loc[3]]] {
for _, tok := range tokens {
if !remove[tok.name] {
continue
}
runs = appendKeptRun(runs, seg[runStart:loc[0]], leftRemoved, true)
runStart, leftRemoved = loc[1], true
runs = appendKeptRun(runs, seg[runStart:tok.start], leftRemoved, true)
runStart, leftRemoved = tok.end, true
}
runs = appendKeptRun(runs, seg[runStart:], leftRemoved, false)
return strings.Join(runs, " ")
}
func appendKeptRun(runs []string, run string, leftRemoved, rightRemoved bool) []string {
locs := remarkVarRe.FindAllStringSubmatchIndex(run, -1)
if len(locs) == 0 {
tokens := remarkTokens(run)
if len(tokens) == 0 {
return runs
}
start, end := 0, len(run)
if leftRemoved {
start = locs[0][0]
start = tokens[0].start
}
if rightRemoved {
end = locs[len(locs)-1][1]
end = tokens[len(tokens)-1].end
}
if frag := strings.TrimSpace(run[start:end]); frag != "" {
runs = append(runs, frag)
@@ -560,7 +613,11 @@ func (s *SubService) effectiveTemplate(email string) string {
s.usageShown = map[string]bool{}
}
if s.usageShown[email] {
return filterRemarkTemplate(translated, firstLinkOnlyBodyTokens)
remove := firstLinkOnlyBodyTokens
if s.showIdentityOnAllLinks {
remove = usageInfoTokens
}
return filterRemarkTemplate(translated, remove)
}
s.usageShown[email] = true
return translated
+72
View File
@@ -102,6 +102,41 @@ func TestExpandRemarkVars_EdgeCases(t *testing.T) {
}
}
// defaultRemarkTemplate mirrors the panel's shipped remark template, the one an
// inbound with no remark used to render with a leading hyphen.
const defaultRemarkTemplate = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
func TestExpandRemarkVars_DropsHyphenBetweenEmptyTokens(t *testing.T) {
cases := []struct {
name string
tmpl string
inbound string
email string
want string
}{
{name: "both values", tmpl: "{{INBOUND}}-{{EMAIL}}", inbound: "Germany", email: "john", want: "Germany-john"},
{name: "empty inbound", tmpl: "{{INBOUND}}-{{EMAIL}}", email: "john", want: "john"},
{name: "empty email", tmpl: "{{INBOUND}} - {{EMAIL}}", inbound: "Germany", want: "Germany"},
{name: "literal leading hyphen", tmpl: "-{{EMAIL}}", email: "john", want: "-john"},
{name: "literal leading hyphen before an empty token", tmpl: "-{{INBOUND}}-{{EMAIL}}", email: "john", want: "-john"},
{name: "empty var between two values keeps one separator", tmpl: "{{EMAIL}}-{{INBOUND}}-{{EMAIL}}", email: "john", want: "john-john"},
{name: "emoji decoration before an empty token", tmpl: "🌐{{INBOUND}}-{{EMAIL}}", email: "john", want: "🌐john"},
{name: "literal word before an empty token", tmpl: "Sub {{INBOUND}}-{{EMAIL}}", email: "john", want: "Sub john"},
{name: "decoration kept when both tokens resolve", tmpl: "🌐{{INBOUND}}-{{EMAIL}}", inbound: "Germany", email: "john", want: "🌐Germany-john"},
{name: "default template, empty inbound", tmpl: defaultRemarkTemplate, email: "john", want: "john"},
{name: "default template, empty email", tmpl: defaultRemarkTemplate, inbound: "Germany", want: "Germany"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
ctx := expandCtx(model.Client{Email: tt.email}, xray.ClientTraffic{Enable: true}, &model.Inbound{Remark: tt.inbound})
if got := expandRemarkVars(tt.tmpl, ctx); got != tt.want {
t.Errorf("expandRemarkVars(%q) = %q, want %q", tt.tmpl, got, tt.want)
}
})
}
}
// An unlimited client drops the quota/expiry segments whole — decoration and the
// "|" separator included — instead of printing "📊∞|⏳∞D".
func TestExpandRemarkVars_DropUnlimitedSegments(t *testing.T) {
@@ -652,3 +687,40 @@ func TestEmailOnFirstLinkOnly(t *testing.T) {
t.Fatalf("second link should still carry the inbound name: %q", second)
}
}
func TestIdentityOnAllLinks(t *testing.T) {
const template = "{{INBOUND}}-{{EMAIL}}|{{USERNAME}}|📊{{TRAFFIC_LEFT}}|{{STATUS_EMOJI}}"
inbound := &model.Inbound{
Remark: "DE",
ClientStats: []xray.ClientTraffic{{
Email: "alice@x",
Enable: true,
Total: 100 * gb,
Up: 20 * gb,
}},
}
tests := []struct {
name string
enabled bool
wantSecond string
}{
{name: "disabled", wantSecond: "DE"},
{name: "enabled", enabled: true, wantSecond: "DE-alice@x|alice@x"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &SubService{
remarkTemplate: template,
subscriptionBody: true,
showIdentityOnAllLinks: tt.enabled,
}
client := model.Client{Email: "alice@x"}
if got := s.genTemplatedRemark(inbound, client, "", "ws"); got != "DE-alice@x|alice@x|📊80.00GB|✅" {
t.Fatalf("first link = %q", got)
}
if got := s.genTemplatedRemark(inbound, client, "", "ws"); got != tt.wantSecond {
t.Fatalf("second link = %q, want %q", got, tt.wantSecond)
}
})
}
}
+34 -15
View File
@@ -41,9 +41,10 @@ type SubService struct {
// usageShown tracks, per client email, whether the info part of the template
// has already been emitted this request, so it appears on the first body
// link only. Per-request state; reset in PrepareForRequest.
usageShown map[string]bool
inboundService service.InboundService
settingService service.SettingService
usageShown map[string]bool
showIdentityOnAllLinks bool
inboundService service.InboundService
settingService service.SettingService
// nodesByID is populated per request from the Node table so
// resolveInboundAddress can return the node's address for any
// inbound whose NodeID is set. Keeps the per-link host derivation
@@ -198,6 +199,10 @@ func (s *SubService) loadRemarkSettings() {
if err != nil {
s.datepicker = "gregorian"
}
s.showIdentityOnAllLinks, err = s.settingService.GetSubShowIdentityOnAllLinks()
if err != nil {
s.showIdentityOnAllLinks = false
}
}
func (s *SubService) configuredPublicHost() string {
@@ -1726,12 +1731,6 @@ func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, sec
}
if fp, ok := ep["fingerprint"].(string); ok && fp != "" {
tlsSettings["fingerprint"] = fp
settings, _ := tlsSettings["settings"].(map[string]any)
if settings == nil {
settings = map[string]any{}
tlsSettings["settings"] = settings
}
settings["fingerprint"] = fp
}
if alpn, ok := externalProxyALPNList(ep["alpn"]); ok {
tlsSettings["alpn"] = alpn
@@ -2119,6 +2118,15 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any {
}
}
}
// Older clients still read the pre-#6258 names from the subscription
// extra JSON. Emit aliases after lifting legacy inputs so both old and
// new clients can consume the same link.
if v, ok := extra["sessionIDPlacement"].(string); ok && len(v) > 0 {
extra["sessionPlacement"] = v
}
if v, ok := extra["sessionIDKey"].(string); ok && len(v) > 0 {
extra["sessionKey"] = v
}
for _, field := range []string{"uplinkChunkSize"} {
if v, ok := nonZeroShareValue(xhttp[field]); ok {
@@ -2579,18 +2587,29 @@ type PageData struct {
// ResolveRequest extracts scheme and host info from request/headers consistently.
// ResolveRequest extracts scheme, host, and header information from an HTTP request.
func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, hostWithPort string, hostHeader string) {
trusted := s.forwardedHeadersTrusted(c)
if !trusted {
warnSuppressedForwardedHeaders(c)
}
forwarded := func(name string) string {
if !trusted {
return ""
}
return c.GetHeader(name)
}
// scheme
scheme = "http"
if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
if c.Request.TLS != nil || strings.EqualFold(forwarded("X-Forwarded-Proto"), "https") {
scheme = "https"
}
// base host (no port)
if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil && h != "" {
if h, err := getHostFromXFH(forwarded("X-Forwarded-Host")); err == nil && h != "" {
host = h
}
if host == "" {
host = c.GetHeader("X-Real-IP")
host = forwarded("X-Real-IP")
}
if host == "" {
var err error
@@ -2601,7 +2620,7 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
}
// host:port for URLs
hostWithPort = c.GetHeader("X-Forwarded-Host")
hostWithPort = forwarded("X-Forwarded-Host")
if hostWithPort == "" {
hostWithPort = c.Request.Host
}
@@ -2610,9 +2629,9 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
}
// header display host
hostHeader = c.GetHeader("X-Forwarded-Host")
hostHeader = forwarded("X-Forwarded-Host")
if hostHeader == "" {
hostHeader = c.GetHeader("X-Real-IP")
hostHeader = forwarded("X-Real-IP")
}
if hostHeader == "" {
hostHeader = host
+50
View File
@@ -335,6 +335,8 @@ func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) {
"mode": "packet-up",
"xPaddingBytes": "100-1000",
"uplinkHTTPMethod": "GET",
"sessionIDPlacement": "header",
"sessionIDKey": "X-Session",
"uplinkChunkSize": float64(4096),
"noGRPCHeader": true,
"scMinPostsIntervalMs": "20-40",
@@ -375,6 +377,16 @@ func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) {
if extra["mode"] != "packet-up" {
t.Fatalf("extra[mode] = %#v, want packet-up", extra["mode"])
}
for key, want := range map[string]string{
"sessionIDPlacement": "header",
"sessionIDKey": "X-Session",
"sessionPlacement": "header",
"sessionKey": "X-Session",
} {
if extra[key] != want {
t.Fatalf("extra[%s] = %#v, want %q; extra %#v", key, extra[key], want, extra)
}
}
headers, ok := extra["headers"].(map[string]any)
if !ok {
@@ -388,6 +400,24 @@ func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) {
}
}
func TestBuildXhttpExtra_LegacySessionFieldsEmitBothNames(t *testing.T) {
extra := buildXhttpExtra(map[string]any{
"sessionPlacement": "query",
"sessionKey": "sess",
})
for key, want := range map[string]string{
"sessionIDPlacement": "query",
"sessionIDKey": "sess",
"sessionPlacement": "query",
"sessionKey": "sess",
} {
if extra[key] != want {
t.Fatalf("extra[%s] = %#v, want %q; extra %#v", key, extra[key], want, extra)
}
}
}
func TestBuildXhttpExtra_LeavesDefaultClientSideFieldsOut(t *testing.T) {
extra := buildXhttpExtra(map[string]any{
"uplinkHTTPMethod": "",
@@ -775,6 +805,26 @@ func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
}
}
func TestApplyExternalProxyTLSToStream_FingerprintNotDuplicated(t *testing.T) {
stream := map[string]any{
"security": "tls",
"tlsSettings": map[string]any{},
}
ep := map[string]any{"dest": "proxy.example.com", "fingerprint": "chrome"}
applyExternalProxyTLSToStream(ep, stream, "tls")
ts, _ := stream["tlsSettings"].(map[string]any)
if ts["fingerprint"] != "chrome" {
t.Fatalf("tlsSettings.fingerprint = %v, want %q", ts["fingerprint"], "chrome")
}
if settings, ok := ts["settings"].(map[string]any); ok {
if got, dup := settings["fingerprint"]; dup {
t.Fatalf("fingerprint must not be duplicated into tlsSettings.settings, got %v", got)
}
}
}
func TestApplyExternalProxyTLSParams_SetsPinnedPeerCert(t *testing.T) {
params := map[string]string{"security": "tls"}
ep := map[string]any{
+2 -3
View File
@@ -8,6 +8,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"maps"
"math"
"net/url"
"regexp"
@@ -627,9 +628,7 @@ func applyTransport(stream map[string]any, p url.Values) {
if extra := p.Get("extra"); extra != "" {
var parsed map[string]any
if err := json.Unmarshal([]byte(extra), &parsed); err == nil {
for k, v := range parsed {
xh[k] = v
}
maps.Copy(xh, parsed)
}
}
for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs", "uplinkChunkSize"} {
+2
View File
@@ -78,6 +78,8 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
api.Use(middleware.ConfigEnvelopeMiddleware())
api.Use(middleware.CSRFMiddleware())
api.GET("/openapi.json", ServeOpenAPISpec)
// Inbounds API
inbounds := api.Group("/inbounds")
a.inboundController = NewInboundController(inbounds)
-166
View File
@@ -1,166 +0,0 @@
package controller
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
type routeDef struct {
Method string
Path string
}
// routePattern matches route registrations like g.GET("/path", handler) or api.GET("/path", handler)
var routePattern = regexp.MustCompile(`\b(g|api)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\("([^"]+)"`)
// docRoutePattern matches { method: 'X', path: 'Y' ... } entries in endpoints.ts.
var docRoutePattern = regexp.MustCompile(`method:\s*'([A-Z]+)'\s*,\s*path:\s*'([^']+)'`)
// buildDocSet parses frontend/src/pages/api-docs/endpoints.ts and returns the
// set of documented "METHOD PATH" keys. WS pseudo-routes and subscription
// placeholders (paths starting with /{...}) are skipped because they aren't
// registered on the main Gin engine.
func buildDocSet(t *testing.T) map[string]bool {
t.Helper()
controllerDir, err := filepath.Abs(".")
if err != nil {
t.Fatalf("failed to get current dir: %v", err)
}
endpointsPath := filepath.Join(controllerDir, "..", "..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts")
data, err := os.ReadFile(endpointsPath)
if err != nil {
t.Fatalf("failed to read endpoints.ts at %s: %v", endpointsPath, err)
}
docSet := make(map[string]bool)
for _, m := range docRoutePattern.FindAllStringSubmatch(string(data), -1) {
method, path := m[1], m[2]
if method == "WS" {
continue
}
if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "/{") {
continue
}
docSet[method+" "+path] = true
}
if len(docSet) == 0 {
t.Fatalf("no documented routes parsed from %s — regex or file format may have changed", endpointsPath)
}
return docSet
}
func TestAPIRoutesDocumented(t *testing.T) {
docSet := buildDocSet(t)
controllerDir, err := filepath.Abs(".")
if err != nil {
t.Fatalf("failed to get current dir: %v", err)
}
var allRoutes []routeDef
entries, err := os.ReadDir(controllerDir)
if err != nil {
t.Fatalf("failed to read controller dir: %v", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
data, err := os.ReadFile(filepath.Join(controllerDir, entry.Name()))
if err != nil {
t.Fatalf("failed to read %s: %v", entry.Name(), err)
}
src := string(data)
// Determine the base path for this file based on its initRouter patterns
basePath := ""
switch entry.Name() {
case "index.go":
basePath = ""
case "spa.go":
basePath = "/panel"
case "api.go":
basePath = "/panel/api"
case "inbound.go":
basePath = "/panel/api/inbounds"
case "client.go":
basePath = "/panel/api/clients"
case "group.go":
basePath = "/panel/api/clients"
case "server.go":
basePath = "/panel/api/server"
case "node.go":
basePath = "/panel/api/nodes"
case "host.go":
basePath = "/panel/api/hosts"
case "setting.go":
basePath = "/panel/api/setting"
case "xray_setting.go":
basePath = "/panel/api/xray"
case "websocket.go":
basePath = ""
}
// Find all route registrations
matches := routePattern.FindAllStringSubmatch(src, -1)
for _, m := range matches {
method := m[2]
path := strings.TrimSpace(m[3])
if basePath == "" {
allRoutes = append(allRoutes, routeDef{Method: method, Path: path})
} else {
fullPath := basePath + path
allRoutes = append(allRoutes, routeDef{Method: method, Path: fullPath})
}
}
}
// The WebSocket route /ws is registered in web/web.go (not a controller file)
allRoutes = append(allRoutes, routeDef{Method: "GET", Path: "/ws"})
missingFromDocs := 0
foundInDoc := 0
sourceSet := make(map[string]bool)
for _, r := range allRoutes {
key := r.Method + " " + r.Path
// Skip SPA page routes (these are UI pages, not API endpoints)
spaPages := map[string]bool{
"/": true, "/panel/": true, "/panel/inbounds": true,
"/panel/clients": true, "/panel/groups": true,
"/panel/nodes": true, "/panel/settings": true,
"/panel/xray": true, "/panel/outbound": true,
"/panel/routing": true, "/panel/api-docs": true,
}
if spaPages[r.Path] {
continue
}
// Skip /panel/csrf-token (documented under auth as /csrf-token)
if r.Path == "/panel/csrf-token" {
continue
}
// Skip Chrome DevTools route
if strings.Contains(r.Path, ".well-known") {
continue
}
sourceSet[key] = true
if docSet[key] {
foundInDoc++
} else {
missingFromDocs++
t.Errorf("Route not documented in endpoints.ts: %s %s", r.Method, r.Path)
}
}
t.Logf("Routes found in source: %d, documented: %d, matching: %d, missing: %d",
len(sourceSet), len(docSet), foundInDoc, missingFromDocs)
if missingFromDocs > 0 {
t.Errorf("Found %d undocumented route(s). Update endpoints.ts to match.", missingFromDocs)
}
}
+44 -11
View File
@@ -48,6 +48,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list)
g.GET("/list/paged", a.listPaged)
g.GET("/get/:email", a.get)
g.GET("/get/tgId/:tgId", a.getByTgId)
g.GET("/traffic/:email", a.getTrafficByEmail)
g.GET("/subLinks/:subId", a.getSubLinks)
g.GET("/links/:email", a.getClientLinks)
@@ -105,6 +106,32 @@ func (a *ClientController) listPaged(c *gin.Context) {
jsonObj(c, resp, nil)
}
func (a *ClientController) buildClientPayload(rec *model.ClientRecord) (gin.H, error) {
inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
if err != nil {
return nil, err
}
externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id)
if err != nil {
return nil, err
}
flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
if err != nil {
return nil, err
}
rec.Flow = flow
var usedTraffic int64
if t, tErr := a.inboundService.GetClientTrafficByEmail(rec.Email); tErr == nil && t != nil {
usedTraffic = t.Up + t.Down
}
return gin.H{
"client": rec,
"inboundIds": inboundIds,
"externalLinks": externalLinks,
"usedTraffic": usedTraffic,
}, nil
}
func (a *ClientController) get(c *gin.Context) {
email := c.Param("email")
rec, err := a.clientService.GetRecordByEmail(nil, email)
@@ -112,30 +139,36 @@ func (a *ClientController) get(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
payload, err := a.buildClientPayload(rec)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id)
jsonObj(c, payload, nil)
}
func (a *ClientController) getByTgId(c *gin.Context) {
tgIdStr := c.Param("tgId")
tgId, err := strconv.ParseInt(tgIdStr, 10, 64)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
records, err := a.clientService.GetRecordsByTgID(tgId)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
rec.Flow = flow
// Consumed bytes (up+down, including cross-node global overlay) so API
// consumers can pair usage with the client's totalGB quota (#4973).
// Best-effort: a traffic lookup failure must not break the client fetch.
var usedTraffic int64
if t, tErr := a.inboundService.GetClientTrafficByEmail(email); tErr == nil && t != nil {
usedTraffic = t.Up + t.Down
results := make([]gin.H, 0, len(records))
for _, rec := range records {
payload, err := a.buildClientPayload(rec)
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
results = append(results, payload)
}
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds, "externalLinks": externalLinks, "usedTraffic": usedTraffic}, nil)
jsonObj(c, results, nil)
}
func (a *ClientController) create(c *gin.Context) {
+1 -3
View File
@@ -163,7 +163,5 @@ func (a *IndexController) csrfToken(c *gin.Context) {
// getTwoFactorEnable retrieves the current status of two-factor authentication.
func (a *IndexController) getTwoFactorEnable(c *gin.Context) {
status, err := a.settingService.GetTwoFactorEnable()
if err == nil {
jsonObj(c, status, nil)
}
jsonObj(c, status, err)
}
@@ -9,7 +9,7 @@ import (
func TestLoginLimiterBoundsMemoryUnderUsernameFlood(t *testing.T) {
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
for i := 0; i < loginLimitMaxRecords+100; i++ {
for i := range loginLimitMaxRecords + 100 {
limiter.registerFailure("1.2.3.4", "user-"+strconv.Itoa(i))
}
@@ -28,7 +28,7 @@ func TestLoginLimiterEvictionSparesActiveBlocks(t *testing.T) {
limiter.now = func() time.Time { return now }
limiter.mu.Lock()
for i := 0; i < loginLimitMaxRecords-1; i++ {
for i := range loginLimitMaxRecords - 1 {
limiter.attempts["victim-"+strconv.Itoa(i)] = &loginLimitRecord{blockedUntil: now.Add(10 * time.Minute)}
}
limiter.attempts["filler"] = &loginLimitRecord{failures: []time.Time{now}}
+5
View File
@@ -65,6 +65,7 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
g.POST("/all", a.getAllSetting)
g.POST("/defaultSettings", a.getDefaultSettings)
g.POST("/factoryDefaults", a.getFactoryDefaults)
g.POST("/update", a.updateSetting)
g.POST("/validateRegex", a.validateRegex)
g.POST("/updateUser", a.updateUser)
@@ -112,6 +113,10 @@ func (a *SettingController) getDefaultSettings(c *gin.Context) {
jsonObj(c, result, nil)
}
func (a *SettingController) getFactoryDefaults(c *gin.Context) {
jsonObj(c, a.settingService.GetFactoryDefaults(), nil)
}
// updateSetting updates all settings with the provided data.
func (a *SettingController) updateSetting(c *gin.Context) {
form, ok := middleware.BindAndValidate[updateSettingForm](c)
+1 -1
View File
@@ -71,7 +71,7 @@ func isTrustedProxy(ip string) bool {
}
func trustedProxyCIDRs() (trusted string) {
trusted = "127.0.0.1/32,::1/128"
trusted = service.DefaultTrustedProxyCIDRs
defer func() {
_ = recover()
}()
+6 -5
View File
@@ -28,11 +28,12 @@ type AllSetting struct {
TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"`
PanelOutbound string `json:"panelOutbound" form:"panelOutbound"`
PageSize int `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
ExpireDiff int `json:"expireDiff" form:"expireDiff" validate:"gte=0"`
TrafficDiff int `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`
RemarkTemplate string `json:"remarkTemplate" form:"remarkTemplate"`
Datepicker string `json:"datepicker" form:"datepicker"`
PageSize int `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
ExpireDiff int `json:"expireDiff" form:"expireDiff" validate:"gte=0"`
TrafficDiff int `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`
RemarkTemplate string `json:"remarkTemplate" form:"remarkTemplate"`
SubShowIdentityOnAllLinks bool `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"`
Datepicker string `json:"datepicker" form:"datepicker"`
TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"`
TgBotToken string `json:"tgBotToken" form:"tgBotToken"`
@@ -0,0 +1,34 @@
package job
import "testing"
// A bind that succeeds with an unusable search returns (empty, nil); the only
// guard was `err != nil`, so that answer detached the entire inbound.
func TestAutoDeleteAllowed(t *testing.T) {
cases := []struct {
name string
previous int64
fetched int
want bool
}{
{"empty fetch on a fresh job is refused", 0, 0, false},
{"empty fetch after a healthy sync is refused", 500, 0, false},
{"full fetch on a fresh job is allowed", 0, 500, true},
{"steady fetch is allowed", 500, 500, true},
{"growth is allowed", 500, 900, true},
{"shrink above the retention floor is allowed", 500, 250, true},
{"shrink below the retention floor is refused", 500, 249, false},
{"collapse to a single user is refused", 500, 1, false},
{"single user with no history is allowed", 0, 1, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
j := NewLdapSyncJob()
j.lastFlagCount.Store(c.previous)
if got := j.autoDeleteSafeForFetch(c.fetched); got != c.want {
t.Fatalf("autoDeleteSafeForFetch(previous=%d, fetched=%d) = %v, want %v",
c.previous, c.fetched, got, c.want)
}
})
}
}
+33 -3
View File
@@ -2,6 +2,7 @@ package job
import (
"strings"
"sync/atomic"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -12,11 +13,16 @@ import (
var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
// Share of the previous successful fetch a new one must still return to be
// trusted: a sudden collapse means a broken directory far more often than churn.
const ldapAutoDeleteMinRetainPercent = 50
type LdapSyncJob struct {
settingService service.SettingService
inboundService service.InboundService
clientService service.ClientService
xrayService service.XrayService
lastFlagCount atomic.Int64
}
// --- Helper functions for mustGet ---
@@ -77,7 +83,7 @@ func (j *LdapSyncJob) Run() {
UserFilter: mustGetString(j.settingService.GetLdapUserFilter),
UserAttr: mustGetString(j.settingService.GetLdapUserAttr),
FlagField: mustGetStringOr(j.settingService.GetLdapFlagField, mustGetString(j.settingService.GetLdapVlessField)),
TruthyVals: splitCsv(mustGetString(j.settingService.GetLdapTruthyValues)),
TruthyVals: truthyValuesOrDefault(mustGetString(j.settingService.GetLdapTruthyValues)),
Invert: mustGetBool(j.settingService.GetLdapInvertFlag),
}
@@ -157,7 +163,7 @@ func (j *LdapSyncJob) Run() {
// --- Auto delete clients not in LDAP ---
autoDelete := mustGetBool(j.settingService.GetLdapAutoDelete)
if autoDelete {
if autoDelete && j.autoDeleteSafeForFetch(len(flags)) {
ldapEmailSet := map[string]struct{}{}
for e := range flags {
ldapEmailSet[e] = struct{}{}
@@ -166,11 +172,35 @@ func (j *LdapSyncJob) Run() {
j.deleteClientsNotInLDAP(tag, ldapEmailSet)
}
}
j.lastFlagCount.Store(int64(len(flags)))
}
// FetchVlessFlags returns (empty, nil) when the bind succeeds but the search
// yields nothing — a renamed OU, a lost read grant — which is not "all gone".
func (j *LdapSyncJob) autoDeleteSafeForFetch(fetched int) bool {
if fetched == 0 {
logger.Warning("LDAP auto-delete skipped: directory returned no usable users")
return false
}
previous := j.lastFlagCount.Load()
if previous > 0 && int64(fetched)*100 < previous*ldapAutoDeleteMinRetainPercent {
logger.Warningf("LDAP auto-delete skipped: fetched %d users, previous successful sync saw %d (below %d%% retention)",
fetched, previous, ldapAutoDeleteMinRetainPercent)
return false
}
return true
}
func truthyValuesOrDefault(s string) []string {
if vals := splitCsv(s); len(vals) > 0 {
return vals
}
return DefaultTruthyValues
}
func splitCsv(s string) []string {
if s == "" {
return DefaultTruthyValues
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
+1 -1
View File
@@ -63,7 +63,7 @@ func (j *NodeHeartbeatJob) Run() {
if !websocket.HasClients() {
return
}
updated, err := j.nodeService.GetNodeTree()
updated, err := j.nodeService.GetNodeTreeView()
if err != nil {
logger.Warning("node heartbeat: load nodes for broadcast failed:", err)
return
+24 -3
View File
@@ -1,6 +1,8 @@
package job
import (
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
@@ -13,15 +15,25 @@ type PeriodicTrafficResetJob struct {
inboundService service.InboundService
clientService service.ClientService
period Period
location *time.Location
}
// NewPeriodicTrafficResetJob creates a new periodic traffic reset job for the specified period.
func NewPeriodicTrafficResetJob(period Period) *PeriodicTrafficResetJob {
func NewPeriodicTrafficResetJob(period Period, location *time.Location) *PeriodicTrafficResetJob {
return &PeriodicTrafficResetJob{
period: period,
period: period,
location: location,
}
}
func monthlyResetDue(resetDay int, now time.Time) bool {
if resetDay < 1 {
resetDay = 1
}
lastDay := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Day()
return now.Day() == min(resetDay, lastDay)
}
// Run resets traffic statistics for all inbounds that match the configured reset period.
func (j *PeriodicTrafficResetJob) Run() {
inbounds, err := j.inboundService.GetInboundsByTrafficReset(string(j.period))
@@ -30,13 +42,22 @@ func (j *PeriodicTrafficResetJob) Run() {
return
}
if j.period == "monthly" {
now := time.Now().In(j.location)
due := inbounds[:0]
for _, inbound := range inbounds {
if monthlyResetDue(inbound.TrafficResetDay, now) {
due = append(due, inbound)
}
}
inbounds = due
}
if len(inbounds) == 0 {
return
}
logger.Infof("Running periodic traffic reset job for period: %s (%d matching inbounds)", j.period, len(inbounds))
resetCount := 0
for _, inbound := range inbounds {
resetInboundErr := j.inboundService.ResetInboundTraffic(inbound.Id)
if resetInboundErr != nil {
@@ -0,0 +1,31 @@
package job
import (
"testing"
"time"
)
func TestMonthlyResetDue(t *testing.T) {
cases := []struct {
name string
resetDay int
now time.Time
want bool
}{
{"legacy default on first", 0, time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC), true},
{"configured day", 15, time.Date(2026, time.July, 15, 0, 0, 0, 0, time.UTC), true},
{"before configured day", 15, time.Date(2026, time.July, 14, 0, 0, 0, 0, time.UTC), false},
{"month end", 31, time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC), true},
{"short month fallback", 31, time.Date(2026, time.February, 28, 0, 0, 0, 0, time.UTC), true},
{"leap year fallback", 31, time.Date(2028, time.February, 29, 0, 0, 0, 0, time.UTC), true},
{"not before short month end", 31, time.Date(2028, time.February, 28, 0, 0, 0, 0, time.UTC), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := monthlyResetDue(tc.resetDay, tc.now); got != tc.want {
t.Fatalf("monthlyResetDue(%d, %s) = %v, want %v", tc.resetDay, tc.now.Format(time.DateOnly), got, tc.want)
}
})
}
}
+27
View File
@@ -0,0 +1,27 @@
package job
import (
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// ReapSyncOrphansJob hard-deletes the clients the node-snapshot sweep only
// soft-orphaned, once enough clean merges have agreed they are really gone.
type ReapSyncOrphansJob struct {
clientService service.ClientService
}
func NewReapSyncOrphansJob() *ReapSyncOrphansJob {
return new(ReapSyncOrphansJob)
}
func (j *ReapSyncOrphansJob) Run() {
reaped, err := j.clientService.ReapSyncOrphans()
if err != nil {
logger.Warning("reap sync orphans failed:", err)
return
}
if reaped > 0 {
logger.Infof("reap sync orphans: removed %d client(s)", reaped)
}
}
@@ -0,0 +1,91 @@
package job
import (
"errors"
"io"
"net"
"testing"
"time"
"github.com/valyala/fasthttp"
)
func stallingListener(t *testing.T) (addr string, release func()) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
done := make(chan struct{})
go func() {
defer close(done)
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
<-done
_ = c.Close()
}(conn)
go func(c net.Conn) { _, _ = io.Copy(io.Discard, c) }(conn)
}
}()
return ln.Addr().String(), func() {
_ = ln.Close()
<-done
}
}
func TestExternalInformClient_StalledReceiverTimesOut(t *testing.T) {
addr, release := stallingListener(t)
defer release()
request := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(request)
request.Header.SetMethod("POST")
request.Header.SetContentType("application/json; charset=UTF-8")
request.SetBody([]byte(`{"clientTraffics":[],"inboundTraffics":[]}`))
request.SetRequestURI("http://" + addr + "/inform")
request.Header.SetConnectionClose()
response := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(response)
start := time.Now()
err := externalInformClient.DoTimeout(request, response, externalInformTimeout)
elapsed := time.Since(start)
if err == nil {
t.Fatal("a receiver that never answers must produce an error, not a completed request")
}
if !errors.Is(err, fasthttp.ErrTimeout) {
t.Errorf("want a timeout error, got %v", err)
}
if elapsed > externalInformTimeout+2*time.Second {
t.Errorf("call took %v, must be bounded by %v", elapsed, externalInformTimeout)
}
}
func TestExternalInformClient_HasDeadlines(t *testing.T) {
if externalInformClient.ReadTimeout <= 0 || externalInformClient.WriteTimeout <= 0 {
t.Fatal("the traffic-notify client must carry read and write deadlines")
}
if externalInformTimeout >= 5*time.Second {
t.Errorf("the call budget (%v) must stay under the 5s traffic poll cadence", externalInformTimeout)
}
}
func TestInformSkippedWhenNothingToReport(t *testing.T) {
j := &XrayTrafficJob{}
done := make(chan struct{})
go func() {
defer close(done)
j.informTrafficToExternalAPI(nil, nil)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("an empty payload must return before touching settings or the network")
}
}
+42 -10
View File
@@ -2,6 +2,7 @@ package job
import (
"encoding/json"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
@@ -28,6 +29,40 @@ type XrayTrafficJob struct {
// refetch for the rest.
const clientStatsSnapshotMaxClients = 5000
// splitMovedClientTraffics keeps the rows that actually moved bytes this poll,
// alongside the active-email list and set derived from the same pass.
//
// Xray reports a row for every known email whether or not it transferred
// anything, so on a large panel nearly every delta is zero. The database writes
// and the external-API inform consume the full slice before this point; the
// WebSocket frame only feeds the dashboard's live speed column, where an absent
// row and a zero row render identically. Broadcasting just the movers keeps that
// frame from growing with the client count — at 5k clients it was carrying about
// a megabyte of zeros every five seconds.
func splitMovedClientTraffics(clientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, []string, map[string]bool) {
moved := make([]*xray.ClientTraffic, 0, len(clientTraffics))
emails := make([]string, 0, len(clientTraffics))
active := make(map[string]bool, len(clientTraffics))
for _, ct := range clientTraffics {
if ct == nil || ct.Up+ct.Down <= 0 {
continue
}
moved = append(moved, ct)
emails = append(emails, ct.Email)
active[ct.Email] = true
}
return moved, emails, active
}
const externalInformTimeout = 3 * time.Second
var externalInformClient = &fasthttp.Client{
ReadTimeout: externalInformTimeout,
WriteTimeout: externalInformTimeout,
MaxIdleConnDuration: time.Minute,
MaxConnsPerHost: 4,
}
// NewXrayTrafficJob creates a new traffic collection job instance.
func NewXrayTrafficJob() *XrayTrafficJob {
return new(XrayTrafficJob)
@@ -78,14 +113,7 @@ func (j *XrayTrafficJob) Run() {
// than the shared last_online column, which remote-node syncs also bump
// and would otherwise make a client active only on a remote node appear
// online on local inbounds.
activeEmails := make([]string, 0, len(clientTraffics))
deltaActive := make(map[string]bool, len(clientTraffics))
for _, ct := range clientTraffics {
if ct != nil && ct.Up+ct.Down > 0 {
activeEmails = append(activeEmails, ct.Email)
deltaActive[ct.Email] = true
}
}
movedTraffics, activeEmails, deltaActive := splitMovedClientTraffics(clientTraffics)
// When the core supports the online-stats API, union in connection-based
// onlines. Neither signal alone covers everything: an idle-but-connected
// client moves no bytes between polls (the delta heuristic's blind spot),
@@ -169,7 +197,7 @@ func (j *XrayTrafficJob) Run() {
}
websocket.BroadcastTraffic(map[string]any{
"traffics": traffics,
"clientTraffics": clientTraffics,
"clientTraffics": movedTraffics,
"onlineClients": onlineClients,
"onlineByGuid": j.inboundService.GetOnlineClientsByGuid(),
"activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
@@ -197,6 +225,9 @@ func (j *XrayTrafficJob) Run() {
}
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
if len(inboundTraffics) == 0 && len(clientTraffics) == 0 {
return
}
informURL, err := j.settingService.GetExternalTrafficInformURI()
if err != nil {
logger.Warning("get ExternalTrafficInformURI failed:", err)
@@ -218,9 +249,10 @@ func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traf
request.Header.SetContentType("application/json; charset=UTF-8")
request.SetBody(requestBody)
request.SetRequestURI(informURL)
request.Header.SetConnectionClose()
response := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(response)
if err := fasthttp.Do(request, response); err != nil {
if err := externalInformClient.DoTimeout(request, response, externalInformTimeout); err != nil {
logger.Warning("POST ExternalTrafficInformURI failed:", err)
}
}
@@ -0,0 +1,59 @@
package job
import (
"slices"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func TestSplitMovedClientTraffics(t *testing.T) {
rows := []*xray.ClientTraffic{
{Email: "idle@x", Up: 0, Down: 0},
{Email: "up@x", Up: 1024, Down: 0},
{Email: "down@x", Up: 0, Down: 2048},
nil,
{Email: "both@x", Up: 512, Down: 512},
{Email: "alsoidle@x", Up: 0, Down: 0},
}
moved, emails, active := splitMovedClientTraffics(rows)
t.Run("only the rows that moved bytes are broadcast", func(t *testing.T) {
got := make([]string, 0, len(moved))
for _, ct := range moved {
got = append(got, ct.Email)
}
want := []string{"up@x", "down@x", "both@x"}
if !slices.Equal(got, want) {
t.Fatalf("moved = %v, want %v", got, want)
}
})
t.Run("the active list and set agree with the broadcast rows", func(t *testing.T) {
want := []string{"up@x", "down@x", "both@x"}
if !slices.Equal(emails, want) {
t.Fatalf("activeEmails = %v, want %v", emails, want)
}
if len(active) != len(want) {
t.Fatalf("deltaActive has %d entries, want %d", len(active), len(want))
}
for _, e := range want {
if !active[e] {
t.Fatalf("deltaActive missing %q", e)
}
}
if active["idle@x"] {
t.Fatal("an idle client must not count as active")
}
})
t.Run("an all-idle poll broadcasts nothing", func(t *testing.T) {
moved, emails, active := splitMovedClientTraffics([]*xray.ClientTraffic{
{Email: "a@x"}, {Email: "b@x"},
})
if len(moved) != 0 || len(emails) != 0 || len(active) != 0 {
t.Fatalf("expected an empty split, got %d/%d/%d", len(moved), len(emails), len(active))
}
})
}
+139
View File
@@ -0,0 +1,139 @@
package web
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
"time"
"github.com/robfig/cron/v3"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
)
/*
frontend/src/pages/api-docs/endpoints.ts is a hand-maintained registry: an
API route omitted there silently vanishes from the generated OpenAPI docs,
and an entry for a removed route documents an endpoint that 404s. This test
constructs the real router and diffs it against the registry both ways.
Scope: everything under /panel/api/ plus the session-auth surface the
registry also documents (/login, /logout, /csrf-token, /getTwoFactorEnable,
/ws). SPA page routes are UI, not API, and stay out; registry paths that
start with "/{" describe the standalone subscription server, which this
engine does not serve.
*/
var contractExtraRoutes = map[string]bool{
"POST /login": true,
"POST /logout": true,
"GET /csrf-token": true,
"POST /getTwoFactorEnable": true,
"GET /ws": true,
}
func inContractScope(method, path string) bool {
return strings.HasPrefix(path, "/panel/api/") || contractExtraRoutes[method+" "+path]
}
func registeredContractRoutes(t *testing.T) map[string]bool {
t.Helper()
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("init db: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
previous := global.GetWebServer()
s := NewServer()
s.cron = cron.New(cron.WithLocation(time.Local), cron.WithSeconds())
global.SetWebServer(s)
t.Cleanup(func() {
s.cancel()
global.SetWebServer(previous)
})
engine, err := s.initRouter()
if err != nil {
t.Fatalf("init router: %v", err)
}
routes := make(map[string]bool)
for _, r := range engine.Routes() {
routes[r.Method+" "+r.Path] = true
}
if len(routes) == 0 {
t.Fatal("no routes registered; router construction is broken")
}
return routes
}
func documentedContractRoutes(t *testing.T) map[string]bool {
t.Helper()
source, err := os.ReadFile(filepath.Join("..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts"))
if err != nil {
t.Fatalf("read endpoints.ts: %v", err)
}
text := string(source)
methodRe := regexp.MustCompile(`method:\s*'(GET|POST|PUT|DELETE|PATCH|WS)'`)
pathRe := regexp.MustCompile(`path:\s*'([^']+)'`)
methods := methodRe.FindAllStringSubmatchIndex(text, -1)
if declared := strings.Count(text, "method: '"); len(methods) != declared {
t.Fatalf("parsed %d method fields but endpoints.ts declares %d — the parser regex no longer matches the file shape", len(methods), declared)
}
docs := make(map[string]bool)
for i, m := range methods {
segmentEnd := len(text)
if i+1 < len(methods) {
segmentEnd = methods[i+1][0]
}
pathMatch := pathRe.FindStringSubmatch(text[m[1]:segmentEnd])
if pathMatch == nil {
t.Fatalf("entry %d in endpoints.ts has a method but no path before the next entry — the parser cannot pair it", i)
}
method := text[m[2]:m[3]]
if strings.HasPrefix(pathMatch[1], "/{") || !strings.HasPrefix(pathMatch[1], "/") {
continue
}
docs[method+" "+pathMatch[1]] = true
}
if len(docs) == 0 {
t.Fatal("no entries parsed from endpoints.ts; the parser regex is broken")
}
return docs
}
func TestRouteRegistryContract(t *testing.T) {
registered := registeredContractRoutes(t)
documented := documentedContractRoutes(t)
t.Run("every API route is documented", func(t *testing.T) {
var missing []string
for route := range registered {
fields := strings.Fields(route)
if inContractScope(fields[0], fields[1]) && !documented[route] {
missing = append(missing, route)
}
}
sort.Strings(missing)
for _, route := range missing {
t.Error(fmt.Errorf("route %s is registered but absent from endpoints.ts — add an entry or it vanishes from the API docs", route))
}
})
t.Run("every documented route is registered", func(t *testing.T) {
var stale []string
for route := range documented {
if !registered[route] {
stale = append(stale, route)
}
}
sort.Strings(stale)
for _, route := range stale {
t.Error(fmt.Errorf("endpoints.ts documents %s but the server does not register it — remove or fix the entry", route))
}
})
}
+3
View File
@@ -770,6 +770,9 @@ func wireInbound(ib *model.Inbound, remoteNodeID int) url.Values {
if ib.TrafficReset != "" {
v.Set("trafficReset", ib.TrafficReset)
}
if ib.TrafficResetDay > 0 {
v.Set("trafficResetDay", strconv.Itoa(ib.TrafficResetDay))
}
return v
}
+6 -3
View File
@@ -252,9 +252,12 @@ func TestIsNonEmptySlice(t *testing.T) {
}
func TestWireInboundTrafficReset(t *testing.T) {
with := wireInbound(&model.Inbound{TrafficReset: "daily"}, 0)
if got := with.Get("trafficReset"); got != "daily" {
t.Fatalf("trafficReset = %q, want daily", got)
with := wireInbound(&model.Inbound{TrafficReset: "monthly", TrafficResetDay: 15}, 0)
if got := with.Get("trafficReset"); got != "monthly" {
t.Fatalf("trafficReset = %q, want monthly", got)
}
if got := with.Get("trafficResetDay"); got != "15" {
t.Fatalf("trafficResetDay = %q, want 15", got)
}
// Empty TrafficReset must be omitted entirely, not sent as an empty field.
without := wireInbound(&model.Inbound{}, 0)
+2 -2
View File
@@ -69,13 +69,13 @@ func defaultAmneziaWGClients(settingsJSON string, existing, clients []model.Clie
c.PublicKey = pub
}
if len(c.AllowedIPs) == 0 {
addr, err := allocateWireguardAddress(used, v4Base)
addr, err := allocateWireguardAddress(used, v4Base, false)
if err != nil {
return err
}
allowed := []string{addr}
if v6Base != "" {
addr6, err := allocateWireguardAddress(used, v6Base)
addr6, err := allocateWireguardAddress(used, v6Base, false)
if err != nil {
return err
}
+7 -4
View File
@@ -429,8 +429,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
}
}
now := time.Now().Unix() * 1000
cond := depletedCond(db)
cond, condArgs := depletedCond(db)
candidateEmails := make([]string, 0, len(plan))
for email, entry := range plan {
if entry.applyExpiry || entry.applyTotal {
@@ -441,7 +440,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
for _, batch := range chunkStrings(candidateEmails, sqlInChunk) {
var rows []string
if err := db.Model(xray.ClientTraffic{}).
Where(cond+" AND enable = ? AND email IN ?", now, false, batch).
Where(cond+" AND enable = ? AND email IN ?", append(append([]any{}, condArgs...), false, batch)...).
Pluck("email", &rows).Error; err != nil {
return result, needRestart, err
}
@@ -503,7 +502,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
for _, batch := range chunkStrings(wasList, sqlInChunk) {
var rows []string
if err := db.Model(xray.ClientTraffic{}).
Where(cond+" AND email IN ?", now, batch).
Where(cond+" AND email IN ?", append(append([]any{}, condArgs...), batch)...).
Pluck("email", &rows).Error; err != nil {
return result, needRestart, err
}
@@ -836,13 +835,16 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
successEmails := make([]string, 0, len(recordsByEmail))
successIds := make([]int, 0, len(recordsByEmail))
failedEmails := make([]string, 0, len(recordsByEmail))
for email, rec := range recordsByEmail {
if _, skipped := skippedReasons[email]; skipped {
failedEmails = append(failedEmails, email)
continue
}
successEmails = append(successEmails, email)
successIds = append(successIds, rec.Id)
}
withdrawClientTombstones(failedEmails...)
if len(successIds) > 0 {
// Serialize the row cleanup against the traffic poll to avoid the
@@ -876,6 +878,7 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
}
return nil
}); err != nil {
withdrawClientTombstones(successEmails...)
return result, needRestart, err
}
}
@@ -0,0 +1,84 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestCreateAcrossManyInboundsUsesOneEmailSnapshot(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
const uuid = "bbbbbbbb-1111-2222-3333-555555555555"
ids := make([]int, 0, 6)
for i := range 6 {
ib := mkInbound(t, 23001+i, model.VLESS, `{"clients":[]}`)
ids = append(ids, ib.Id)
}
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
Client: model.Client{Email: "fan@x", ID: uuid, SubID: "sub-fan", Enable: true},
InboundIds: ids,
}); err != nil {
t.Fatalf("Create across %d inbounds: %v", len(ids), err)
}
if n := countClientRecords(t); n != 1 {
t.Fatalf("client records = %d, want 1", n)
}
rec := lookupClientRecord(t, "fan@x")
if rec.UUID != uuid || rec.SubID != "sub-fan" {
t.Fatalf("record = {uuid:%q sub:%q}, want {%q sub-fan}", rec.UUID, rec.SubID, uuid)
}
for _, id := range ids {
if !settingsHoldUUID(t, inboundSvc, id, uuid) {
t.Fatalf("inbound %d settings missing the client", id)
}
}
linked, err := svc.GetInboundIdsForRecord(rec.Id)
if err != nil {
t.Fatalf("GetInboundIdsForRecord: %v", err)
}
if len(linked) != len(ids) {
t.Fatalf("linked inbounds = %d, want %d", len(linked), len(ids))
}
}
func TestAttachAcrossManyInboundsUsesOneEmailSnapshot(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
first := mkInbound(t, 23101, model.VLESS, `{"clients":[]}`)
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
Client: model.Client{Email: "att@x", ID: "cccccccc-1111-2222-3333-666666666666", SubID: "sub-att", Enable: true},
InboundIds: []int{first.Id},
}); err != nil {
t.Fatalf("seed Create: %v", err)
}
rec := lookupClientRecord(t, "att@x")
ids := []int{first.Id}
for i := range 4 {
ib := mkInbound(t, 23102+i, model.VLESS, `{"clients":[]}`)
ids = append(ids, ib.Id)
}
if _, err := svc.Attach(inboundSvc, rec.Id, ids); err != nil {
t.Fatalf("Attach across %d inbounds: %v", len(ids), err)
}
if n := countClientRecords(t); n != 1 {
t.Fatalf("client records after attach = %d, want 1", n)
}
linked, err := svc.GetInboundIdsForRecord(rec.Id)
if err != nil {
t.Fatalf("GetInboundIdsForRecord: %v", err)
}
if len(linked) != len(ids) {
t.Fatalf("linked inbounds = %d, want %d", len(linked), len(ids))
}
}
+21 -6
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"strings"
"time"
"unicode"
"github.com/google/uuid"
@@ -21,7 +22,7 @@ import (
func hasForbiddenClientChar(s string) bool {
for _, r := range s {
if r == '/' || r == '\\' || r == ' ' || r < 0x20 || r == 0x7f {
if r == '/' || r == '\\' || r < 0x20 || r == 0x7f || unicode.IsSpace(r) {
return true
}
}
@@ -110,6 +111,11 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
}
}
emailSubIDs, sidErr := inboundSvc.getAllEmailSubIDs()
if sidErr != nil {
return false, sidErr
}
needRestart := false
for _, ibId := range payload.InboundIds {
inbound, getErr := inboundSvc.GetInbound(ibId)
@@ -123,10 +129,10 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
if mErr != nil {
return needRestart, mErr
}
nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{
nr, addErr := s.addInboundClient(inboundSvc, &model.Inbound{
Id: ibId,
Settings: string(settingsPayload),
})
}, emailSubIDs)
if addErr != nil {
return needRestart, addErr
}
@@ -375,7 +381,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
}
}
if updated.SubID != "" {
if updated.SubID != existing.SubID {
var subCollision int64
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("sub_id = ? AND id <> ?", updated.SubID, id).
@@ -518,6 +524,7 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
inboundIds, err := s.GetInboundIdsForRecord(id)
if err != nil {
withdrawClientTombstones(existing.Email)
return false, err
}
@@ -555,7 +562,9 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
}
// A failed inbound still holds the client in its settings JSON: keep the
// record so the next delete retries exactly the leftovers, and report it.
// The tombstone lifts with it, or the next node merge finishes the deletion.
if len(delErrs) > 0 {
withdrawClientTombstones(existing.Email)
return needRestart, errors.Join(delErrs...)
}
@@ -588,6 +597,7 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
}
return tx.Delete(&model.ClientRecord{}, id).Error
}); err != nil {
withdrawClientTombstones(existing.Email)
return needRestart, err
}
return needRestart, nil
@@ -615,6 +625,11 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
clientWire.Flow = flow
clientWire.UpdatedAt = time.Now().UnixMilli()
emailSubIDs, sidErr := inboundSvc.getAllEmailSubIDs()
if sidErr != nil {
return false, sidErr
}
needRestart := false
for _, ibId := range inboundIds {
if _, attached := have[ibId]; attached {
@@ -632,10 +647,10 @@ func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []
if mErr != nil {
return needRestart, mErr
}
nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{
nr, addErr := s.addInboundClient(inboundSvc, &model.Inbound{
Id: ibId,
Settings: string(settingsPayload),
})
}, emailSubIDs)
if addErr != nil {
return needRestart, addErr
}
@@ -0,0 +1,44 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// Delete tombstones up front and keeps the record when an inbound fails. A
// surviving tombstone lets the next node merge finish the refused deletion.
func TestFailedDeleteWithdrawsTombstone(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
db := database.GetDB()
const email = "retry@x"
broken := mkInbound(t, 30401, model.VLESS, `{"clients": [ THIS IS NOT JSON`)
rec := &model.ClientRecord{Email: email, Enable: true, UUID: "33333333-3333-3333-3333-333333333333"}
if err := db.Create(rec).Error; err != nil {
t.Fatalf("create client record: %v", err)
}
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: broken.Id}).Error; err != nil {
t.Fatalf("attach client: %v", err)
}
t.Cleanup(func() { withdrawClientTombstones(email) })
if _, err := svc.Delete(inboundSvc, rec.Id, false); err == nil {
t.Fatal("setup: delete was expected to fail on the unparseable inbound settings")
}
var surviving int64
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&surviving).Error; err != nil {
t.Fatalf("count clients: %v", err)
}
if surviving != 1 {
t.Fatalf("failed delete must keep the record for a retry, got %d rows", surviving)
}
if isClientEmailTombstoned(email) {
t.Fatal("delete kept the record but left a live tombstone: the next node sync would finish the deletion it refused")
}
}
+2 -2
View File
@@ -495,7 +495,7 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
}
cipher := ""
if oldInbound.Protocol == "shadowsocks" {
cipher = oldSettings["method"].(string)
cipher, _ = oldSettings["method"].(string)
}
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
"email": client.Email,
@@ -899,7 +899,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
if clients[0].Enable {
cipher := ""
if oldInbound.Protocol == "shadowsocks" {
cipher = oldSettings["method"].(string)
cipher, _ = oldSettings["method"].(string)
}
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
"email": clients[0].Email,
+3
View File
@@ -115,6 +115,9 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
}
incoming := clients[i].ToRecord()
// ToRecord copies the raw email; store the trimmed key this function
// looks up by, or a padded email is inserted and never found again.
incoming.Email = email
row, ok := existing[email]
if !ok {
if _, dup := pending[email]; !dup {
+13
View File
@@ -105,6 +105,19 @@ func tombstoneClientEmail(email string) {
}
}
func withdrawClientTombstones(emails ...string) {
if len(emails) == 0 {
return
}
recentlyDeletedMu.Lock()
defer recentlyDeletedMu.Unlock()
for _, email := range emails {
if email != "" {
delete(recentlyDeleted, email)
}
}
}
func tombstoneClientEmails(emails []string) {
if len(emails) == 0 {
return
+10
View File
@@ -2,6 +2,7 @@ package service
import (
"encoding/json"
"errors"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -103,6 +104,15 @@ func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int,
return ids, nil
}
func (s *ClientService) GetRecordsByTgID(tgId int64) ([]*model.ClientRecord, error) {
if tgId <= 0 {
return nil, errors.New("tg_id must be a positive integer")
}
var rows []*model.ClientRecord
err := database.GetDB().Where("tg_id = ?", tgId).Find(&rows).Error
return rows, err
}
func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) {
row := &model.ClientRecord{}
if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil {
@@ -0,0 +1,80 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestGetRecordsByTgID(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
db := database.GetDB()
records := []model.ClientRecord{
{Email: "alice@x", TgID: 100, SubID: "sa"},
{Email: "bob@x", TgID: 100, SubID: "sb"},
{Email: "carol@x", TgID: 200, SubID: "sc"},
{Email: "dave@x", TgID: 0, SubID: "sd"},
}
for _, r := range records {
if err := db.Create(&r).Error; err != nil {
t.Fatalf("create record %q: %v", r.Email, err)
}
}
t.Run("multiple clients share tgId", func(t *testing.T) {
got, err := svc.GetRecordsByTgID(100)
if err != nil {
t.Fatalf("GetRecordsByTgID(100): %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 records, got %d", len(got))
}
emails := make(map[string]bool)
for _, r := range got {
emails[r.Email] = true
}
if !emails["alice@x"] || !emails["bob@x"] {
t.Fatalf("expected alice@x and bob@x, got %v", got)
}
})
t.Run("single client by tgId", func(t *testing.T) {
got, err := svc.GetRecordsByTgID(200)
if err != nil {
t.Fatalf("GetRecordsByTgID(200): %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 record, got %d", len(got))
}
if got[0].Email != "carol@x" {
t.Fatalf("expected carol@x, got %s", got[0].Email)
}
})
t.Run("tgId zero rejected as sentinel", func(t *testing.T) {
_, err := svc.GetRecordsByTgID(0)
if err == nil {
t.Fatal("expected error for tgId=0")
}
})
t.Run("negative tgId rejected", func(t *testing.T) {
_, err := svc.GetRecordsByTgID(-5)
if err == nil {
t.Fatal("expected error for tgId=-5")
}
})
t.Run("nonexistent tgId returns empty", func(t *testing.T) {
got, err := svc.GetRecordsByTgID(999)
if err != nil {
t.Fatalf("GetRecordsByTgID(999): %v", err)
}
if len(got) != 0 {
t.Fatalf("expected 0 records, got %d", len(got))
}
})
}
+486 -399
View File
@@ -1,13 +1,16 @@
package service
import (
"slices"
"sort"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
// ClientSlim is the row-shape used by the clients page. It drops fields the
@@ -74,33 +77,262 @@ type ClientPageResponse struct {
// ClientsSummary collects per-bucket counts plus the matching email lists so
// the clients page can render the dashboard stat cards and their hover
// popovers without shipping the full client array.
// popovers without shipping the full client array. The counters are exact;
// the lists stop at clientSummaryEmailCap entries and only back the popovers.
type ClientsSummary struct {
Total int `json:"total"`
Active int `json:"active"`
Online []string `json:"online"`
Depleted []string `json:"depleted"`
Expiring []string `json:"expiring"`
Deactive []string `json:"deactive"`
Total int `json:"total"`
Active int `json:"active"`
OnlineCount int `json:"onlineCount"`
DepletedCount int `json:"depletedCount"`
ExpiringCount int `json:"expiringCount"`
DeactiveCount int `json:"deactiveCount"`
Online []string `json:"online"`
Depleted []string `json:"depleted"`
Expiring []string `json:"expiring"`
Deactive []string `json:"deactive"`
}
const (
clientPageDefaultSize = 25
clientPageMaxSize = 200
// clientSummaryEmailCap bounds each bucket's email list. Shipping every
// matching email made the response — and the Zod validation the page runs
// over it — grow with the client count on a request that repeats every 5s,
// and left the hover popover rendering thousands of rows.
clientSummaryEmailCap = 200
// sqlNeverSentinel sorts "never expires" / "unlimited quota" clients last,
// matching the sentinel the in-memory comparator used.
sqlNeverSentinel = "4611686018427387903"
// sqlClientEnabled tolerates a NULL enable column, which GORM scans as
// false: without the COALESCE such a row would match neither the enabled
// nor the disabled branch of any predicate.
sqlClientEnabled = "COALESCE(c.enable, FALSE)"
)
// ListPaged loads every client (with traffic + attachments) into memory,
// applies the requested filter / search / protocol predicates, sorts, and
// returns the requested page along with total and filtered counts. The DB
// query itself is unchanged from List(); the win is that the response
// only carries 25-ish slim rows over the wire instead of all 2000 full
// records, which on real panels was the dominant cost.
func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) {
all, err := s.List()
if err != nil {
return nil, err
const clientSearchCond = `(LOWER(c.email) LIKE ? ESCAPE '\'
OR LOWER(COALESCE(c.sub_id, '')) LIKE ? ESCAPE '\'
OR LOWER(COALESCE(c.comment, '')) LIKE ? ESCAPE '\'
OR LOWER(COALESCE(c.uuid, '')) LIKE ? ESCAPE '\'
OR LOWER(COALESCE(c.password, '')) LIKE ? ESCAPE '\'
OR LOWER(COALESCE(c.auth, '')) LIKE ? ESCAPE '\'
OR (COALESCE(c.tg_id, 0) <> 0 AND CAST(c.tg_id AS TEXT) LIKE ? ESCAPE '\'))`
// clientQuery builds the statements behind the clients page: a clients row
// joined to its traffic counters, plus the expressions every bucket predicate
// shares. Filtering, sorting, paging and the summary all run in the database.
// Loading every client (with attachments and traffic) into Go and doing it in
// memory cost ~200ms per request at 20k clients on a page that polls every
// 5 seconds, which is what made the table feel stuck on large panels.
type clientQuery struct {
db *gorm.DB
joins []clientQueryJoin
usedExpr string
nowMs int64
expireDiffMs int64
trafficDiffBytes int64
}
type clientQueryJoin struct {
sql string
args []any
}
func newClientQuery(db *gorm.DB, nowMs, expireDiffMs, trafficDiffBytes int64) clientQuery {
q := clientQuery{
db: db,
nowMs: nowMs,
expireDiffMs: expireDiffMs,
trafficDiffBytes: trafficDiffBytes,
joins: []clientQueryJoin{{sql: "LEFT JOIN client_traffics ct ON ct.email = c.email"}},
usedExpr: "(COALESCE(ct.up, 0) + COALESCE(ct.down, 0))",
}
total := len(all)
freshSince := globalTrafficFreshSince()
var probe int64
err := db.Model(&model.ClientGlobalTraffic{}).
Where("updated_at >= ?", freshSince).
Limit(1).Count(&probe).Error
if err != nil || probe == 0 {
return q
}
// A master still pushes cross-panel usage here, so the predicates have to
// see the same raised counters overlayGlobalTraffic applies on read.
q.joins = append(q.joins, clientQueryJoin{
sql: "LEFT JOIN (SELECT email, MAX(up) AS up, MAX(down) AS down FROM client_global_traffics" +
" WHERE updated_at >= ? GROUP BY email) g ON g.email = c.email",
args: []any{freshSince},
})
q.usedExpr = "(CASE WHEN COALESCE(g.up, 0) > COALESCE(ct.up, 0) THEN COALESCE(g.up, 0) ELSE COALESCE(ct.up, 0) END" +
" + CASE WHEN COALESCE(g.down, 0) > COALESCE(ct.down, 0) THEN COALESCE(g.down, 0) ELSE COALESCE(ct.down, 0) END)"
return q
}
func (q clientQuery) from() *gorm.DB {
tx := q.db.Table("clients AS c")
for _, j := range q.joins {
tx = tx.Joins(j.sql, j.args...)
}
return tx
}
func (q clientQuery) depletedExpr() string {
return "((c.total_gb > 0 AND " + q.usedExpr + " >= c.total_gb)" +
" OR (c.expiry_time > 0 AND c.expiry_time <= " + sqlInt(q.nowMs) + "))"
}
func (q clientQuery) nearDepletionExpr() string {
return "((c.expiry_time > 0 AND c.expiry_time - " + sqlInt(q.nowMs) + " < " + sqlInt(q.expireDiffMs) + ")" +
" OR (c.total_gb > 0 AND c.total_gb - " + q.usedExpr + " < " + sqlInt(q.trafficDiffBytes) + "))"
}
func (q clientQuery) expiringExpr() string {
return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND " + q.nearDepletionExpr() + ")"
}
func (q clientQuery) activeExpr() string {
return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND NOT " + q.nearDepletionExpr() + ")"
}
// summaryDeactiveExpr is narrower than the "deactive" bucket filter: a disabled
// client that also ran out counts once, under depleted, so the stat cards add
// up to the client total.
func (q clientQuery) summaryDeactiveExpr() string {
return "(NOT " + sqlClientEnabled + " AND NOT " + q.depletedExpr() + ")"
}
// applyParams narrows tx by every predicate the clients page sends. Matching is
// OR within a field and AND across fields, mirroring the query-param contract.
// The second return says whether anything narrowed the set, so an unfiltered
// request can reuse the total count instead of scanning for it again.
func (q clientQuery) applyParams(tx *gorm.DB, params ClientPageParams, onlines []string) (*gorm.DB, bool) {
narrowed := false
where := func(cond string, args ...any) {
narrowed = true
tx = tx.Where(cond, args...)
}
if needle := strings.ToLower(strings.TrimSpace(params.Search)); needle != "" {
pattern := "%" + escapeLikeLiteral(needle) + "%"
where(clientSearchCond, pattern, pattern, pattern, pattern, pattern, pattern, pattern)
}
if protocols := parseCSVStrings(params.Protocol); len(protocols) > 0 {
where("EXISTS (SELECT 1 FROM client_inbounds ci JOIN inbounds ib ON ib.id = ci.inbound_id"+
" WHERE ci.client_id = c.id AND LOWER(ib.protocol) IN ?)", protocols)
}
if inboundIds := parseCSVInts(params.Inbound); len(inboundIds) > 0 {
where("EXISTS (SELECT 1 FROM client_inbounds ci WHERE ci.client_id = c.id AND ci.inbound_id IN ?)", inboundIds)
}
if buckets := parseCSVStrings(params.Filter); len(buckets) > 0 {
cond, args := q.bucketCond(buckets, onlines)
where(cond, args...)
}
if params.ExpiryFrom > 0 || params.ExpiryTo > 0 {
// 0 means "never expires" and a negative value is the delayed-start
// sentinel; both sit outside any bounded range.
where("c.expiry_time > 0")
if params.ExpiryFrom > 0 {
where("c.expiry_time >= ?", params.ExpiryFrom)
}
if params.ExpiryTo > 0 {
where("c.expiry_time <= ?", params.ExpiryTo)
}
}
if params.UsageFrom > 0 {
where(q.usedExpr+" >= ?", params.UsageFrom)
}
if params.UsageTo > 0 {
where(q.usedExpr+" <= ?", params.UsageTo)
}
switch strings.ToLower(strings.TrimSpace(params.AutoRenew)) {
case "on":
where("COALESCE(c.reset, 0) > 0")
case "off":
where("COALESCE(c.reset, 0) <= 0")
}
switch strings.ToLower(strings.TrimSpace(params.HasTgID)) {
case "yes":
where("COALESCE(c.tg_id, 0) <> 0")
case "no":
where("COALESCE(c.tg_id, 0) = 0")
}
switch strings.ToLower(strings.TrimSpace(params.HasComment)) {
case "yes":
where("TRIM(COALESCE(c.comment, '')) <> ''")
case "no":
where("TRIM(COALESCE(c.comment, '')) = ''")
}
if groups := parseCSVStrings(params.Group); len(groups) > 0 {
where("LOWER(TRIM(COALESCE(c.group_name, ''))) IN ?", groups)
}
return tx, narrowed
}
func (q clientQuery) bucketCond(buckets, onlines []string) (string, []any) {
conds := make([]string, 0, len(buckets))
args := make([]any, 0, len(buckets))
for _, b := range buckets {
switch b {
case "active":
conds = append(conds, "("+sqlClientEnabled+" AND NOT "+q.depletedExpr()+")")
case "deactive":
conds = append(conds, "(NOT "+sqlClientEnabled+")")
case "depleted":
conds = append(conds, q.depletedExpr())
case "expiring":
conds = append(conds, q.expiringExpr())
case "online":
cond, inArgs := emailInCond("c.email", onlines)
conds = append(conds, "("+sqlClientEnabled+" AND "+cond+")")
args = append(args, inArgs...)
default:
// An unrecognised bucket name matched every client before the
// predicates moved into SQL; keep that so a stale saved filter
// cannot silently empty the table.
conds = append(conds, "(1 = 1)")
}
}
return "(" + strings.Join(conds, " OR ") + ")", args
}
func (q clientQuery) applyOrder(tx *gorm.DB, sortKey, order string) *gorm.DB {
dir := " ASC"
if order == "descend" {
dir = " DESC"
}
// createdAt / updatedAt / lastOnline broke ties on the client id inside the
// comparator, so reversing the sort reversed the tiebreak with it. The
// other keys leaned on a stable sort over an id-ordered slice instead.
tieDir := " ASC"
var expr string
switch sortKey {
case "enable":
expr = sqlClientEnabled
case "email":
expr = "LOWER(c.email)"
case "inboundIds":
expr = "(SELECT COUNT(*) FROM client_inbounds ci WHERE ci.client_id = c.id)"
case "traffic":
expr = q.usedExpr
case "remaining":
expr = "CASE WHEN c.total_gb > 0 THEN c.total_gb - " + q.usedExpr + " ELSE " + sqlNeverSentinel + " END"
case "expiryTime":
expr = "CASE WHEN c.expiry_time > 0 THEN c.expiry_time ELSE " + sqlNeverSentinel + " END"
case "createdAt":
expr, tieDir = "c.created_at", dir
case "updatedAt":
expr, tieDir = "c.updated_at", dir
case "lastOnline":
expr, tieDir = "COALESCE(ct.last_online, 0)", dir
default:
return tx.Order("c.id ASC")
}
return tx.Order(expr + dir + ", c.id" + tieDir)
}
// ListPaged returns one page of clients together with the counts the clients
// page header needs. Every predicate runs in SQL, so the cost tracks the page
// size rather than the number of clients on the panel.
func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) {
db := database.GetDB()
pageSize := params.PageSize
if pageSize <= 0 {
@@ -114,27 +346,6 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
page = 1
}
protocols := parseCSVStrings(params.Protocol)
inboundIDs := parseCSVInts(params.Inbound)
buckets := parseCSVStrings(params.Filter)
var protocolByInbound map[int]string
if len(protocols) > 0 {
inbounds, err := inboundSvc.GetAllInbounds()
if err == nil {
protocolByInbound = make(map[int]string, len(inbounds))
for _, ib := range inbounds {
protocolByInbound[ib.Id] = string(ib.Protocol)
}
}
}
onlines := inboundSvc.GetOnlineClients()
onlineSet := make(map[string]struct{}, len(onlines))
for _, e := range onlines {
onlineSet[e] = struct{}{}
}
var expireDiffMs, trafficDiffBytes int64
if settingSvc != nil {
if v, err := settingSvc.GetExpireDiff(); err == nil {
@@ -145,77 +356,44 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
}
}
nowMs := time.Now().UnixMilli()
summary := buildClientsSummary(all, onlineSet, nowMs, expireDiffMs, trafficDiffBytes)
onlines := inboundSvc.GetOnlineClients()
q := newClientQuery(db, time.Now().UnixMilli(), expireDiffMs, trafficDiffBytes)
needle := strings.ToLower(strings.TrimSpace(params.Search))
filtered := make([]ClientWithAttachments, 0, len(all))
for _, c := range all {
if needle != "" && !clientMatchesSearch(c, needle) {
continue
}
if len(protocols) > 0 && !clientMatchesAnyProtocol(c, protocols, protocolByInbound) {
continue
}
if len(inboundIDs) > 0 && !clientMatchesAnyInbound(c, inboundIDs) {
continue
}
if len(buckets) > 0 && !clientMatchesAnyBucket(c, buckets, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
continue
}
if !clientMatchesExpiryRange(c, params.ExpiryFrom, params.ExpiryTo) {
continue
}
if !clientMatchesUsageRange(c, params.UsageFrom, params.UsageTo) {
continue
}
if !clientMatchesAutoRenew(c, params.AutoRenew) {
continue
}
if !clientMatchesHasTgID(c, params.HasTgID) {
continue
}
if !clientMatchesHasComment(c, params.HasComment) {
continue
}
if !clientMatchesAnyGroup(c, params.Group) {
continue
}
filtered = append(filtered, c)
var total int64
if err := db.Model(&model.ClientRecord{}).Count(&total).Error; err != nil {
return nil, err
}
sortClients(filtered, params.Sort, params.Order)
filteredCount := len(filtered)
start := (page - 1) * pageSize
end := start + pageSize
if start > filteredCount {
start = filteredCount
}
if end > filteredCount {
end = filteredCount
}
pageRows := filtered[start:end]
items := make([]ClientSlim, 0, len(pageRows))
for _, c := range pageRows {
items = append(items, toClientSlim(c))
summary, err := q.summary(onlines, int(total))
if err != nil {
return nil, err
}
groupRows, gErr := s.ListGroups()
if gErr != nil {
return nil, gErr
filtered := total
if scoped, narrowed := q.applyParams(q.from(), params, onlines); narrowed {
if err := scoped.Count(&filtered).Error; err != nil {
return nil, err
}
}
groups := make([]string, 0, len(groupRows))
for _, g := range groupRows {
groups = append(groups, g.Name)
items := []ClientSlim{}
offset := (page - 1) * pageSize
if int64(offset) < filtered {
items, err = q.pageRows(params, onlines, offset, pageSize)
if err != nil {
return nil, err
}
}
groups, err := s.listGroupNames()
if err != nil {
return nil, err
}
return &ClientPageResponse{
Items: items,
Total: total,
Filtered: filteredCount,
Total: int(total),
Filtered: int(filtered),
Page: page,
PageSize: pageSize,
Summary: summary,
@@ -223,77 +401,229 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
}, nil
}
func buildClientsSummary(all []ClientWithAttachments, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) ClientsSummary {
// pageRows resolves the requested page to client ids, then loads the records,
// attachments and traffic for those ids only. A page never exceeds
// clientPageMaxSize rows, which stays under sqlInChunk, so the follow-up IN
// lists need no chunking.
func (q clientQuery) pageRows(params ClientPageParams, onlines []string, offset, limit int) ([]ClientSlim, error) {
tx, _ := q.applyParams(q.from(), params, onlines)
var ids []int
if err := q.applyOrder(tx, params.Sort, params.Order).
Offset(offset).Limit(limit).
Pluck("c.id", &ids).Error; err != nil {
return nil, err
}
if len(ids) == 0 {
return []ClientSlim{}, nil
}
var records []model.ClientRecord
if err := q.db.Where("id IN ?", ids).Find(&records).Error; err != nil {
return nil, err
}
byId := make(map[int]*model.ClientRecord, len(records))
emails := make([]string, 0, len(records))
for i := range records {
byId[records[i].Id] = &records[i]
if records[i].Email != "" {
emails = append(emails, records[i].Email)
}
}
var links []model.ClientInbound
if err := q.db.Where("client_id IN ?", ids).Order("inbound_id ASC").Find(&links).Error; err != nil {
return nil, err
}
attachments := make(map[int][]int, len(ids))
for _, l := range links {
attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
}
trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
if len(emails) > 0 {
var stats []xray.ClientTraffic
if err := q.db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
return nil, err
}
overlayGlobalTrafficValues(q.db, stats)
for i := range stats {
trafficByEmail[stats[i].Email] = &stats[i]
}
}
items := make([]ClientSlim, 0, len(ids))
for _, id := range ids {
rec := byId[id]
if rec == nil {
continue
}
items = append(items, ClientSlim{
Email: rec.Email,
SubID: rec.SubID,
Enable: rec.Enable,
TotalGB: rec.TotalGB,
ExpiryTime: rec.ExpiryTime,
LimitIP: rec.LimitIP,
Reset: rec.Reset,
Group: rec.Group,
Comment: rec.Comment,
InboundIds: attachments[rec.Id],
Traffic: trafficByEmail[rec.Email],
CreatedAt: rec.CreatedAt,
UpdatedAt: rec.UpdatedAt,
})
}
return items, nil
}
func (q clientQuery) summary(onlines []string, total int) (ClientsSummary, error) {
s := ClientsSummary{
Total: len(all),
Total: total,
Online: []string{},
Depleted: []string{},
Expiring: []string{},
Deactive: []string{},
}
for _, c := range all {
used := int64(0)
if c.Traffic != nil {
used = c.Traffic.Up + c.Traffic.Down
var counts struct {
Active int64
Depleted int64
Expiring int64
Deactive int64
}
// SUM over an empty table yields NULL, which not every driver scans into an
// int; COALESCE keeps a panel with no clients from erroring out.
if err := q.from().Select(
"COALESCE(SUM(CASE WHEN " + q.activeExpr() + " THEN 1 ELSE 0 END), 0) AS active," +
" COALESCE(SUM(CASE WHEN " + q.depletedExpr() + " THEN 1 ELSE 0 END), 0) AS depleted," +
" COALESCE(SUM(CASE WHEN " + q.expiringExpr() + " THEN 1 ELSE 0 END), 0) AS expiring," +
" COALESCE(SUM(CASE WHEN " + q.summaryDeactiveExpr() + " THEN 1 ELSE 0 END), 0) AS deactive",
).Scan(&counts).Error; err != nil {
return s, err
}
s.Active = int(counts.Active)
s.DepletedCount = int(counts.Depleted)
s.ExpiringCount = int(counts.Expiring)
s.DeactiveCount = int(counts.Deactive)
buckets := []struct {
cond string
count int
out *[]string
}{
{q.depletedExpr(), s.DepletedCount, &s.Depleted},
{q.expiringExpr(), s.ExpiringCount, &s.Expiring},
{q.summaryDeactiveExpr(), s.DeactiveCount, &s.Deactive},
}
for _, b := range buckets {
// The counter already says the bucket is empty, so skip the scan that
// would look for emails it cannot find.
if b.count == 0 {
continue
}
exhausted := c.TotalGB > 0 && used >= c.TotalGB
expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
if c.Enable {
if _, ok := onlineSet[c.Email]; ok {
s.Online = append(s.Online, c.Email)
var emails []string
if err := q.from().Where(b.cond).
Order("c.id ASC").Limit(clientSummaryEmailCap).
Pluck("c.email", &emails).Error; err != nil {
return s, err
}
if len(emails) > 0 {
*b.out = emails
}
}
online, onlineCount, err := q.onlineEmails(onlines)
if err != nil {
return s, err
}
s.Online = online
s.OnlineCount = onlineCount
return s, nil
}
// onlineEmails intersects the emails xray reports as connected with the enabled
// clients this panel stores. The online set lives in memory and is bounded by
// live connections, so it drives the query rather than a scan of every client.
func (q clientQuery) onlineEmails(onlines []string) ([]string, int, error) {
matched := []string{}
count := 0
for _, batch := range chunkStrings(onlines, sqlInChunk) {
var page []string
if err := q.db.Model(&model.ClientRecord{}).
Where("COALESCE(enable, FALSE) = TRUE AND email IN ?", batch).
Order("id ASC").
Pluck("email", &page).Error; err != nil {
return nil, 0, err
}
count += len(page)
if room := clientSummaryEmailCap - len(matched); room > 0 {
matched = append(matched, page[:min(room, len(page))]...)
}
}
return matched, count, nil
}
// listGroupNames returns the group names the clients page offers as filters:
// the stored groups plus any name a client still carries. ListGroups also sums
// per-client traffic per group, which this page never reads and which costs a
// full join over client_traffics on every poll.
func (s *ClientService) listGroupNames() ([]string, error) {
db := database.GetDB()
var stored []string
if err := db.Model(&model.ClientGroup{}).Pluck("name", &stored).Error; err != nil {
return nil, err
}
var used []string
if err := db.Model(&model.ClientRecord{}).
Where("group_name <> ''").
Distinct().
Pluck("group_name", &used).Error; err != nil {
return nil, err
}
seen := make(map[string]struct{}, len(stored)+len(used))
out := make([]string, 0, len(stored)+len(used))
for _, list := range [][]string{stored, used} {
for _, name := range list {
if name == "" {
continue
}
}
if exhausted || expired {
s.Depleted = append(s.Depleted, c.Email)
continue
}
if !c.Enable {
s.Deactive = append(s.Deactive, c.Email)
continue
}
nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
if nearExpiry || nearLimit {
s.Expiring = append(s.Expiring, c.Email)
} else {
s.Active++
if _, dup := seen[name]; dup {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
}
return s
sort.Slice(out, func(i, j int) bool {
return strings.ToLower(out[i]) < strings.ToLower(out[j])
})
return out, nil
}
func toClientSlim(c ClientWithAttachments) ClientSlim {
return ClientSlim{
Email: c.Email,
SubID: c.SubID,
Enable: c.Enable,
TotalGB: c.TotalGB,
ExpiryTime: c.ExpiryTime,
LimitIP: c.LimitIP,
Reset: c.Reset,
Group: c.Group,
Comment: c.Comment,
InboundIds: c.InboundIds,
Traffic: c.Traffic,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
func sqlInt(v int64) string {
return strconv.FormatInt(v, 10)
}
func clientMatchesSearch(c ClientWithAttachments, needle string) bool {
if needle == "" {
return true
// escapeLikeLiteral neutralises LIKE wildcards so searching for "a_b" keeps
// matching literally, the way strings.Contains did.
func escapeLikeLiteral(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
}
// emailInCond renders an IN over a possibly large email set, split so no single
// IN list outgrows the drivers' bind-parameter ceiling.
func emailInCond(column string, emails []string) (string, []any) {
if len(emails) == 0 {
return "1 = 0", nil
}
candidates := [...]string{c.Email, c.SubID, c.Comment, c.UUID, c.Password, c.Auth}
for _, v := range candidates {
if v != "" && strings.Contains(strings.ToLower(v), needle) {
return true
}
chunks := chunkStrings(emails, sqlInChunk)
parts := make([]string, 0, len(chunks))
args := make([]any, 0, len(chunks))
for _, chunk := range chunks {
parts = append(parts, column+" IN ?")
args = append(args, chunk)
}
if c.TgID != 0 && strings.Contains(strconv.FormatInt(c.TgID, 10), needle) {
return true
}
return false
return "(" + strings.Join(parts, " OR ") + ")", args
}
// parseCSVStrings splits a comma-separated list, trims/lower-cases each item,
@@ -339,246 +669,3 @@ func parseCSVInts(raw string) []int {
}
return out
}
func clientMatchesAnyProtocol(c ClientWithAttachments, protocols []string, byInbound map[int]string) bool {
for _, id := range c.InboundIds {
p := byInbound[id]
if p == "" {
continue
}
if slices.Contains(protocols, strings.ToLower(p)) {
return true
}
}
return false
}
func clientMatchesAnyInbound(c ClientWithAttachments, inboundIds []int) bool {
for _, id := range c.InboundIds {
if slices.Contains(inboundIds, id) {
return true
}
}
return false
}
func clientMatchesAnyBucket(c ClientWithAttachments, buckets []string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
for _, b := range buckets {
if clientMatchesBucket(c, b, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
return true
}
}
return false
}
func clientMatchesExpiryRange(c ClientWithAttachments, fromMs, toMs int64) bool {
if fromMs <= 0 && toMs <= 0 {
return true
}
// expiryTime of 0 means "never expires"; treat it as outside any bounded
// range so users filtering by date see only clients with concrete expiries.
if c.ExpiryTime == 0 {
return false
}
// Negative expiry is the "delayed start" sentinel; same treatment as never.
if c.ExpiryTime < 0 {
return false
}
if fromMs > 0 && c.ExpiryTime < fromMs {
return false
}
if toMs > 0 && c.ExpiryTime > toMs {
return false
}
return true
}
func clientMatchesUsageRange(c ClientWithAttachments, fromBytes, toBytes int64) bool {
if fromBytes <= 0 && toBytes <= 0 {
return true
}
used := int64(0)
if c.Traffic != nil {
used = c.Traffic.Up + c.Traffic.Down
}
if fromBytes > 0 && used < fromBytes {
return false
}
if toBytes > 0 && used > toBytes {
return false
}
return true
}
func clientMatchesAutoRenew(c ClientWithAttachments, mode string) bool {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "on":
return c.Reset > 0
case "off":
return c.Reset <= 0
}
return true
}
func clientMatchesHasTgID(c ClientWithAttachments, mode string) bool {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "yes":
return c.TgID != 0
case "no":
return c.TgID == 0
}
return true
}
func clientMatchesHasComment(c ClientWithAttachments, mode string) bool {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "yes":
return strings.TrimSpace(c.Comment) != ""
case "no":
return strings.TrimSpace(c.Comment) == ""
}
return true
}
func clientMatchesAnyGroup(c ClientWithAttachments, csv string) bool {
groups := parseCSVStrings(csv)
if len(groups) == 0 {
return true
}
current := strings.TrimSpace(c.Group)
for _, g := range groups {
if g == "" {
if current == "" {
return true
}
continue
}
if strings.EqualFold(g, current) {
return true
}
}
return false
}
func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
if bucket == "" {
return true
}
used := int64(0)
if c.Traffic != nil {
used = c.Traffic.Up + c.Traffic.Down
}
exhausted := c.TotalGB > 0 && used >= c.TotalGB
expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
switch bucket {
case "online":
if onlineSet == nil {
return false
}
_, ok := onlineSet[c.Email]
return ok && c.Enable
case "depleted":
return exhausted || expired
case "deactive":
return !c.Enable
case "active":
return c.Enable && !exhausted && !expired
case "expiring":
if !c.Enable || exhausted || expired {
return false
}
nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
return nearExpiry || nearLimit
}
return true
}
func sortClients(rows []ClientWithAttachments, sortKey, order string) {
if sortKey == "" {
return
}
desc := order == "descend"
less := func(i, j int) bool {
a, b := rows[i], rows[j]
switch sortKey {
case "enable":
if a.Enable == b.Enable {
return false
}
return !a.Enable && b.Enable
case "email":
return strings.ToLower(a.Email) < strings.ToLower(b.Email)
case "inboundIds":
return len(a.InboundIds) < len(b.InboundIds)
case "traffic":
ua := int64(0)
if a.Traffic != nil {
ua = a.Traffic.Up + a.Traffic.Down
}
ub := int64(0)
if b.Traffic != nil {
ub = b.Traffic.Up + b.Traffic.Down
}
return ua < ub
case "remaining":
ra := int64(1<<62 - 1)
if a.TotalGB > 0 {
used := int64(0)
if a.Traffic != nil {
used = a.Traffic.Up + a.Traffic.Down
}
ra = a.TotalGB - used
}
rb := int64(1<<62 - 1)
if b.TotalGB > 0 {
used := int64(0)
if b.Traffic != nil {
used = b.Traffic.Up + b.Traffic.Down
}
rb = b.TotalGB - used
}
return ra < rb
case "expiryTime":
ea := int64(1<<62 - 1)
if a.ExpiryTime > 0 {
ea = a.ExpiryTime
}
eb := int64(1<<62 - 1)
if b.ExpiryTime > 0 {
eb = b.ExpiryTime
}
return ea < eb
case "createdAt":
if a.CreatedAt == b.CreatedAt {
return a.Id < b.Id
}
return a.CreatedAt < b.CreatedAt
case "updatedAt":
if a.UpdatedAt == b.UpdatedAt {
return a.Id < b.Id
}
return a.UpdatedAt < b.UpdatedAt
case "lastOnline":
la := int64(0)
if a.Traffic != nil {
la = a.Traffic.LastOnline
}
lb := int64(0)
if b.Traffic != nil {
lb = b.Traffic.LastOnline
}
if la == lb {
return a.Id < b.Id
}
return la < lb
}
return false
}
sort.SliceStable(rows, func(i, j int) bool {
if desc {
return less(j, i)
}
return less(i, j)
})
}
+624
View File
@@ -0,0 +1,624 @@
package service
import (
"slices"
"strconv"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
const (
pagingDay = int64(86400000)
pagingGB = int64(1) << 30
)
type pagingSeed struct {
email string
enable bool
totalGB int64
expiryTime int64
used int64
subID string
uuid string
password string
auth string
comment string
group string
tgID int64
reset int
lastOnline int64
inbounds []int
}
// seedPagingClients writes one vless and one trojan inbound plus a fixed client
// set covering every bucket, sort key and search field ListPaged supports.
// Returns "now" so the expectations can be phrased relative to it.
func seedPagingClients(t *testing.T) (int64, []pagingSeed) {
t.Helper()
db := database.GetDB()
now := time.Now().UnixMilli()
vless := &model.Inbound{UserId: 1, Tag: "in-vless", Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
trojan := &model.Inbound{UserId: 1, Tag: "in-trojan", Enable: true, Port: 40002, Protocol: model.Trojan, Settings: `{"clients":[]}`}
for _, ib := range []*model.Inbound{vless, trojan} {
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound %s: %v", ib.Tag, err)
}
}
seeds := []pagingSeed{
{email: "alpha@x", enable: true, totalGB: 0, expiryTime: 0, used: 5 * pagingGB, subID: "sub-alpha", uuid: "uuid-alpha", inbounds: []int{vless.Id}, lastOnline: now - 10*pagingDay},
{email: "bravo@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: pagingGB, password: "pw-bravo", inbounds: []int{vless.Id, trojan.Id}, lastOnline: now - pagingDay},
{email: "charlie@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: 10 * pagingGB, auth: "auth-charlie", inbounds: []int{trojan.Id}},
{email: "delta@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now - pagingDay, used: pagingGB, inbounds: []int{vless.Id}},
{email: "echo@x", enable: false, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, inbounds: []int{vless.Id}},
{email: "foxtrot@x", enable: false, totalGB: 10 * pagingGB, expiryTime: now - pagingDay, inbounds: []int{trojan.Id}},
{email: "golf@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 2*pagingDay, inbounds: []int{vless.Id}},
{email: "hotel@x", enable: true, totalGB: 10 * pagingGB, expiryTime: now + 30*pagingDay, used: 10*pagingGB - pagingGB/2, inbounds: []int{vless.Id}},
{email: "india@x", enable: true, totalGB: 0, expiryTime: -5 * pagingDay, inbounds: []int{vless.Id}},
{email: "juliet@x", enable: true, comment: " vip customer ", group: "VIP", tgID: 555, reset: 7, inbounds: []int{vless.Id}},
{email: "kilo_1@x", enable: true, group: "vip", inbounds: nil},
{email: "kilo1@x", enable: true, inbounds: []int{trojan.Id}},
}
for i, s := range seeds {
rec := model.ClientRecord{
Email: s.email,
SubID: s.subID,
UUID: s.uuid,
Password: s.password,
Auth: s.auth,
Comment: s.comment,
Group: s.group,
TgID: s.tgID,
Reset: s.reset,
Enable: s.enable,
TotalGB: s.totalGB,
ExpiryTime: s.expiryTime,
CreatedAt: now - int64(len(seeds)-i)*pagingDay,
UpdatedAt: now - int64(i)*pagingDay,
}
if err := db.Create(&rec).Error; err != nil {
t.Fatalf("create client %s: %v", s.email, err)
}
if !s.enable {
// clients.enable carries a `default:true` tag, so GORM leaves the
// zero value out of the INSERT and the column comes back true.
// Restate updated_at so the autoUpdateTime hook cannot reshuffle
// the sort fixtures.
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).
Updates(map[string]any{"enable": false, "updated_at": rec.UpdatedAt}).Error; err != nil {
t.Fatalf("disable %s: %v", s.email, err)
}
}
traffic := xray.ClientTraffic{
Email: s.email,
Enable: s.enable,
Up: s.used / 2,
Down: s.used - s.used/2,
Total: s.totalGB,
ExpiryTime: s.expiryTime,
LastOnline: s.lastOnline,
}
if err := db.Create(&traffic).Error; err != nil {
t.Fatalf("create traffic %s: %v", s.email, err)
}
for _, id := range s.inbounds {
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: id}).Error; err != nil {
t.Fatalf("attach %s to %d: %v", s.email, id, err)
}
}
}
return now, seeds
}
func pagedEmails(items []ClientSlim) []string {
out := make([]string, 0, len(items))
for _, it := range items {
out = append(out, it.Email)
}
return out
}
func setupPagingServices(t *testing.T) (*ClientService, *InboundService, *SettingService) {
t.Helper()
setupBulkDB(t)
settingSvc := &SettingService{}
if err := settingSvc.setInt("expireDiff", 3); err != nil {
t.Fatalf("set expireDiff: %v", err)
}
if err := settingSvc.setInt("trafficDiff", 1); err != nil {
t.Fatalf("set trafficDiff: %v", err)
}
return &ClientService{}, &InboundService{}, settingSvc
}
func TestListPagedFilters(t *testing.T) {
svc, inboundSvc, settingSvc := setupPagingServices(t)
now, _ := seedPagingClients(t)
tests := []struct {
name string
params ClientPageParams
want []string
}{
{
name: "no filter returns every client in id order",
params: ClientPageParams{PageSize: 50},
want: []string{"alpha@x", "bravo@x", "charlie@x", "delta@x", "echo@x", "foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
},
{
name: "depleted bucket covers quota and expiry",
params: ClientPageParams{PageSize: 50, Filter: "depleted"},
want: []string{"charlie@x", "delta@x", "foxtrot@x"},
},
{
name: "deactive bucket is every disabled client",
params: ClientPageParams{PageSize: 50, Filter: "deactive"},
want: []string{"echo@x", "foxtrot@x"},
},
{
name: "expiring bucket covers near expiry and near quota",
params: ClientPageParams{PageSize: 50, Filter: "expiring"},
want: []string{"golf@x", "hotel@x"},
},
{
name: "active bucket keeps enabled clients that still have room",
params: ClientPageParams{PageSize: 50, Filter: "active"},
want: []string{"alpha@x", "bravo@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
},
{
name: "buckets are ORed",
params: ClientPageParams{PageSize: 50, Filter: "depleted,expiring"},
want: []string{"charlie@x", "delta@x", "foxtrot@x", "golf@x", "hotel@x"},
},
{
name: "unknown bucket keeps matching everything",
params: ClientPageParams{PageSize: 50, Filter: "nonsense"},
want: []string{"alpha@x", "bravo@x", "charlie@x", "delta@x", "echo@x", "foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
},
{
name: "protocol filter follows the attachments",
params: ClientPageParams{PageSize: 50, Protocol: "trojan"},
want: []string{"bravo@x", "charlie@x", "foxtrot@x", "kilo1@x"},
},
{
name: "inbound filter follows the attachments",
params: ClientPageParams{PageSize: 50, Inbound: "2"},
want: []string{"bravo@x", "charlie@x", "foxtrot@x", "kilo1@x"},
},
{
name: "search matches the email",
params: ClientPageParams{PageSize: 50, Search: "KILO"},
want: []string{"kilo_1@x", "kilo1@x"},
},
{
name: "search treats LIKE wildcards literally",
params: ClientPageParams{PageSize: 50, Search: "kilo_1"},
want: []string{"kilo_1@x"},
},
{
name: "search matches the subId",
params: ClientPageParams{PageSize: 50, Search: "sub-alpha"},
want: []string{"alpha@x"},
},
{
name: "search matches the uuid",
params: ClientPageParams{PageSize: 50, Search: "uuid-alpha"},
want: []string{"alpha@x"},
},
{
name: "search matches the password",
params: ClientPageParams{PageSize: 50, Search: "pw-bravo"},
want: []string{"bravo@x"},
},
{
name: "search matches the auth",
params: ClientPageParams{PageSize: 50, Search: "auth-charlie"},
want: []string{"charlie@x"},
},
{
name: "search matches the comment",
params: ClientPageParams{PageSize: 50, Search: "vip customer"},
want: []string{"juliet@x"},
},
{
name: "search matches the telegram id",
params: ClientPageParams{PageSize: 50, Search: "555"},
want: []string{"juliet@x"},
},
{
name: "group filter is case insensitive",
params: ClientPageParams{PageSize: 50, Group: "vip"},
want: []string{"juliet@x", "kilo_1@x"},
},
{
name: "hasComment yes",
params: ClientPageParams{PageSize: 50, HasComment: "yes"},
want: []string{"juliet@x"},
},
{
name: "hasTgId yes",
params: ClientPageParams{PageSize: 50, HasTgID: "yes"},
want: []string{"juliet@x"},
},
{
name: "autoRenew on",
params: ClientPageParams{PageSize: 50, AutoRenew: "on"},
want: []string{"juliet@x"},
},
{
name: "usage range is inclusive on both bounds",
params: ClientPageParams{PageSize: 50, UsageFrom: pagingGB, UsageTo: 5 * pagingGB},
want: []string{"alpha@x", "bravo@x", "delta@x"},
},
{
name: "expiry range excludes never and delayed start",
params: ClientPageParams{PageSize: 50, ExpiryFrom: now, ExpiryTo: now + 10*pagingDay},
want: []string{"golf@x"},
},
{
name: "filters combine with AND",
params: ClientPageParams{PageSize: 50, Filter: "depleted", Protocol: "trojan"},
want: []string{"charlie@x", "foxtrot@x"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
resp, err := svc.ListPaged(inboundSvc, settingSvc, tc.params)
if err != nil {
t.Fatalf("ListPaged: %v", err)
}
got := pagedEmails(resp.Items)
if !slices.Equal(got, tc.want) {
t.Fatalf("emails = %v, want %v", got, tc.want)
}
if resp.Filtered != len(tc.want) {
t.Fatalf("filtered = %d, want %d", resp.Filtered, len(tc.want))
}
if resp.Total != 12 {
t.Fatalf("total = %d, want 12", resp.Total)
}
})
}
}
func TestListPagedSorting(t *testing.T) {
svc, inboundSvc, settingSvc := setupPagingServices(t)
seedPagingClients(t)
tests := []struct {
name string
sort string
order string
want []string
}{
{
name: "no sort key keeps insertion order",
want: []string{"alpha@x", "bravo@x", "charlie@x"},
},
{
name: "email ascending", sort: "email", order: "ascend",
want: []string{"alpha@x", "bravo@x", "charlie@x"},
},
{
name: "email descending", sort: "email", order: "descend",
want: []string{"kilo_1@x", "kilo1@x", "juliet@x"},
},
{
name: "traffic descending", sort: "traffic", order: "descend",
want: []string{"charlie@x", "hotel@x", "alpha@x"},
},
{
name: "remaining descending puts unlimited quotas first", sort: "remaining", order: "descend",
want: []string{"alpha@x", "india@x", "juliet@x"},
},
{
name: "expiry ascending starts with the expired", sort: "expiryTime", order: "ascend",
want: []string{"delta@x", "foxtrot@x", "golf@x"},
},
{
name: "createdAt ascending follows insertion", sort: "createdAt", order: "ascend",
want: []string{"alpha@x", "bravo@x", "charlie@x"},
},
{
name: "updatedAt descending starts with the newest", sort: "updatedAt", order: "descend",
want: []string{"alpha@x", "bravo@x", "charlie@x"},
},
{
name: "lastOnline descending breaks ties on the id, reversed too", sort: "lastOnline", order: "descend",
want: []string{"bravo@x", "alpha@x", "kilo1@x"},
},
{
name: "enable ascending puts disabled first", sort: "enable", order: "ascend",
want: []string{"echo@x", "foxtrot@x", "alpha@x"},
},
{
name: "inboundIds descending puts the widest attachment first", sort: "inboundIds", order: "descend",
want: []string{"bravo@x", "alpha@x", "charlie@x"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 3, Sort: tc.sort, Order: tc.order})
if err != nil {
t.Fatalf("ListPaged: %v", err)
}
got := pagedEmails(resp.Items)
if !slices.Equal(got, tc.want) {
t.Fatalf("emails = %v, want %v", got, tc.want)
}
})
}
}
func TestListPagedPagination(t *testing.T) {
svc, inboundSvc, settingSvc := setupPagingServices(t)
seedPagingClients(t)
t.Run("second page continues where the first stopped", func(t *testing.T) {
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 2, PageSize: 5, Sort: "email", Order: "ascend"})
if err != nil {
t.Fatalf("ListPaged: %v", err)
}
want := []string{"foxtrot@x", "golf@x", "hotel@x", "india@x", "juliet@x"}
if got := pagedEmails(resp.Items); !slices.Equal(got, want) {
t.Fatalf("emails = %v, want %v", got, want)
}
if resp.Page != 2 || resp.PageSize != 5 {
t.Fatalf("page/pageSize = %d/%d, want 2/5", resp.Page, resp.PageSize)
}
})
t.Run("page past the end is empty but keeps the counts", func(t *testing.T) {
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 9, PageSize: 5})
if err != nil {
t.Fatalf("ListPaged: %v", err)
}
if len(resp.Items) != 0 {
t.Fatalf("items = %v, want none", pagedEmails(resp.Items))
}
if resp.Filtered != 12 || resp.Total != 12 {
t.Fatalf("filtered/total = %d/%d, want 12/12", resp.Filtered, resp.Total)
}
})
t.Run("page size is clamped to the maximum", func(t *testing.T) {
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 5000})
if err != nil {
t.Fatalf("ListPaged: %v", err)
}
if resp.PageSize != clientPageMaxSize {
t.Fatalf("pageSize = %d, want %d", resp.PageSize, clientPageMaxSize)
}
})
}
func TestListPagedRowContents(t *testing.T) {
svc, inboundSvc, settingSvc := setupPagingServices(t)
seedPagingClients(t)
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50})
if err != nil {
t.Fatalf("ListPaged: %v", err)
}
byEmail := make(map[string]ClientSlim, len(resp.Items))
for _, it := range resp.Items {
byEmail[it.Email] = it
}
t.Run("attachments are reported in inbound order", func(t *testing.T) {
got := byEmail["bravo@x"].InboundIds
if !slices.Equal(got, []int{1, 2}) {
t.Fatalf("inboundIds = %v, want [1 2]", got)
}
})
t.Run("an unattached client reports no inbounds", func(t *testing.T) {
if got := byEmail["kilo_1@x"].InboundIds; len(got) != 0 {
t.Fatalf("inboundIds = %v, want none", got)
}
})
t.Run("traffic counters ride along with the row", func(t *testing.T) {
got := byEmail["hotel@x"].Traffic
if got == nil {
t.Fatal("traffic = nil, want the seeded counters")
}
if want := 10*pagingGB - pagingGB/2; got.Up+got.Down != want {
t.Fatalf("used = %d, want %d", got.Up+got.Down, want)
}
})
t.Run("groups list every name in use", func(t *testing.T) {
if !slices.Equal(resp.Groups, []string{"vip", "VIP"}) && !slices.Equal(resp.Groups, []string{"VIP", "vip"}) {
t.Fatalf("groups = %v, want VIP and vip", resp.Groups)
}
})
}
func TestListPagedSummary(t *testing.T) {
svc, inboundSvc, settingSvc := setupPagingServices(t)
seedPagingClients(t)
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 5, Filter: "depleted"})
if err != nil {
t.Fatalf("ListPaged: %v", err)
}
s := resp.Summary
t.Run("counts stay whole-panel while the page is filtered", func(t *testing.T) {
if s.Total != 12 {
t.Fatalf("total = %d, want 12", s.Total)
}
if s.DepletedCount != 3 {
t.Fatalf("depletedCount = %d, want 3", s.DepletedCount)
}
if s.ExpiringCount != 2 {
t.Fatalf("expiringCount = %d, want 2", s.ExpiringCount)
}
if s.DeactiveCount != 1 {
t.Fatalf("deactiveCount = %d, want 1", s.DeactiveCount)
}
if s.Active != 6 {
t.Fatalf("active = %d, want 6", s.Active)
}
})
t.Run("every client lands in exactly one counter", func(t *testing.T) {
if sum := s.Active + s.DepletedCount + s.ExpiringCount + s.DeactiveCount; sum != s.Total {
t.Fatalf("buckets sum to %d, want %d", sum, s.Total)
}
})
t.Run("bucket lists carry the matching emails", func(t *testing.T) {
if want := []string{"charlie@x", "delta@x", "foxtrot@x"}; !slices.Equal(s.Depleted, want) {
t.Fatalf("depleted = %v, want %v", s.Depleted, want)
}
if want := []string{"golf@x", "hotel@x"}; !slices.Equal(s.Expiring, want) {
t.Fatalf("expiring = %v, want %v", s.Expiring, want)
}
if want := []string{"echo@x"}; !slices.Equal(s.Deactive, want) {
t.Fatalf("deactive = %v, want %v", s.Deactive, want)
}
})
}
func TestListPagedSummaryEmailListsAreCapped(t *testing.T) {
svc, inboundSvc, settingSvc := setupPagingServices(t)
db := database.GetDB()
const n = clientSummaryEmailCap + 25
past := time.Now().UnixMilli() - pagingDay
for i := range n {
rec := model.ClientRecord{Email: "bulk-" + strconv.Itoa(i) + "@x", Enable: true, TotalGB: pagingGB, ExpiryTime: past}
if err := db.Create(&rec).Error; err != nil {
t.Fatalf("create client %d: %v", i, err)
}
}
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 25})
if err != nil {
t.Fatalf("ListPaged: %v", err)
}
if resp.Summary.DepletedCount != n {
t.Fatalf("depletedCount = %d, want %d", resp.Summary.DepletedCount, n)
}
if len(resp.Summary.Depleted) != clientSummaryEmailCap {
t.Fatalf("depleted list = %d entries, want %d", len(resp.Summary.Depleted), clientSummaryEmailCap)
}
}
func TestListPagedGlobalTrafficOverlay(t *testing.T) {
svc, inboundSvc, settingSvc := setupPagingServices(t)
seedPagingClients(t)
// bravo has used 1GB of its 10GB locally; a master reporting 10GB of
// cross-panel usage has to move it into the depleted bucket.
if err := inboundSvc.AcceptGlobalTraffic("master-guid", []*xray.ClientTraffic{
{Email: "bravo@x", Up: 4 * pagingGB, Down: 6 * pagingGB},
}); err != nil {
t.Fatalf("AcceptGlobalTraffic: %v", err)
}
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50, Filter: "depleted"})
if err != nil {
t.Fatalf("ListPaged: %v", err)
}
want := []string{"bravo@x", "charlie@x", "delta@x", "foxtrot@x"}
if got := pagedEmails(resp.Items); !slices.Equal(got, want) {
t.Fatalf("depleted = %v, want %v", got, want)
}
if resp.Summary.DepletedCount != 4 {
t.Fatalf("depletedCount = %d, want 4", resp.Summary.DepletedCount)
}
for _, it := range resp.Items {
if it.Email != "bravo@x" {
continue
}
if it.Traffic == nil || it.Traffic.Up+it.Traffic.Down != 10*pagingGB {
t.Fatalf("bravo traffic = %+v, want the overlaid 10GB", it.Traffic)
}
}
}
func TestClientQueryOnlineEmails(t *testing.T) {
_, _, _ = setupPagingServices(t)
seedPagingClients(t)
q := newClientQuery(database.GetDB(), time.Now().UnixMilli(), 0, 0)
emails, count, err := q.onlineEmails([]string{"alpha@x", "echo@x", "ghost@x", "kilo1@x"})
if err != nil {
t.Fatalf("onlineEmails: %v", err)
}
if want := []string{"alpha@x", "kilo1@x"}; !slices.Equal(emails, want) {
t.Fatalf("online = %v, want %v (disabled and unknown emails drop out)", emails, want)
}
if count != 2 {
t.Fatalf("count = %d, want 2", count)
}
}
func TestEmailInCondChunksLargeSets(t *testing.T) {
emails := make([]string, sqlInChunk+1)
for i := range emails {
emails[i] = "e" + strconv.Itoa(i)
}
cond, args := emailInCond("c.email", emails)
if want := "(c.email IN ? OR c.email IN ?)"; cond != want {
t.Fatalf("cond = %q, want %q", cond, want)
}
if len(args) != 2 {
t.Fatalf("args = %d chunks, want 2", len(args))
}
if first, ok := args[0].([]string); !ok || len(first) != sqlInChunk {
t.Fatalf("first chunk = %v, want %d entries", args[0], sqlInChunk)
}
emptyCond, emptyArgs := emailInCond("c.email", nil)
if emptyCond != "1 = 0" || emptyArgs != nil {
t.Fatalf("empty set = %q/%v, want an always-false predicate", emptyCond, emptyArgs)
}
}
func TestEscapeLikeLiteral(t *testing.T) {
tests := []struct {
in string
want string
}{
{"plain", "plain"},
{"a_b", `a\_b`},
{"50%", `50\%`},
{`back\slash`, `back\\slash`},
}
for _, tc := range tests {
if got := escapeLikeLiteral(tc.in); got != tc.want {
t.Fatalf("escapeLikeLiteral(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestListPagedEmptyPanel(t *testing.T) {
svc, inboundSvc, settingSvc := setupPagingServices(t)
resp, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{})
if err != nil {
t.Fatalf("ListPaged on a panel with no clients: %v", err)
}
if len(resp.Items) != 0 || resp.Total != 0 || resp.Filtered != 0 {
t.Fatalf("items/total/filtered = %d/%d/%d, want 0/0/0", len(resp.Items), resp.Total, resp.Filtered)
}
if resp.Summary.Active != 0 || resp.Summary.DepletedCount != 0 {
t.Fatalf("summary = %+v, want zeroed counters", resp.Summary)
}
if resp.Groups == nil {
t.Fatal("groups = nil, want an empty list so the filter drawer renders")
}
}
@@ -0,0 +1,85 @@
package service
import (
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
// How long a client stays recoverable after a node merge concluded it is gone.
// Any merge that sees it attached again inside this window clears the mark.
const syncOrphanReapGrace = 15 * time.Minute
// A traffic row with no clients row behind it is drift the orphan sweep will
// never mark, so it stays on the old delete-immediately path.
func clientRecordExists(tx *gorm.DB, email string) bool {
var n int64
if err := tx.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&n).Error; err != nil {
return false
}
return n > 0
}
func markSyncOrphan(tx *gorm.DB, email string, nowMs int64) error {
if email == "" {
return nil
}
return tx.Model(&model.ClientRecord{}).
Where("email = ? AND sync_orphaned_at = 0", email).
Update("sync_orphaned_at", nowMs).Error
}
// A client that is attached again is not orphaned, whatever an earlier merge
// concluded — this is what makes a bad merge recoverable instead of fatal.
func clearSyncOrphanMarks(tx *gorm.DB) error {
return tx.Model(&model.ClientRecord{}).
Where("sync_orphaned_at > 0 AND EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)").
Update("sync_orphaned_at", 0).Error
}
// ReapSyncOrphans deletes the clients the node-snapshot sweep marked and no
// later merge reclaimed. It is the only path that hard-deletes for that sweep.
func (s *ClientService) ReapSyncOrphans() (int, error) {
db := database.GetDB()
cutoff := time.Now().Add(-syncOrphanReapGrace).UnixMilli()
var emails []string
if err := db.Model(&model.ClientRecord{}).
Where("sync_orphaned_at > 0 AND sync_orphaned_at <= ?", cutoff).
Where("NOT EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)").
Pluck("email", &emails).Error; err != nil {
return 0, err
}
if len(emails) == 0 {
return 0, nil
}
reaped := 0
for _, batch := range chunkStrings(emails, sqlInChunk) {
if err := runSerializedTx(func(tx *gorm.DB) error {
if err := adjustGroupBaselinesForRemovedTraffic(tx, batch); err != nil {
return err
}
if err := tx.Where("email IN ?", batch).Delete(&model.ClientRecord{}).Error; err != nil {
return err
}
if err := tx.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; err != nil {
return err
}
if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil {
return err
}
return tx.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error
}); err != nil {
return reaped, err
}
reaped += len(batch)
logger.Infof("reaped %d client(s) confirmed removed on their node", len(batch))
}
return reaped, nil
}
@@ -0,0 +1,148 @@
package service
import (
"fmt"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
func readOrphanMark(t *testing.T, db *gorm.DB, email string) int64 {
t.Helper()
var row model.ClientRecord
if err := db.Where("email = ?", email).First(&row).Error; err != nil {
t.Fatalf("read client %q: %v", email, err)
}
return row.SyncOrphanedAt
}
func backdateOrphanMark(t *testing.T, db *gorm.DB, email string) {
t.Helper()
past := time.Now().Add(-2 * syncOrphanReapGrace).UnixMilli()
if err := db.Model(&model.ClientRecord{}).
Where("email = ?", email).
Update("sync_orphaned_at", past).Error; err != nil {
t.Fatalf("backdate orphan mark: %v", err)
}
}
// The merge must soft-orphan, not delete: everything stays recoverable until
// the grace period has elapsed and the reaper confirms nothing reclaimed it.
func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
clientSvc := &ClientService{}
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
const email = "gone@x"
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
t.Fatalf("setup: clients=%d client_traffics=%d, want 1/1", rec, traf)
}
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false); err != nil {
t.Fatalf("orphaning merge: %v", err)
}
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
t.Fatalf("merge hard-deleted the client: clients=%d client_traffics=%d, want 1/1", rec, traf)
}
if readOrphanMark(t, db, email) <= 0 {
t.Fatal("merge did not stamp sync_orphaned_at")
}
reaped, err := clientSvc.ReapSyncOrphans()
if err != nil {
t.Fatalf("reap inside grace: %v", err)
}
if reaped != 0 {
t.Fatalf("reaped %d client(s) inside the grace period, want 0", reaped)
}
if rec, _ := countClientRows(t, db, email); rec != 1 {
t.Fatal("client removed before the grace period elapsed")
}
backdateOrphanMark(t, db, email)
reaped, err = clientSvc.ReapSyncOrphans()
if err != nil {
t.Fatalf("reap after grace: %v", err)
}
if reaped != 1 {
t.Fatalf("reaped %d client(s) after the grace period, want 1", reaped)
}
rec, traf := countClientRows(t, db, email)
if rec != 0 || traf != 0 {
t.Fatalf("after reap: clients=%d client_traffics=%d, want 0/0", rec, traf)
}
}
// A client the node reports again was never gone: clearing the mark is what
// turns a bad merge into a recoverable blip instead of a delayed deletion.
func TestSyncOrphanMarkClearedOnReattach(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
clientSvc := &ClientService{}
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
const email = "flaky@x"
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false); err != nil {
t.Fatalf("orphaning merge: %v", err)
}
if readOrphanMark(t, db, email) <= 0 {
t.Fatal("setup: expected the merge to mark the client")
}
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
xray.ClientTraffic{Email: email, Up: 6, Down: 6, Enable: true})
if orphanedAt := readOrphanMark(t, db, email); orphanedAt != 0 {
t.Fatalf("re-attached client kept its orphan mark: sync_orphaned_at=%d", orphanedAt)
}
backdateOrphanMark(t, db, email)
if reaped, err := clientSvc.ReapSyncOrphans(); err != nil || reaped != 0 {
t.Fatalf("reaped %d client(s) (err=%v) that the node still reports, want 0", reaped, err)
}
}
// The reaper is scoped to the node sweep. Orphans from any other cause carry no
// mark and keep their existing manual-cleanup semantics.
func TestReapSyncOrphansIgnoresUnmarkedOrphans(t *testing.T) {
db := initTrafficTestDB(t)
clientSvc := &ClientService{}
const email = "manual@x"
rec := &model.ClientRecord{Email: email, Enable: true, UUID: "44444444-4444-4444-4444-444444444444"}
if err := db.Create(rec).Error; err != nil {
t.Fatalf("create client: %v", err)
}
reaped, err := clientSvc.ReapSyncOrphans()
if err != nil {
t.Fatalf("reap: %v", err)
}
if reaped != 0 {
t.Fatalf("reaped %d unmarked orphan(s), want 0", reaped)
}
var surviving int64
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&surviving).Error; err != nil {
t.Fatalf("count clients: %v", err)
}
if surviving != 1 {
t.Fatalf("unmarked orphan was reaped: %d rows survive, want 1", surviving)
}
}
@@ -108,6 +108,45 @@ func TestClientUpdateDuplicateSubIDDoesNotRenameEmail(t *testing.T) {
}
}
func TestClientUpdateKeepsSharedSubIDEditable(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{
{Email: "a@node", ID: "aaaaaaaa-0000-0000-0000-000000000005", SubID: "sub-shared", Enable: true},
{Email: "b@node", ID: "aaaaaaaa-0000-0000-0000-000000000006", SubID: "sub-shared", Enable: true},
}
ib := mkInbound(t, 22004, model.VLESS, clientsSettings(t, source))
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
t.Fatalf("seed linkage: %v", err)
}
first := lookupClientRecord(t, "a@node")
if first.SubID != "sub-shared" || lookupClientRecord(t, "b@node").SubID != "sub-shared" {
t.Fatalf("seed did not produce a shared subId")
}
updated := source[0]
updated.TotalGB = 42
if _, err := svc.Update(inboundSvc, first.Id, updated); err != nil {
t.Fatalf("Update of a client whose subId is already shared: %v", err)
}
if got := lookupClientRecord(t, "a@node").TotalGB; got != 42 {
t.Fatalf("totalGB after update = %d, want 42", got)
}
omitted := source[0]
omitted.SubID = ""
omitted.TotalGB = 43
if _, err := svc.Update(inboundSvc, first.Id, omitted); err != nil {
t.Fatalf("Update with subId omitted entirely: %v", err)
}
other := lookupClientRecord(t, "b@node")
if other.SubID != "sub-shared" {
t.Fatalf("other client subId = %q, want %q", other.SubID, "sub-shared")
}
}
func mustInboundSettings(t *testing.T, inboundSvc *InboundService, id int) string {
t.Helper()
ib, err := inboundSvc.GetInbound(id)
+29 -9
View File
@@ -46,11 +46,23 @@ func wireguardAllocationBase(used []string, fallback string) string {
return fallback
}
const wireguardPoolFloorBits = 16
// allocateWireguardAddress returns the first free single-host address in base
// (suffixed /32 for an IPv4 base, /128 for IPv6) that is not already present
// in used. The server holds the first host (.1 / ::1), so allocation starts
// at the second host (.2 / ::2).
func allocateWireguardAddress(used []string, base string) (string, error) {
// at the second host (.2 / ::2). When allowWidening is set and base is an
// IPv4 prefix narrower than wireguardPoolFloorBits, falls back to a wider
// /16 scope once the configured base is exhausted, so a small pool doesn't
// hard-fail while a wider address space is genuinely available.
//
// allowWidening must be false for AmneziaWG: unlike plain WireGuard, an
// AmneziaWG peer's address must fall inside the kernel interface's own
// configured Address subnet to be routable at all, so silently handing out
// an address from a wider scope would produce an address the interface can
// never actually reach — better to fail loudly on pool exhaustion than hand
// out a client config that will never connect.
func allocateWireguardAddress(used []string, base string, allowWidening bool) (string, error) {
if base == "" {
base = defaultWireguardBase
}
@@ -68,14 +80,22 @@ func allocateWireguardAddress(used []string, base string) (string, error) {
taken[a] = struct{}{}
}
}
addr := prefix.Masked().Addr().Next().Next()
for prefix.Contains(addr) {
if _, ok := taken[addr]; !ok {
return addr.String() + "/" + hostBits, nil
scopes := []netip.Prefix{prefix}
if allowWidening && prefix.Addr().Is4() && prefix.Bits() > wireguardPoolFloorBits {
if wider, wErr := prefix.Addr().Prefix(wireguardPoolFloorBits); wErr == nil {
scopes = append(scopes, wider)
}
addr = addr.Next()
}
return "", common.NewError("wireguard: no free address available in", base)
for _, scope := range scopes {
addr := scope.Masked().Addr().Next().Next()
for scope.Contains(addr) {
if _, ok := taken[addr]; !ok {
return addr.String() + "/" + hostBits, nil
}
addr = addr.Next()
}
}
return "", common.NewError("wireguard: no free address available in", scopes[len(scopes)-1].String())
}
// normalizeWireguardAllowedIPs validates user-supplied allowedIPs entries and
@@ -148,7 +168,7 @@ func defaultWireguardClients(existing, clients []model.Client, interfaceClients
c.PublicKey = pub
}
if len(c.AllowedIPs) == 0 {
addr, err := allocateWireguardAddress(used, base)
addr, err := allocateWireguardAddress(used, base, true)
if err != nil {
return err
}
+52 -2
View File
@@ -1,6 +1,7 @@
package service
import (
"fmt"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -20,11 +21,12 @@ func TestAllocateWireguardAddress(t *testing.T) {
{name: "fills gap", used: []string{"10.0.0.3/32", "10.0.0.4/32"}, base: "10.0.0.0/24", want: "10.0.0.2/32"},
{name: "ignores catch-all", used: []string{"0.0.0.0/0", "::/0"}, base: "10.0.0.0/24", want: "10.0.0.2/32"},
{name: "default base when empty", used: nil, base: "", want: "10.0.0.2/32"},
{name: "exhausted /30", used: []string{"10.9.0.2/32", "10.9.0.3/32"}, base: "10.9.0.0/30", err: true},
{name: "full ipv4 scope widens instead of failing", used: []string{"10.9.0.2/32", "10.9.0.3/32"}, base: "10.9.0.0/30", want: "10.9.0.4/32"},
{name: "exhausted ipv6 scope errors", used: []string{"fd00::2/128", "fd00::3/128"}, base: "fd00::/126", err: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := allocateWireguardAddress(tt.used, tt.base)
got, err := allocateWireguardAddress(tt.used, tt.base, true)
if tt.err {
if err == nil {
t.Fatalf("expected error, got %q", got)
@@ -130,6 +132,54 @@ func TestDefaultWireguardClientsHonorsExistingSubnet(t *testing.T) {
}
}
func TestAllocateWireguardAddressWidensPastFullSlash24(t *testing.T) {
used := make([]string, 0, 254)
for i := 2; i <= 255; i++ {
used = append(used, fmt.Sprintf("10.0.0.%d/32", i))
}
got, err := allocateWireguardAddress(used, "10.0.0.0/24", true)
if err != nil {
t.Fatalf("allocate with a full /24: %v", err)
}
if got != "10.0.1.0/32" {
t.Fatalf("address after a full /24 = %q, want 10.0.1.0/32", got)
}
used = append(used, got)
next, err := allocateWireguardAddress(used, "10.0.0.0/24", true)
if err != nil {
t.Fatalf("allocate after widening: %v", err)
}
if next != "10.0.1.1/32" {
t.Fatalf("second widened address = %q, want 10.0.1.1/32", next)
}
}
func TestAllocateWireguardAddressFillsItsOwnSlash24First(t *testing.T) {
got, err := allocateWireguardAddress([]string{"172.16.0.2/32"}, "172.16.0.0/24", true)
if err != nil {
t.Fatalf("allocateWireguardAddress: %v", err)
}
if got != "172.16.0.3/32" {
t.Fatalf("address = %q, want 172.16.0.3/32 — the inbound's own /24 comes first", got)
}
}
func TestAllocateWireguardAddressWithoutWideningFailsClosed(t *testing.T) {
// AmneziaWG's caller passes allowWidening=false: an address outside the
// kernel interface's own configured subnet would never actually route,
// so exhausting the pool must error instead of silently handing out an
// address from a wider scope (unlike plain WireGuard's allowWidening=true).
used := make([]string, 0, 254)
for i := 2; i <= 255; i++ {
used = append(used, fmt.Sprintf("10.0.0.%d/32", i))
}
if _, err := allocateWireguardAddress(used, "10.0.0.0/24", false); err == nil {
t.Fatal("expected an error on a full /24 with widening disabled, got nil")
}
}
func TestDefaultWireguardClientsAllocatesDistinctIPs(t *testing.T) {
clients := []model.Client{{Email: "x@wg"}, {Email: "y@wg"}}
ifaces := []any{map[string]any{"email": "x@wg"}, map[string]any{"email": "y@wg"}}
@@ -0,0 +1,160 @@
package service
import (
"encoding/json"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func streamWithNoiseItem(t *testing.T, item map[string]any) map[string]any {
t.Helper()
return map[string]any{
"network": "tcp",
"finalmask": map[string]any{
"udp": []any{map[string]any{
"type": "noise",
"settings": map[string]any{
"reset": "60",
"noise": []any{item},
},
}},
},
}
}
func noiseItem(t *testing.T, stream map[string]any) map[string]any {
t.Helper()
finalmask, _ := stream["finalmask"].(map[string]any)
udp, _ := finalmask["udp"].([]any)
mask, _ := udp[0].(map[string]any)
settings, _ := mask["settings"].(map[string]any)
noise, _ := settings["noise"].([]any)
item, _ := noise[0].(map[string]any)
return item
}
// TestDropEmptyRandPacketsClearsEditorResidue is the regression for the mask
// editor writing packet:[] alongside a rand. xray-core counts the empty array
// as a packet and refuses the config, which keeps every inbound offline.
func TestDropEmptyRandPacketsClearsEditorResidue(t *testing.T) {
stream := streamWithNoiseItem(t, map[string]any{
"type": "array",
"rand": "1-8192",
"packet": []any{},
"delay": "5",
})
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 1 {
t.Fatalf("cleared = %d, want 1", cleared)
}
item := noiseItem(t, stream)
if _, present := item["packet"]; present {
t.Fatalf("packet survived: %#v", item)
}
if item["rand"] != "1-8192" || item["delay"] != "5" {
t.Fatalf("healing changed the mask: %#v", item)
}
}
func TestDropEmptyRandPacketsLeavesRealPacketsAlone(t *testing.T) {
tests := []struct {
name string
item map[string]any
}{
{"packet without a rand", map[string]any{"type": "array", "packet": []any{1.0, 2.0}, "rand": 0.0}},
{"empty packet without a rand", map[string]any{"type": "array", "packet": []any{}}},
{"empty packet with a zero rand", map[string]any{"type": "array", "packet": []any{}, "rand": 0.0}},
{"empty packet with a zero range", map[string]any{"type": "array", "packet": []any{}, "rand": "0-0"}},
{"string packet", map[string]any{"type": "str", "packet": "ping", "rand": "1-10"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stream := streamWithNoiseItem(t, tt.item)
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 0 {
t.Fatalf("cleared = %d, want the item left alone", cleared)
}
if _, present := noiseItem(t, stream)["packet"]; !present {
t.Fatal("packet was dropped")
}
})
}
}
// TestDropEmptyRandPacketsReachesNestedItems covers header-custom, whose items
// sit two arrays deep and are subject to the same exclusive-kind rule.
func TestDropEmptyRandPacketsReachesNestedItems(t *testing.T) {
stream := map[string]any{
"finalmask": map[string]any{
"tcp": []any{map[string]any{
"type": "header-custom",
"settings": map[string]any{
"clients": []any{[]any{map[string]any{"type": "array", "rand": 64.0, "packet": []any{}}}},
"servers": []any{[]any{map[string]any{"type": "array", "rand": 32.0, "packet": []any{}}}},
},
}},
},
}
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 2 {
t.Fatalf("cleared = %d, want 2", cleared)
}
}
func TestDropEmptyRandPacketsIgnoresMissingFinalMask(t *testing.T) {
stream := map[string]any{"network": "tcp"}
if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 0 {
t.Fatalf("cleared = %d, want 0", cleared)
}
}
// TestHealedConfigsBuildInXray closes the loop on both heals: the rows xray
// refuses outright must build once the panel has healed them.
func TestHealedConfigsBuildInXray(t *testing.T) {
t.Run("hysteria v1 row", func(t *testing.T) {
in := model.Inbound{
Protocol: model.Hysteria,
Port: 36715,
Listen: "127.0.0.1",
Tag: "in-hysteria",
Settings: `{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`,
StreamSettings: `{"network":"hysteria","hysteriaSettings":{"version":1,"udpIdleTimeout":60}}`,
}
raw, err := json.Marshal(in.GenXrayInboundConfig())
if err != nil {
t.Fatalf("marshal generated inbound: %v", err)
}
var healed map[string]any
if err := json.Unmarshal(raw, &healed); err != nil {
t.Fatalf("decode generated inbound: %v", err)
}
assertXrayAccepts(t, "the healed hysteria inbound", buildGoldenInbound(t, healed))
var unhealed map[string]any
if err := json.Unmarshal([]byte(`{
"tag":"in-hysteria","listen":"127.0.0.1","port":36715,"protocol":"hysteria",
"settings":`+in.Settings+`,"streamSettings":`+in.StreamSettings+`}`), &unhealed); err != nil {
t.Fatalf("decode raw inbound: %v", err)
}
if err := buildGoldenInbound(t, unhealed); err == nil {
t.Fatal("the unhealed v1 row is expected to be refused; the heal is what makes it buildable")
}
})
t.Run("noise item with an empty packet", func(t *testing.T) {
item := map[string]any{"type": "array", "rand": "1-8192", "packet": []any{}, "delay": "5"}
stream := streamWithNoiseItem(t, item)
inbound := map[string]any{
"tag": "in-vless", "listen": "127.0.0.1", "port": 8443, "protocol": "vless",
"settings": map[string]any{"clients": []any{}, "decryption": "none"},
"streamSettings": stream,
}
if err := buildGoldenInbound(t, inbound); err == nil {
t.Fatal("xray-core is expected to refuse a packet and a rand on one item")
}
dropEmptyRandPackets(stream["finalmask"])
inbound["streamSettings"] = stream
assertXrayAccepts(t, "the healed noise mask", buildGoldenInbound(t, inbound))
})
}
+69 -2
View File
@@ -2,6 +2,7 @@ package service
import (
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -70,7 +71,7 @@ func TestDepletedCond_ProbeGuard(t *testing.T) {
// No global rows: the cross-panel EXISTS branch is skipped (#5392), but a
// client over its local quota is still disabled.
if got := depletedCond(db); got != depletedClientsCondLocal {
if got, _ := depletedCond(db); got != depletedClientsCondLocal {
t.Fatalf("empty globals must use the local-only predicate")
}
seedClientRow(t, "local-cap", 1, 600, 600, 1000)
@@ -85,11 +86,77 @@ func TestDepletedCond_ProbeGuard(t *testing.T) {
if err := svc.AcceptGlobalTraffic("master-a", []*xray.ClientTraffic{{Email: "local-cap", Up: 1, Down: 1}}); err != nil {
t.Fatalf("AcceptGlobalTraffic: %v", err)
}
if got := depletedCond(db); got != depletedClientsCond {
if got, _ := depletedCond(db); got != depletedClientsCond {
t.Fatalf("with globals present the cross-panel predicate must be used")
}
}
func TestStaleGlobalTraffic_Ignored(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
staleAt := time.Now().Add(-globalTrafficFreshWindow - time.Hour).UnixMilli()
seedStaleGlobal := func(t *testing.T, guid, email string, up, down int64) {
t.Helper()
if err := svc.AcceptGlobalTraffic(guid, []*xray.ClientTraffic{{Email: email, Up: up, Down: down}}); err != nil {
t.Fatalf("AcceptGlobalTraffic(%s): %v", guid, err)
}
if err := db.Model(&model.ClientGlobalTraffic{}).
Where("master_guid = ? AND email = ?", guid, email).
Update("updated_at", staleAt).Error; err != nil {
t.Fatalf("age row: %v", err)
}
}
t.Run("stale row alone neither enforces nor selects the cross-panel predicate", func(t *testing.T) {
seedClientRow(t, "cap", 1, 100, 100, 1000)
seedStaleGlobal(t, "dead-master", "cap", 1000, 900)
if got, _ := depletedCond(db); got != depletedClientsCondLocal {
t.Fatalf("only stale globals must fall back to the local-only predicate")
}
if _, count, err := svc.disableInvalidClients(db); err != nil {
t.Fatalf("disableInvalidClients: %v", err)
} else if count != 0 {
t.Fatalf("stale global usage must not disable a client, disabled %d", count)
}
if got := readTraffic(t, db, "cap"); !got.Enable {
t.Error("client within its local quota must stay enabled")
}
})
t.Run("stale row does not inflate the displayed total", func(t *testing.T) {
rows := []*xray.ClientTraffic{{Email: "cap", Up: 100, Down: 100}}
overlayGlobalTraffic(db, rows)
if rows[0].Up != 100 || rows[0].Down != 100 {
t.Errorf("stale global must not overlay display counters, got up=%d down=%d", rows[0].Up, rows[0].Down)
}
})
t.Run("a live master still enforces alongside the stale row", func(t *testing.T) {
if err := svc.AcceptGlobalTraffic("live-master", []*xray.ClientTraffic{{Email: "cap", Up: 1, Down: 1}}); err != nil {
t.Fatalf("AcceptGlobalTraffic: %v", err)
}
if got, _ := depletedCond(db); got != depletedClientsCond {
t.Fatalf("a fresh global row must select the cross-panel predicate")
}
if _, count, err := svc.disableInvalidClients(db); err != nil {
t.Fatalf("disableInvalidClients: %v", err)
} else if count != 0 {
t.Fatalf("the live master reports usage well under quota, disabled %d", count)
}
if err := svc.AcceptGlobalTraffic("live-master", []*xray.ClientTraffic{{Email: "cap", Up: 600, Down: 500}}); err != nil {
t.Fatalf("AcceptGlobalTraffic: %v", err)
}
if _, count, err := svc.disableInvalidClients(db); err != nil {
t.Fatalf("disableInvalidClients: %v", err)
} else if count != 1 {
t.Fatalf("fresh cross-panel depletion must disable the client, disabled %d", count)
}
})
}
func TestGlobalUsage_DisablesClient(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
@@ -0,0 +1,350 @@
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")
}
+272 -57
View File
@@ -8,10 +8,13 @@ import (
"errors"
"fmt"
"net"
"regexp"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -32,6 +35,13 @@ type InboundService struct {
fallbackService FallbackService
}
func normalizeTrafficResetDay(day int) int {
if day < 1 {
return 1
}
return min(day, 31)
}
func normalizeInboundShareAddrStrategy(strategy string) string {
strategy = strings.TrimSpace(strategy)
switch strategy {
@@ -618,6 +628,188 @@ func validateFinalMaskRealityCombo(streamSettings string) error {
return common.NewError("Finalmask is not supported with REALITY security — it crashes Xray-core on the first connection (see XTLS/Xray-core#6453). Remove the finalmask configuration or switch security to tls/none.")
}
var xmcProfileUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9_]{3,16}$`)
// xmcMaskProfilesComplete reports whether an xmc finalmask carries the signed
// Minecraft session profiles xray-core has required since v26.7.28 (#6487).
// The core replaced the old `usernames` string list with `profiles` objects
// and removed the "default to Dream when empty" fallback, so a mask still on
// the legacy shape — or one whose profiles are incomplete — now fails
// conf.XMC.Build() and takes the entire config down with it rather than
// degrading that one inbound.
//
// The texture fields are a signed blob only Mojang's session server can issue
// (resolve the UUID by username, then fetch the profile with unsigned=false),
// so the panel cannot synthesize a valid profile from a legacy username; an
// incomplete mask can only be reported or dropped.
func xmcMaskProfilesComplete(mask map[string]any) bool {
settings, ok := mask["settings"].(map[string]any)
if !ok {
return false
}
profiles, _ := settings["profiles"].([]any)
if len(profiles) == 0 {
return false
}
for _, entry := range profiles {
profile, ok := entry.(map[string]any)
if !ok {
return false
}
username, _ := profile["username"].(string)
if !xmcProfileUsernamePattern.MatchString(username) {
return false
}
id, _ := profile["uuid"].(string)
if _, err := uuid.Parse(id); err != nil {
return false
}
if value, _ := profile["texturesValue"].(string); value == "" {
return false
}
if signature, _ := profile["texturesSignature"].(string); signature == "" {
return false
}
}
return true
}
// isIncompleteXmcMask reports whether a finalmask.tcp entry is an xmc mask
// xray-core would refuse to build.
func isIncompleteXmcMask(entry any) bool {
mask, ok := entry.(map[string]any)
if !ok {
return false
}
if maskType, _ := mask["type"].(string); maskType != "xmc" {
return false
}
return !xmcMaskProfilesComplete(mask)
}
// incompleteXmcMaskCount counts the stream's xmc finalmask entries that
// xray-core would refuse to build.
func incompleteXmcMaskCount(stream map[string]any) int {
finalmask, ok := stream["finalmask"].(map[string]any)
if !ok {
return 0
}
tcp, _ := finalmask["tcp"].([]any)
count := 0
for _, entry := range tcp {
if isIncompleteXmcMask(entry) {
count++
}
}
return count
}
// stripIncompleteXmcMasks removes every xmc finalmask entry xray-core would
// refuse to build, returning how many were dropped, and clears the finalmask
// object once nothing is left in it.
//
// AddInbound and UpdateInbound reject an incomplete mask at save time, but a
// row that never went through those paths — an upgrade from a panel predating
// v26.7.28, node sync, a restored backup, a direct DB edit — would otherwise
// fail the whole config build and keep every other inbound offline too.
// Dropping only the offending mask degrades that one inbound instead, which
// the accompanying warning tells the admin to reconfigure.
func stripIncompleteXmcMasks(stream map[string]any) int {
finalmask, ok := stream["finalmask"].(map[string]any)
if !ok {
return 0
}
tcp, _ := finalmask["tcp"].([]any)
if len(tcp) == 0 {
return 0
}
kept := make([]any, 0, len(tcp))
dropped := 0
for _, entry := range tcp {
if isIncompleteXmcMask(entry) {
dropped++
continue
}
kept = append(kept, entry)
}
if dropped == 0 {
return 0
}
if len(kept) == 0 {
delete(finalmask, "tcp")
} else {
finalmask["tcp"] = kept
}
if len(finalmask) == 0 {
delete(stream, "finalmask")
}
return dropped
}
// dropEmptyRandPackets removes the leftover empty "packet" from finalmask
// items that also carry a rand, and reports how many it cleared.
//
// xray-core treats even an empty array as a packet, and every item kind is
// exclusive: noise refuses "len(item.Packet) > 0 && item.Rand.To > 0" and
// header-custom refuses "exactly one item kind must be set". Either error
// fails the whole config build, so one such item keeps every inbound offline.
// The panel's mask editor wrote that pair whenever an item was switched to the
// rand-driven array kind, so stored rows carry it; clearing an empty packet
// changes nothing about the mask the admin configured.
func dropEmptyRandPackets(node any) int {
switch value := node.(type) {
case map[string]any:
cleared := 0
if packet, ok := value["packet"].([]any); ok && len(packet) == 0 && randIsSet(value["rand"]) {
delete(value, "packet")
cleared++
}
for _, child := range value {
cleared += dropEmptyRandPackets(child)
}
return cleared
case []any:
cleared := 0
for _, child := range value {
cleared += dropEmptyRandPackets(child)
}
return cleared
default:
return 0
}
}
// randIsSet reports whether a finalmask item's rand selects a random packet.
// It is a number on header-custom items and a dash-range string on noise ones.
func randIsSet(value any) bool {
switch rand := value.(type) {
case float64:
return rand > 0
case string:
return rand != "" && rand != "0" && rand != "0-0"
default:
return false
}
}
// validateFinalMaskXmcProfiles rejects an xmc finalmask without complete
// profiles at save time, so the admin gets a targeted error instead of a core
// that refuses to start (or, after GetXrayConfig heals it, an inbound quietly
// serving without the obfuscation they configured).
func validateFinalMaskXmcProfiles(streamSettings string) error {
if streamSettings == "" {
return nil
}
var stream map[string]any
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
return nil
}
if incompleteXmcMaskCount(stream) == 0 {
return nil
}
return common.NewError("XMC finalmask requires at least one complete Minecraft profile — each needs a username (3-16 of A-Z a-z 0-9 _), a UUID, and both texture fields from Mojang's session server (XTLS/Xray-core#6487). Complete the profiles or remove the XMC mask.")
}
// normalizeMtprotoSecret rebuilds every mtproto client's FakeTLS secret so it is
// always valid before the row is persisted, and drops the vestigial inbound-level
// secret and adTag: MTProto is multi-client, so mtg and every share link read
@@ -746,11 +938,15 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet
// Returns the created inbound, whether Xray needs restart, and any error.
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
inbound.Id = 0
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
// Normalize streamSettings based on protocol
s.normalizeStreamSettings(inbound)
if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
return inbound, false, err
}
if err := validateFinalMaskXmcProfiles(inbound.StreamSettings); err != nil {
return inbound, false, err
}
s.normalizeMtprotoSecret(inbound)
if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil {
return inbound, false, err
@@ -914,7 +1110,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
payload := inbound
pushable := true
if inbound.Protocol == model.MTProto {
if built, bErr := s.buildRuntimeInboundForAPI(tx, inbound); bErr == nil {
if built, bErr := s.buildInboundForLocalRuntime(tx, inbound); bErr == nil {
payload = built
} else {
logger.Debug("Unable to prepare runtime inbound config:", bErr)
@@ -1162,7 +1358,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
return needRestart, nil
}
runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
runtimeInbound, err := s.buildInboundForLocalRuntime(db, inbound)
if err != nil {
logger.Debug("SetInboundEnable: build runtime config failed:", err)
return true, nil
@@ -1175,11 +1371,15 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
}
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
// Normalize streamSettings based on protocol
s.normalizeStreamSettings(inbound)
if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
return inbound, false, err
}
if err := validateFinalMaskXmcProfiles(inbound.StreamSettings); err != nil {
return inbound, false, err
}
s.normalizeMtprotoSecret(inbound)
if err := s.normalizeAmneziaWGSettings(inbound); err != nil {
return inbound, false, err
@@ -1307,6 +1507,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
oldInbound.Enable = inbound.Enable
oldInbound.ExpiryTime = inbound.ExpiryTime
oldInbound.TrafficReset = inbound.TrafficReset
oldInbound.TrafficResetDay = inbound.TrafficResetDay
oldInbound.Listen = inbound.Listen
oldInbound.Port = inbound.Port
oldInbound.Protocol = inbound.Protocol
@@ -1348,7 +1549,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
payload := oldInbound
pushable := true
if inbound.Enable {
if built, err2 := s.buildRuntimeInboundForAPI(tx, oldInbound); err2 == nil {
if built, err2 := s.buildInboundForLocalRuntime(tx, oldInbound); err2 == nil {
payload = built
} else {
logger.Debug("Unable to prepare runtime inbound config:", err2)
@@ -1374,7 +1575,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
var runtimeInbound *model.Inbound
if inbound.Enable {
var err2 error
runtimeInbound, err2 = s.buildRuntimeInboundForAPI(tx, oldInbound)
runtimeInbound, err2 = s.buildInboundForLocalRuntime(tx, oldInbound)
if err2 != nil {
logger.Debug("Unable to prepare runtime inbound config:", err2)
needRestart = true
@@ -1445,81 +1646,95 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
return inbound, needRestart, nil
}
func (s *InboundService) buildRuntimeInboundForAPI(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
// A node mirrors this payload into its own DB, so every client must survive:
// filtering one out makes the node delete it, and the master then mirrors that.
func (s *InboundService) buildInboundForNodePush(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
if inbound == nil {
return nil, fmt.Errorf("inbound is nil")
}
runtimeInbound := *inbound
built := *inbound
settings := map[string]any{}
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
return nil, err
}
mutated := false
if clients, ok := settings["clients"].([]any); ok {
var clientStats []xray.ClientTraffic
err := tx.Model(xray.ClientTraffic{}).
Where("inbound_id = ?", inbound.Id).
Select("email", "enable").
Find(&clientStats).Error
if err != nil {
return nil, err
}
if !inboundCanHostFallbacks(inbound) {
return &built, nil
}
fallbacks, err := s.fallbackService.BuildFallbacksJSON(tx, inbound.Id)
if err != nil {
return nil, err
}
if len(fallbacks) == 0 {
return &built, nil
}
generic := make([]any, 0, len(fallbacks))
for _, f := range fallbacks {
generic = append(generic, f)
}
settings["fallbacks"] = generic
enableMap := make(map[string]bool, len(clientStats))
for _, clientTraffic := range clientStats {
enableMap[clientTraffic.Email] = clientTraffic.Enable
}
modifiedSettings, mErr := json.MarshalIndent(settings, "", " ")
if mErr != nil {
return nil, mErr
}
built.Settings = string(modifiedSettings)
return &built, nil
}
finalClients := make([]any, 0, len(clients))
for _, client := range clients {
c, ok := client.(map[string]any)
if !ok {
continue
}
email, _ := c["email"].(string)
if enable, exists := enableMap[email]; exists && !enable {
continue
}
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
continue
}
finalClients = append(finalClients, c)
}
settings["clients"] = finalClients
mutated = true
// Strips disabled clients on top of the node payload. Safe only because the
// target here is an in-memory Xray/mtg config, not another panel's database.
func (s *InboundService) buildInboundForLocalRuntime(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
built, err := s.buildInboundForNodePush(tx, inbound)
if err != nil {
return nil, err
}
if inboundCanHostFallbacks(inbound) {
fallbacks, fbErr := s.fallbackService.BuildFallbacksJSON(tx, inbound.Id)
if fbErr != nil {
return nil, fbErr
}
if len(fallbacks) > 0 {
generic := make([]any, 0, len(fallbacks))
for _, f := range fallbacks {
generic = append(generic, f)
}
settings["fallbacks"] = generic
mutated = true
}
settings := map[string]any{}
if err := json.Unmarshal([]byte(built.Settings), &settings); err != nil {
return nil, err
}
clients, ok := settings["clients"].([]any)
if !ok {
return built, nil
}
if !mutated {
return &runtimeInbound, nil
var clientStats []xray.ClientTraffic
if err := tx.Model(xray.ClientTraffic{}).
Where("inbound_id = ?", built.Id).
Select("email", "enable").
Find(&clientStats).Error; err != nil {
return nil, err
}
enableMap := make(map[string]bool, len(clientStats))
for _, clientTraffic := range clientStats {
enableMap[clientTraffic.Email] = clientTraffic.Enable
}
finalClients := make([]any, 0, len(clients))
for _, client := range clients {
c, ok := client.(map[string]any)
if !ok {
continue
}
email, _ := c["email"].(string)
if enable, exists := enableMap[email]; exists && !enable {
continue
}
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
continue
}
finalClients = append(finalClients, c)
}
settings["clients"] = finalClients
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return nil, err
}
runtimeInbound := *built
runtimeInbound.Settings = string(modifiedSettings)
return &runtimeInbound, nil
}
@@ -0,0 +1,108 @@
package service
import (
"encoding/json"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// TestAPIUserFromClientCarriesShadowsocksCipher pins what the renew path must
// hand the runtime API. A shadowsocks client object holds no cipher of its own,
// and xray's legacy and 2022 inbounds take different account types that they
// cast without checking — an account built for the wrong one panics the core.
func TestAPIUserFromClientCarriesShadowsocksCipher(t *testing.T) {
client := map[string]any{"email": "a@x", "password": "pw", "method": "aes-256-gcm"}
user := apiUserFromClient(client, "aes-256-gcm")
if got := user["cipher"]; got != "aes-256-gcm" {
t.Fatalf("cipher = %v, want the inbound method", got)
}
if _, polluted := client["cipher"]; polluted {
t.Fatal("the stored client object was mutated; the API-only cipher would be persisted into the inbound settings")
}
user["email"] = "changed@x"
if client["email"] != "a@x" {
t.Fatal("the API user shares storage with the stored client object")
}
}
func TestAPIUserFromClientWithoutCipher(t *testing.T) {
client := map[string]any{"email": "a@x", "id": "11111111-1111-1111-1111-111111111111"}
user := apiUserFromClient(client, "")
if _, ok := user["cipher"]; ok {
t.Fatal("a non-shadowsocks client must not gain a cipher key")
}
if user["id"] != client["id"] {
t.Fatalf("id = %v, want it copied from the client", user["id"])
}
}
// TestAutoRenewShadowsocksKeepsSettingsClean renews a shadowsocks client and
// checks the inbound settings the panel writes back: the cipher the API needs
// must not leak into a stored client object, where it would end up in the
// generated xray config as a per-user key.
func TestAutoRenewShadowsocksKeepsSettingsClean(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
past := time.Now().Add(-48 * time.Hour).UnixMilli()
clients := []model.Client{
{Email: "ss@x", Password: "pw", Enable: false, Reset: 30, ExpiryTime: past},
}
settings := map[string]any{
"method": "aes-256-gcm",
"network": "tcp,udp",
"clients": []map[string]any{
{"email": "ss@x", "password": "pw", "method": "aes-256-gcm", "enable": false, "expiryTime": past, "reset": 30},
},
}
raw, err := json.MarshalIndent(settings, "", " ")
if err != nil {
t.Fatal(err)
}
ib := mkInbound(t, 30011, model.Shadowsocks, string(raw))
if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
if err := db.Create(&[]xray.ClientTraffic{
{InboundId: ib.Id, Email: "ss@x", Enable: false, Up: 10, Down: 20, Reset: 30, ExpiryTime: past},
}).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
if _, count, err := svc.autoRenewClients(db); err != nil {
t.Fatalf("autoRenewClients: %v", err)
} else if count != 1 {
t.Fatalf("renewed count = %d, want 1", count)
}
var stored model.Inbound
if err := db.Where("id = ?", ib.Id).First(&stored).Error; err != nil {
t.Fatalf("read inbound: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal([]byte(stored.Settings), &parsed); err != nil {
t.Fatalf("unmarshal stored settings: %v", err)
}
storedClients, _ := parsed["clients"].([]any)
if len(storedClients) != 1 {
t.Fatalf("stored clients = %d, want 1", len(storedClients))
}
client, _ := storedClients[0].(map[string]any)
if _, polluted := client["cipher"]; polluted {
t.Fatalf("the renewed client was persisted with an API-only cipher key: %+v", client)
}
if client["method"] != "aes-256-gcm" {
t.Fatalf("the client's method was lost: %+v", client)
}
if enabled, _ := client["enable"].(bool); !enabled {
t.Fatalf("the renewed client was not re-enabled: %+v", client)
}
}
+32 -20
View File
@@ -19,7 +19,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
now := time.Now().Unix() * 1000
needRestart := false
if p != nil {
if process := currentXrayProcess(); process != nil {
var tags []string
err := tx.Table("inbounds").
Select("inbounds.tag").
@@ -28,7 +28,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
if err != nil {
return false, 0, err
}
_ = s.xrayApi.Init(p.GetAPIPort())
_ = s.xrayApi.Init(process.GetAPIPort())
for _, tag := range tags {
err1 := s.xrayApi.DelInbound(tag)
if err1 == nil {
@@ -49,46 +49,58 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
return needRestart, count, err
}
const globalTrafficFreshWindow = 24 * time.Hour
func globalTrafficFreshSince() int64 {
return time.Now().Add(-globalTrafficFreshWindow).UnixMilli()
}
// depletedClientsCond matches clients that exhausted their quota or expired.
// Besides the local counters it also trips on the cross-panel usage a master
// pushed into client_global_traffics — that's what lets a node cut a client
// whose combined usage exceeds the quota even though the local share doesn't
// (placeholders: now).
// whose combined usage exceeds the quota even though the local share doesn't.
// Only rows a master refreshed recently count (placeholders: now, freshSince).
const depletedClientsCond = `((total > 0 AND up + down >= total)
OR (expiry_time > 0 AND expiry_time <= ?)
OR (total > 0 AND EXISTS (
SELECT 1 FROM client_global_traffics g
WHERE g.email = client_traffics.email AND g.up + g.down >= client_traffics.total
WHERE g.email = client_traffics.email
AND g.updated_at >= ?
AND g.up + g.down >= client_traffics.total
)))`
// depletedClientsCondLocal is depletedClientsCond without the cross-panel
// client_global_traffics check. The EXISTS branch is a correlated subquery that
// turns every traffic poll into a full client_traffics scan; on a panel no
// master pushes to (the common case) client_global_traffics is empty, so the
// branch can never match and is pure CPU cost (#5392).
// branch can never match and is pure CPU cost (#5392). Placeholders: now.
const depletedClientsCondLocal = `((total > 0 AND up + down >= total)
OR (expiry_time > 0 AND expiry_time <= ?))`
// depletedCond returns the local-only predicate unless this panel actually
// holds global-traffic rows, in which case the cross-panel EXISTS check is
// needed to enforce combined quota. Both variants take the same single
// expiry_time placeholder, so callers pass identical args either way.
func depletedCond(tx *gorm.DB) string {
// depletedCond returns the predicate matching depleted clients together with
// the arguments it binds. The local-only variant is used unless this panel
// holds a global-traffic row a master still refreshes, in which case the
// cross-panel EXISTS check is needed to enforce combined quota.
func depletedCond(tx *gorm.DB) (string, []any) {
now := time.Now().UnixMilli()
freshSince := globalTrafficFreshSince()
var probe int64
if err := tx.Model(&model.ClientGlobalTraffic{}).Limit(1).Count(&probe).Error; err == nil && probe > 0 {
return depletedClientsCond
err := tx.Model(&model.ClientGlobalTraffic{}).
Where("updated_at >= ?", freshSince).
Limit(1).Count(&probe).Error
if err == nil && probe > 0 {
return depletedClientsCond, []any{now, freshSince}
}
return depletedClientsCondLocal
return depletedClientsCondLocal, []any{now}
}
func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error) {
now := time.Now().Unix() * 1000
needRestart := false
cond := depletedCond(tx)
cond, condArgs := depletedCond(tx)
var depletedRows []xray.ClientTraffic
err := tx.Model(xray.ClientTraffic{}).
Where(cond+" AND enable = ?", now, true).
Where(cond+" AND enable = ?", append(condArgs, true)...).
Find(&depletedRows).Error
if err != nil {
return false, 0, err
@@ -141,8 +153,8 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
}
}
if p != nil && len(localTargets) > 0 {
_ = s.xrayApi.Init(p.GetAPIPort())
if process := currentXrayProcess(); process != nil && len(localTargets) > 0 {
_ = s.xrayApi.Init(process.GetAPIPort())
for _, t := range localTargets {
err1 := s.xrayApi.RemoveUser(t.Tag, t.Email)
if err1 == nil {
@@ -185,7 +197,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
if len(depletedEmails) > 0 {
if err := tx.Model(&model.ClientRecord{}).
Where("email IN ?", depletedEmails).
Updates(map[string]any{"enable": false, "updated_at": now}).Error; err != nil {
Updates(map[string]any{"enable": false, "updated_at": time.Now().UnixMilli()}).Error; err != nil {
logger.Warning("disableInvalidClients update clients.enable:", err)
}
}
@@ -31,9 +31,9 @@ func TestBuildRuntimeInboundForAPI_InjectsFallbacks(t *testing.T) {
t.Fatalf("seed fallback: %v", err)
}
runtimeIb, err := svc.buildRuntimeInboundForAPI(db, master)
runtimeIb, err := svc.buildInboundForLocalRuntime(db, master)
if err != nil {
t.Fatalf("buildRuntimeInboundForAPI: %v", err)
t.Fatalf("buildInboundForLocalRuntime: %v", err)
}
var settings map[string]any
@@ -68,9 +68,9 @@ func TestBuildRuntimeInboundForAPI_NoFallbacksOnWsInbound(t *testing.T) {
t.Fatalf("seed fallback: %v", err)
}
runtimeIb, err := svc.buildRuntimeInboundForAPI(db, ib)
runtimeIb, err := svc.buildInboundForLocalRuntime(db, ib)
if err != nil {
t.Fatalf("buildRuntimeInboundForAPI: %v", err)
t.Fatalf("buildInboundForLocalRuntime: %v", err)
}
if strings.Contains(runtimeIb.Settings, "fallbacks") {
t.Fatalf("ws inbound must not receive fallbacks: %s", runtimeIb.Settings)
@@ -0,0 +1,204 @@
package service
import (
"encoding/json"
"testing"
"github.com/xtls/xray-core/infra/conf"
)
const completeXmcProfile = `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`
func TestValidateFinalMaskXmcProfiles(t *testing.T) {
tests := []struct {
name string
streamSettings string
wantErr bool
}{
{
name: "empty streamSettings",
streamSettings: "",
wantErr: false,
},
{
name: "no finalmask",
streamSettings: `{"network":"tcp","security":"none"}`,
wantErr: false,
},
{
name: "non-xmc mask is untouched",
streamSettings: `{"finalmask":{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
wantErr: false,
},
{
name: "xmc with a complete profile",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"hostname":"mc.example.com","password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
wantErr: false,
},
{
name: "legacy usernames shape without profiles",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"hostname":"mc.example.com","password":"pw","usernames":["Dream"]}}]}}`,
wantErr: true,
},
{
name: "xmc with an empty profiles array",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[]}}]}}`,
wantErr: true,
},
{
name: "profile missing the textures signature",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":""}]}}]}}`,
wantErr: true,
},
{
name: "profile with an unparseable uuid",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"Notch","uuid":"not-a-uuid","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}]}}]}}`,
wantErr: true,
},
{
name: "profile with an out-of-range username",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"ab","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}]}}]}}`,
wantErr: true,
},
{
name: "one complete and one incomplete profile",
streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `,{"username":"Herobrine","uuid":"","texturesValue":"","texturesSignature":""}]}}]}}`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateFinalMaskXmcProfiles(tt.streamSettings)
if (err != nil) != tt.wantErr {
t.Errorf("validateFinalMaskXmcProfiles(%q) error = %v, wantErr %v", tt.streamSettings, err, tt.wantErr)
}
})
}
}
// TestXmcMaskProfilesCompleteMatchesCoreValidation pins the panel's predicate
// to xray-core's own loader instead of a restatement of it: every profile the
// panel accepts must build, and every one it rejects must fail to build. A
// future core release that tightens or relaxes the rules fails here rather
// than silently producing configs the core refuses to start on.
func TestXmcMaskProfilesCompleteMatchesCoreValidation(t *testing.T) {
profiles := []struct {
name string
raw string
}{
{name: "complete", raw: completeXmcProfile},
{name: "undashed uuid", raw: `{"username":"Notch","uuid":"069a79f444e94726a5befca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
{name: "username at the 16 char limit", raw: `{"username":"Abcdefghijklmnop","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
{name: "username over the limit", raw: `{"username":"Abcdefghijklmnopq","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
{name: "username with a hyphen", raw: `{"username":"No-tch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
{name: "empty uuid", raw: `{"username":"Notch","uuid":"","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
{name: "missing textures value", raw: `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"","texturesSignature":"c2ln"}`},
}
for _, tt := range profiles {
t.Run(tt.name, func(t *testing.T) {
var coreProfile conf.XMCProfile
if err := json.Unmarshal([]byte(tt.raw), &coreProfile); err != nil {
t.Fatalf("unmarshal into conf.XMCProfile: %v", err)
}
_, coreErr := coreProfile.Build()
mask := map[string]any{"type": "xmc"}
var settings map[string]any
if err := json.Unmarshal([]byte(`{"profiles":[`+tt.raw+`]}`), &settings); err != nil {
t.Fatalf("unmarshal settings: %v", err)
}
mask["settings"] = settings
panelAccepts := xmcMaskProfilesComplete(mask)
coreAccepts := coreErr == nil
if panelAccepts != coreAccepts {
t.Errorf("xmcMaskProfilesComplete = %v, but conf.XMCProfile.Build() accepts = %v (err %v)", panelAccepts, coreAccepts, coreErr)
}
})
}
}
// TestXmcEmptyProfilesRejectedByCore covers the rule that lives on XMC rather
// than XMCProfile: v26.7.28 dropped the "default to Dream" fallback, so a mask
// with no profiles at all is now a build failure.
func TestXmcEmptyProfilesRejectedByCore(t *testing.T) {
var core conf.XMC
if err := json.Unmarshal([]byte(`{"hostname":"mc.example.com","password":"pw","profiles":[]}`), &core); err != nil {
t.Fatalf("unmarshal into conf.XMC: %v", err)
}
if _, err := core.Build(); err == nil {
t.Fatal("conf.XMC.Build() accepted an empty profiles list; the panel's strip/validate pair is no longer needed")
}
}
func TestStripIncompleteXmcMasks(t *testing.T) {
tests := []struct {
name string
stream string
wantDropped int
wantStream string
}{
{
name: "legacy usernames mask is dropped and finalmask removed",
stream: `{"network":"tcp","finalmask":{"tcp":[{"type":"xmc","settings":{"usernames":["Dream"],"password":"pw"}}]}}`,
wantDropped: 1,
wantStream: `{"network":"tcp"}`,
},
{
name: "complete mask is kept",
stream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
wantDropped: 0,
wantStream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
},
{
name: "sibling masks survive the drop",
stream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"usernames":["Dream"]}},{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
wantDropped: 1,
wantStream: `{"finalmask":{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
},
{
name: "udp masks are preserved when tcp empties out",
stream: `{"finalmask":{"tcp":[{"type":"xmc","settings":{}}],"udp":[{"type":"salamander"}]}}`,
wantDropped: 1,
wantStream: `{"finalmask":{"udp":[{"type":"salamander"}]}}`,
},
{
name: "stream without finalmask is untouched",
stream: `{"network":"tcp","security":"tls"}`,
wantDropped: 0,
wantStream: `{"network":"tcp","security":"tls"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var stream map[string]any
if err := json.Unmarshal([]byte(tt.stream), &stream); err != nil {
t.Fatalf("unmarshal stream: %v", err)
}
dropped := stripIncompleteXmcMasks(stream)
if dropped != tt.wantDropped {
t.Errorf("stripIncompleteXmcMasks dropped = %d, want %d", dropped, tt.wantDropped)
}
var want map[string]any
if err := json.Unmarshal([]byte(tt.wantStream), &want); err != nil {
t.Fatalf("unmarshal wantStream: %v", err)
}
got, err := json.Marshal(stream)
if err != nil {
t.Fatalf("marshal stream: %v", err)
}
wantJSON, err := json.Marshal(want)
if err != nil {
t.Fatalf("marshal want: %v", err)
}
if string(got) != string(wantJSON) {
t.Errorf("stream after strip = %s, want %s", got, wantJSON)
}
})
}
}
+9 -2
View File
@@ -19,11 +19,18 @@ import (
func (s *InboundService) MigrationRemoveOrphanedTraffics() {
db := database.GetDB()
query := fmt.Sprintf(
"DELETE FROM client_traffics WHERE email NOT IN (SELECT %s %s)",
"DELETE FROM client_traffics WHERE email NOT IN (SELECT email FROM clients) AND email NOT IN (SELECT %s %s)",
database.JSONFieldText("client.value", "email"),
database.JSONClientsFromInbound(),
)
db.Exec(query)
result := db.Exec(query)
if result.Error != nil {
logger.Warning("MigrationRemoveOrphanedTraffics failed:", result.Error)
return
}
if result.RowsAffected > 0 {
logger.Infof("MigrationRemoveOrphanedTraffics: removed %d orphaned client_traffics row(s)", result.RowsAffected)
}
}
func (s *InboundService) MigrationRequirements() {
@@ -129,6 +129,65 @@ func TestMigrationRequirements_CleansLegacyZeroAddrTag(t *testing.T) {
}
}
func TestMigrationRemoveOrphanedTraffics(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
clientSvc := &ClientService{}
inboundSvc := &InboundService{}
const attachedEmail = "attached@example.com"
attachedClient := model.Client{Email: attachedEmail, ID: "11111111-1111-1111-1111-111111111111", SubID: attachedEmail, Enable: true}
attachedIb := mkInbound(t, 30003, model.VLESS, clientsSettings(t, []model.Client{attachedClient}))
if err := clientSvc.SyncInbound(nil, attachedIb.Id, []model.Client{attachedClient}); err != nil {
t.Fatalf("seed attached client: %v", err)
}
mkTraffic(t, attachedIb.Id, attachedEmail, 0, 0, 0, 0, true)
const detachedEmail = "detached@example.com"
detachedClient := model.Client{Email: detachedEmail, ID: "22222222-2222-2222-2222-222222222222", SubID: detachedEmail, Enable: true}
detachedIb := mkInbound(t, 30004, model.VLESS, clientsSettings(t, []model.Client{detachedClient}))
if err := clientSvc.SyncInbound(nil, detachedIb.Id, []model.Client{detachedClient}); err != nil {
t.Fatalf("seed detached client: %v", err)
}
mkTraffic(t, detachedIb.Id, detachedEmail, 123, 456, 0, 0, true)
detachedRec := lookupClientRecord(t, detachedEmail)
if _, err := clientSvc.Detach(inboundSvc, detachedRec.Id, []int{detachedIb.Id}); err != nil {
t.Fatalf("Detach: %v", err)
}
const jsonOnlyEmail = "jsononly@example.com"
jsonOnlyClient := model.Client{Email: jsonOnlyEmail, ID: "33333333-3333-3333-3333-333333333333", SubID: jsonOnlyEmail, Enable: true}
jsonOnlyIb := mkInbound(t, 30005, model.VLESS, clientsSettings(t, []model.Client{jsonOnlyClient}))
mkTraffic(t, jsonOnlyIb.Id, jsonOnlyEmail, 0, 0, 0, 0, true)
const trulyOrphanedEmail = "deleted@example.com"
mkTraffic(t, attachedIb.Id, trulyOrphanedEmail, 0, 0, 0, 0, true)
inboundSvc.MigrationRemoveOrphanedTraffics()
cases := []struct {
name string
email string
want int64
}{
{"attached, in clients table and JSON", attachedEmail, 1},
{"detached-but-alive, in clients table only", detachedEmail, 1},
{"seeder-skipped-but-live, in JSON only", jsonOnlyEmail, 1},
{"truly orphaned, in neither", trulyOrphanedEmail, 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var got int64
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", c.email).Count(&got).Error; err != nil {
t.Fatalf("count client_traffics for %s: %v", c.email, err)
}
if got != c.want {
t.Errorf("client_traffics count for %s: got %d, want %d", c.email, got, c.want)
}
})
}
}
func TestMigrationRequirements_NormalizesShareAddressFields(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
+2 -2
View File
@@ -14,7 +14,7 @@ import (
// running: one instance per enabled local mtproto inbound, serving only the
// secrets of clients that are both enabled in the inbound settings and not
// depletion-disabled in client_traffics. That is the same effective client set
// buildRuntimeInboundForAPI pushes on interactive edits, so the reconcile job
// buildInboundForLocalRuntime pushes on interactive edits, so the reconcile job
// and the push paths agree on one fingerprint — a disagreement would surface
// as a needless mtg restart, and a job that read only the raw settings would
// keep serving depleted clients until an unrelated restart. Inbounds whose
@@ -95,7 +95,7 @@ func (s *InboundService) applyLocalMtproto(inboundId int) {
}
payload := inbound
if inbound.Enable {
if built, bErr := s.buildRuntimeInboundForAPI(database.GetDB(), inbound); bErr == nil {
if built, bErr := s.buildInboundForLocalRuntime(database.GetDB(), inbound); bErr == nil {
payload = built
}
}
@@ -55,9 +55,9 @@ func TestDesiredMtprotoInstancesFiltersDepleted(t *testing.T) {
})
t.Run("matchesInteractivePushFiltering", func(t *testing.T) {
built, err := svc.buildRuntimeInboundForAPI(database.GetDB(), served)
built, err := svc.buildInboundForLocalRuntime(database.GetDB(), served)
if err != nil {
t.Fatalf("buildRuntimeInboundForAPI: %v", err)
t.Fatalf("buildInboundForLocalRuntime: %v", err)
}
pushInst, ok := mtproto.InstanceFromInbound(built)
if !ok {
+52 -30
View File
@@ -125,11 +125,8 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
}
}
}
// Reconcile with the same runtime-built payload interactive pushes
// send (disabled clients filtered, settings.fallbacks injected) so
// fingerprints line up and fallback edits actually reach the node.
runtimeIb := ib
if built, bErr := s.buildRuntimeInboundForAPI(db, ib); bErr == nil {
if built, bErr := s.buildInboundForNodePush(db, ib); bErr == nil {
runtimeIb = built
}
if _, err := rt.ReconcileInbound(ctx, runtimeIb, existsOnNode); err != nil {
@@ -311,7 +308,8 @@ func adoptedWireChanged(c, snapIb *model.Inbound, adoptedSettings string) bool {
c.ExpiryTime != snapIb.ExpiryTime ||
c.StreamSettings != snapIb.StreamSettings ||
c.Sniffing != snapIb.Sniffing ||
c.TrafficReset != snapIb.TrafficReset
c.TrafficReset != snapIb.TrafficReset ||
c.TrafficResetDay != normalizeTrafficResetDay(snapIb.TrafficResetDay)
}
// adoptedWireInbound is the central inbound as it reads after adopting the
@@ -330,6 +328,7 @@ func adoptedWireInbound(c, snapIb *model.Inbound, adoptedSettings string) *model
a.StreamSettings = snapIb.StreamSettings
a.Sniffing = snapIb.Sniffing
a.TrafficReset = snapIb.TrafficReset
a.TrafficResetDay = normalizeTrafficResetDay(snapIb.TrafficResetDay)
return &a
}
@@ -350,7 +349,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
// origin (an inbound the node forwards from its own sub-node) is kept as-is,
// so a chained Node1->Node2->Node3 still attributes Node3's inbounds to Node3.
var nodeRow model.Node
db.Select("guid").Where("id = ?", nodeID).First(&nodeRow)
db.Select("guid", "config_dirty", "inbound_sync_mode", "inbound_tags").Where("id = ?", nodeID).First(&nodeRow)
// Re-read inside the serialized writer: a client added while this snapshot
// was in flight marks the node dirty after the caller sampled the flag.
dirty = dirty || nodeRow.ConfigDirty
nodeRow.Id = nodeID
unmanagedTag := unmanagedTagPredicate(&nodeRow)
selfKey := effectiveNodeKey(&model.Node{Id: nodeID, Guid: nodeRow.Guid})
guidShared := nodeRow.Guid != "" && selfKey != nodeRow.Guid
originGuidFor := func(snapIb *model.Inbound) string {
@@ -561,6 +565,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
StreamSettings: snapIb.StreamSettings,
Sniffing: snapIb.Sniffing,
TrafficReset: snapIb.TrafficReset,
TrafficResetDay: normalizeTrafficResetDay(snapIb.TrafficResetDay),
LastTrafficResetTime: snapIb.LastTrafficResetTime,
Enable: snapIb.Enable,
Remark: snapIb.Remark,
@@ -616,6 +621,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
updates["stream_settings"] = snapIb.StreamSettings
updates["sniffing"] = snapIb.Sniffing
updates["traffic_reset"] = snapIb.TrafficReset
updates["traffic_reset_day"] = normalizeTrafficResetDay(snapIb.TrafficResetDay)
updates["last_traffic_reset_time"] = snapIb.LastTrafficResetTime
if adoptedWireChanged(c, snapIb, adoptedSettings) {
adoptedInbounds = append(adoptedInbounds, adoptedWireInbound(c, snapIb, adoptedSettings))
@@ -667,6 +673,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
if _, kept := snapTags[c.Tag]; kept {
continue
}
if unmanagedTag(c.Tag) {
continue
}
var goneEmails []string
if err := tx.Model(xray.ClientTraffic{}).
Where("inbound_id = ?", c.Id).
@@ -882,7 +891,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
if uErr != nil {
return false, uErr
}
if !stillUsed {
// Usage, quota and expiry live on this row, so a client the orphan
// sweep will mark keeps it until the reaper confirms the removal.
if !stillUsed && !clientRecordExists(tx, existing.Email) {
if err := tx.Where("inbound_id = ? AND email = ?", c.Id, existing.Email).
Delete(&xray.ClientTraffic{}).Error; err != nil {
return false, err
@@ -897,6 +908,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
emails map[string]struct{}
}
var perInboundOld []oldSet
syncFailedInbounds := map[int]struct{}{}
for _, snapIb := range snap.Inbounds {
if snapIb == nil {
continue
@@ -969,10 +981,16 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
}
if err := s.clientService.SyncInbound(tx, c.Id, filtered); err != nil {
logger.Warningf("setRemoteTraffic: sync clients for tag %q failed: %v", snapIb.Tag, err)
syncFailedInbounds[c.Id] = struct{}{}
}
}
for _, old := range perInboundOld {
// The sweep's premise is that links were just rebuilt from the snapshot,
// which is exactly what a failed SyncInbound violates.
if _, failed := syncFailedInbounds[old.inboundID]; failed {
continue
}
var stillAttached []string
if err := tx.Table("clients").
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
@@ -998,19 +1016,20 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
if attachmentCount > 0 {
continue
}
if err := tx.Where("email = ?", email).Delete(&model.ClientRecord{}).Error; err != nil {
logger.Warningf("setRemoteTraffic: delete ClientRecord %q failed: %v", email, err)
}
if err := tx.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
logger.Warningf("setRemoteTraffic: delete ClientTraffic %q failed: %v", email, err)
}
if err := tx.Where("email = ?", email).Delete(&model.NodeClientTraffic{}).Error; err != nil {
logger.Warningf("setRemoteTraffic: delete NodeClientTraffic %q failed: %v", email, err)
// "Ended the merge unattached" is true for a real remote deletion and
// equally true for a bad merge, so record a strike instead of deleting.
if err := markSyncOrphan(tx, email, now); err != nil {
logger.Warningf("setRemoteTraffic: mark orphan %q failed: %v", email, err)
continue
}
structuralChange = true
}
}
if err := clearSyncOrphanMarks(tx); err != nil {
logger.Warning("setRemoteTraffic: clear orphan marks failed:", err)
}
if err := liftActivatedClientRecordExpiries(tx); err != nil {
logger.Warning("setRemoteTraffic: lift activated expiries failed:", err)
}
@@ -1032,7 +1051,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
}
}
if p != nil {
if process := currentXrayProcess(); process != nil {
tree := snap.OnlineTree
switch {
case len(tree) == 0 && len(snap.OnlineEmails) > 0:
@@ -1054,17 +1073,18 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
tree = remapped
}
}
p.SetNodeOnlineTree(nodeID, tree)
process.SetNodeOnlineTree(nodeID, tree)
}
return structuralChange, nil
}
func (s *InboundService) GetOnlineClients() []string {
if p == nil {
process := currentXrayProcess()
if process == nil {
return []string{}
}
return p.GetOnlineClients()
return process.GetOnlineClients()
}
// GetOnlineClientsByGuid returns online emails keyed by the panelGuid of the
@@ -1073,11 +1093,12 @@ func (s *InboundService) GetOnlineClients() []string {
// node-id keying so a client three hops down is attributed to its real node,
// not the intermediate one it was synced through.
func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
if p == nil {
process := currentXrayProcess()
if process == nil {
return map[string][]string{}
}
out := p.GetMergedNodeTrees()
if local := p.GetLocalOnlineClients(); len(local) > 0 {
out := process.GetMergedNodeTrees()
if local := process.GetLocalOnlineClients(); len(local) > 0 {
if guid := s.panelGuid(); guid != "" {
out[guid] = mergeEmails(out[guid], local)
}
@@ -1090,10 +1111,11 @@ func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
// report per-inbound activity, so a GUID missing from the map means "don't
// gate" for that node's inbounds.
func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
if p == nil {
process := currentXrayProcess()
if process == nil {
return map[string][]string{}
}
active := p.GetLocalActiveInbounds()
active := process.GetLocalActiveInbounds()
if len(active) == 0 {
return map[string][]string{}
}
@@ -1105,14 +1127,14 @@ func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
}
func (s *InboundService) SetNodeOnlineTree(nodeID int, tree map[string][]string) {
if p != nil {
p.SetNodeOnlineTree(nodeID, tree)
if process := currentXrayProcess(); process != nil {
process.SetNodeOnlineTree(nodeID, tree)
}
}
func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
if p != nil {
p.ClearNodeOnlineClients(nodeID)
if process := currentXrayProcess(); process != nil {
process.ClearNodeOnlineClients(nodeID)
}
}
@@ -1173,8 +1195,8 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
// xray.Process for why the local sets are kept separate from the shared
// last_online column.
func (s *InboundService) RefreshLocalOnlineClients(activeEmails, activeInboundTags []string) {
if p != nil {
p.RefreshLocalOnline(activeEmails, activeInboundTags, time.Now().UnixMilli(), onlineGracePeriodMs)
if process := currentXrayProcess(); process != nil {
process.RefreshLocalOnline(activeEmails, activeInboundTags, time.Now().UnixMilli(), onlineGracePeriodMs)
}
}
+25 -4
View File
@@ -287,6 +287,23 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
return dbClientTraffics, newExpiryByEmail, nil
}
// apiUserFromClient prepares a stored client object for the runtime AddUser
// call. The copy matters twice over: the stored object keeps being mutated and
// marshalled back into the inbound's settings, which must not gain an API-only
// key, and shadowsocks clients carry no cipher of their own — it lives on the
// inbound, and without it the API cannot tell which of xray's two shadowsocks
// account types the running inbound expects.
func apiUserFromClient(client map[string]any, cipher string) map[string]any {
user := maps.Clone(client)
if user == nil {
user = map[string]any{}
}
if cipher != "" {
user["cipher"] = cipher
}
return user
}
func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
// check for time expired
var traffics []*xray.ClientTraffic
@@ -367,6 +384,10 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
if len(clients) == 0 {
continue
}
cipher := ""
if inbounds[inbound_index].Protocol == model.Shadowsocks {
cipher, _ = settings["method"].(string)
}
for client_index := range clients {
c := clients[client_index].(map[string]any)
email, _ := c["email"].(string)
@@ -393,7 +414,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
}{
protocol: string(inbounds[inbound_index].Protocol),
tag: inbounds[inbound_index].Tag,
client: c,
client: apiUserFromClient(c, cipher),
})
}
clients[client_index] = any(c)
@@ -431,8 +452,8 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
if err = clearGlobalTraffic(tx, renewEmails...); err != nil {
return false, 0, err
}
if p != nil {
err1 = s.xrayApi.Init(p.GetAPIPort())
if process := currentXrayProcess(); process != nil {
err1 = s.xrayApi.Init(process.GetAPIPort())
if err1 != nil {
return true, int64(len(traffics)), nil
}
@@ -603,7 +624,7 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
if err != nil {
return false, err
}
cipher = oldSettings["method"].(string)
cipher, _ = oldSettings["method"].(string)
}
err1 := rt.AddUser(context.Background(), inbound, map[string]any{
"email": client.Email,
+10 -4
View File
@@ -100,15 +100,21 @@ func chunkGlobalRows(rows []model.ClientGlobalTraffic, size int) [][]model.Clien
}
// overlayGlobalTraffic raises Up/Down on the given rows to the largest global
// value any master pushed for that email. Read-path only — callers hand it
// rows about to be serialized for display; the stored counters are untouched.
// value any master still pushing for that email reported. Read-path only —
// callers hand it rows about to be serialized for display; the stored counters
// are untouched. Rows older than globalTrafficFreshWindow are ignored: they
// come from a master that stopped pushing, and folding their frozen counters
// in would keep showing usage the client no longer has (#6113).
func overlayGlobalTraffic(db *gorm.DB, rows []*xray.ClientTraffic) {
if len(rows) == 0 {
return
}
freshSince := globalTrafficFreshSince()
// Cheap short-circuit for the common case (a panel no master pushes to).
var probe int64
if err := db.Model(&model.ClientGlobalTraffic{}).Limit(1).Count(&probe).Error; err != nil || probe == 0 {
if err := db.Model(&model.ClientGlobalTraffic{}).
Where("updated_at >= ?", freshSince).
Limit(1).Count(&probe).Error; err != nil || probe == 0 {
return
}
@@ -126,7 +132,7 @@ func overlayGlobalTraffic(db *gorm.DB, rows []*xray.ClientTraffic) {
}
for _, batch := range chunkStrings(emails, sqlInChunk) {
var globals []model.ClientGlobalTraffic
if err := db.Where("email IN ?", batch).Find(&globals).Error; err != nil {
if err := db.Where("email IN ? AND updated_at >= ?", batch, freshSince).Find(&globals).Error; err != nil {
logger.Warning("overlayGlobalTraffic:", err)
return
}
@@ -0,0 +1,18 @@
package service
import "testing"
func TestNormalizeTrafficResetDay(t *testing.T) {
tests := map[int]int{
0: 1,
1: 1,
15: 15,
31: 31,
32: 31,
}
for input, want := range tests {
if got := normalizeTrafficResetDay(input); got != want {
t.Errorf("normalizeTrafficResetDay(%d) = %d, want %d", input, got, want)
}
}
}
+3 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/gob"
"os"
"path/filepath"
"slices"
"sync"
"time"
@@ -170,8 +171,8 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in
h.mu.Unlock()
startIdx := len(raw)
for i := len(raw) - 1; i >= 0; i-- {
if raw[i].T < cutoff {
for i, r := range slices.Backward(raw) {
if r.T < cutoff {
break
}
startIdx = i
+24 -3
View File
@@ -652,9 +652,9 @@ func (s *NodeService) EnsureInboundTagAllowedTx(tx *gorm.DB, nodeID int, tag str
Updates(map[string]any{"inbound_tags": string(buf)}).Error
}
func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
if n == nil || snap == nil || n.InboundSyncMode != "selected" {
return
func nodeSelectedTagSet(n *model.Node) map[string]struct{} {
if n == nil || n.InboundSyncMode != "selected" {
return nil
}
prefix := nodeTagPrefix(&n.Id)
allowed := make(map[string]struct{}, len(n.InboundTags)*2)
@@ -668,6 +668,27 @@ func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
}
}
}
return allowed
}
// A deselected tag is still served by the node — FilterNodeSnapshot just stops
// reporting it — so its absence must never be read as "the node deleted it".
func unmanagedTagPredicate(n *model.Node) func(string) bool {
managed := nodeSelectedTagSet(n)
if managed == nil {
return func(string) bool { return false }
}
return func(tag string) bool {
_, ok := managed[tag]
return !ok
}
}
func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
if n == nil || snap == nil || n.InboundSyncMode != "selected" {
return
}
allowed := nodeSelectedTagSet(n)
filtered := make([]*model.Inbound, 0, len(snap.Inbounds))
for _, inbound := range snap.Inbounds {
if inbound == nil {
@@ -0,0 +1,78 @@
package service
import (
"encoding/json"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func payloadClientEmails(t *testing.T, settings string) []string {
t.Helper()
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
t.Fatalf("settings not valid json: %v", err)
}
raw, _ := parsed["clients"].([]any)
out := make([]string, 0, len(raw))
for _, c := range raw {
cm, ok := c.(map[string]any)
if !ok {
continue
}
if email, _ := cm["email"].(string); email != "" {
out = append(out, email)
}
}
return out
}
// Stripping disabled clients from a node push made the node delete the row and
// the master mirror that back: every depleted client destroyed, not disabled.
func TestNodePushKeepsDisabledClientsLocalPushStripsThem(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
clients := []model.Client{
{Email: "alive@x", ID: "11111111-1111-1111-1111-111111111111", Enable: true},
{Email: "quota@x", ID: "22222222-2222-2222-2222-222222222222", Enable: true},
}
ib := mkInbound(t, 30301, model.VLESS, clientsSettings(t, clients))
for _, c := range clients {
row := &xray.ClientTraffic{InboundId: ib.Id, Email: c.Email, Enable: true}
if err := db.Create(row).Error; err != nil {
t.Fatalf("seed traffic %s: %v", c.Email, err)
}
}
if err := db.Model(xray.ClientTraffic{}).
Where("email = ?", "quota@x").
Update("enable", false).Error; err != nil {
t.Fatalf("deplete quota@x: %v", err)
}
t.Run("node payload keeps the depletion-disabled client", func(t *testing.T) {
built, err := svc.buildInboundForNodePush(db, ib)
if err != nil {
t.Fatalf("buildInboundForNodePush: %v", err)
}
got := payloadClientEmails(t, built.Settings)
if len(got) != 2 {
t.Fatalf("node push dropped a client: got %v, want both alive@x and quota@x", got)
}
})
t.Run("local xray payload still strips it", func(t *testing.T) {
built, err := svc.buildInboundForLocalRuntime(db, ib)
if err != nil {
t.Fatalf("buildInboundForLocalRuntime: %v", err)
}
got := payloadClientEmails(t, built.Settings)
if len(got) != 1 || got[0] != "alive@x" {
t.Fatalf("local runtime payload = %v, want only alive@x", got)
}
})
}
@@ -0,0 +1,162 @@
package service
import (
"fmt"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
func countClientRows(t *testing.T, db *gorm.DB, email string) (records, traffics int64) {
t.Helper()
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&records).Error; err != nil {
t.Fatalf("count clients %q: %v", email, err)
}
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Count(&traffics).Error; err != nil {
t.Fatalf("count client_traffics %q: %v", email, err)
}
return records, traffics
}
func seedNodeRow(t *testing.T, db *gorm.DB, n *model.Node) {
t.Helper()
if err := db.Create(n).Error; err != nil {
t.Fatalf("create node: %v", err)
}
}
func snapshotWithClients(t *testing.T, tag, settings string, stats ...xray.ClientTraffic) *runtime.TrafficSnapshot {
t.Helper()
return &runtime.TrafficSnapshot{
Inbounds: []*model.Inbound{{Tag: tag, Settings: settings, ClientStats: stats}},
}
}
func snapshotWithoutClients(t *testing.T, tag string) *runtime.TrafficSnapshot {
t.Helper()
return snapshotWithClients(t, tag, `{"clients":[]}`)
}
func snapshotWithTwoInbounds(t *testing.T, tagA, settingsA, emailA, tagB, settingsB, emailB string) *runtime.TrafficSnapshot {
t.Helper()
return &runtime.TrafficSnapshot{
Inbounds: []*model.Inbound{
{Tag: tagA, Settings: settingsA, ClientStats: []xray.ClientTraffic{{Email: emailA, Enable: true}}},
{Tag: tagB, Settings: settingsB, ClientStats: []xray.ClientTraffic{{Email: emailB, Enable: true}}},
},
}
}
// The job samples config_dirty before the snapshot round-trip; a client added
// in that window is deleted again unless the merge re-reads the flag itself.
func TestSetRemoteTrafficRereadsConfigDirty(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
const email = "carol"
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
xray.ClientTraffic{Email: email, Up: 1, Down: 1, Enable: true})
if rec, _ := countClientRows(t, db, email); rec != 1 {
t.Fatalf("setup: client not attached, got %d rows", rec)
}
if err := db.Model(model.Node{}).Where("id = ?", 1).Update("config_dirty", true).Error; err != nil {
t.Fatalf("mark node dirty: %v", err)
}
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false); err != nil {
t.Fatalf("setRemoteTrafficLocked: %v", err)
}
rec, traf := countClientRows(t, db, email)
if rec != 1 || traf != 1 {
t.Fatalf("stale dirty=false wiped a client added mid-flight: clients=%d client_traffics=%d, want 1/1", rec, traf)
}
}
// FilterNodeSnapshot stops reporting a deselected tag; reading that absence as
// "the node deleted it" wiped the master's copy of an inbound still running.
func TestDeselectedTagIsNotSwept(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
seedNodeRow(t, db, &model.Node{
Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true,
InboundSyncMode: "selected", InboundTags: []string{"keep"},
})
createNodeInboundWithClient(t, db, 1, "keep", 41001, "kept@x")
createNodeInboundWithClient(t, db, 1, "drop", 41002, "dropped@x")
keepSettings := `{"clients":[{"email":"kept@x","enable":true}]}`
dropSettings := `{"clients":[{"email":"dropped@x","enable":true}]}`
snap := snapshotWithTwoInbounds(t, "keep", keepSettings, "kept@x", "drop", dropSettings, "dropped@x")
if _, err := svc.setRemoteTrafficLocked(1, snap, false); err != nil {
t.Fatalf("seed sync: %v", err)
}
if rec, traf := countClientRows(t, db, "dropped@x"); rec != 1 || traf != 1 {
t.Fatalf("setup: dropped@x not seeded, clients=%d client_traffics=%d", rec, traf)
}
keepOnly := snapshotWithClients(t, "keep", keepSettings, xray.ClientTraffic{Email: "kept@x", Enable: true})
if _, err := svc.setRemoteTrafficLocked(1, keepOnly, false); err != nil {
t.Fatalf("post-deselect sync: %v", err)
}
rec, traf := countClientRows(t, db, "dropped@x")
if rec != 1 || traf != 1 {
t.Fatalf("deselecting a tag deleted its clients: clients=%d client_traffics=%d, want 1/1", rec, traf)
}
var inbounds int64
if err := db.Model(model.Inbound{}).Where("tag = ?", "drop").Count(&inbounds).Error; err != nil {
t.Fatalf("count inbounds: %v", err)
}
if inbounds != 1 {
t.Fatalf("deselecting a tag deleted the inbound the node still serves: got %d rows, want 1", inbounds)
}
}
func TestSyncInboundStoresTrimmedEmail(t *testing.T) {
db := initTrafficTestDB(t)
svc := &ClientService{}
ib := &model.Inbound{UserId: 1, Tag: "trim-in", Enable: true, Port: 41501, Protocol: model.VLESS}
if err := database.GetDB().Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
padded := "bob "
clients := []model.Client{{Email: padded, Enable: true}}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("first SyncInbound: %v", err)
}
var stored []string
if err := db.Model(&model.ClientRecord{}).Pluck("email", &stored).Error; err != nil {
t.Fatalf("read clients: %v", err)
}
if len(stored) != 1 || stored[0] != "bob" {
t.Fatalf("stored email = %q, want the trimmed %q — the lookup key must match what is written", stored, "bob")
}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("second SyncInbound must not hit a unique-constraint on the untrimmed row: %v", err)
}
var links int64
if err := db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&links).Error; err != nil {
t.Fatalf("count links: %v", err)
}
if links != 1 {
t.Fatalf("links after re-sync = %d, want 1", links)
}
}
@@ -30,8 +30,8 @@ import (
// CheckXrayConfig's version gate.
func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
coreVersion := "Unknown"
if p != nil {
coreVersion = p.GetXrayVersion()
if process := currentXrayProcess(); process != nil {
coreVersion = process.GetXrayVersion()
}
kept := make([]any, 0, len(outbounds))
var dropped []string
+22 -20
View File
@@ -626,8 +626,8 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
status.AppStats.Mem = rtm.Sys
}
status.AppStats.Threads = uint32(runtime.NumGoroutine())
if p != nil && p.IsRunning() {
status.AppStats.Uptime = p.GetUptime()
if process := currentXrayProcess(); process != nil && process.IsRunning() {
status.AppStats.Uptime = process.GetUptime()
} else {
status.AppStats.Uptime = 0
}
@@ -670,8 +670,8 @@ func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
systemMetrics.append("tcpCount", t, float64(status.TcpCount))
systemMetrics.append("udpCount", t, float64(status.UdpCount))
online := 0
if p != nil && p.IsRunning() {
online = len(p.GetOnlineClients())
if process := currentXrayProcess(); process != nil && process.IsRunning() {
online = len(process.GetOnlineClients())
}
systemMetrics.append("online", t, float64(online))
if len(status.Loads) >= 3 {
@@ -1345,25 +1345,26 @@ func (s *ServerService) GetDb() ([]byte, error) {
if database.IsPostgres() {
return s.exportPostgresDB()
}
// Update by manually trigger a checkpoint operation
err := database.Checkpoint()
backupPath, cleanup, err := s.backupSQLite()
if err != nil {
return nil, err
}
// Open the file for reading
file, err := os.Open(config.GetDBPath())
if err != nil {
return nil, err
}
defer file.Close()
defer cleanup()
return os.ReadFile(backupPath)
}
// Read the file contents
fileContents, err := io.ReadAll(file)
func (s *ServerService) backupSQLite() (string, func(), error) {
backupDir, err := os.MkdirTemp(filepath.Dir(config.GetDBPath()), ".x-ui-backup-")
if err != nil {
return nil, err
return "", nil, err
}
return fileContents, nil
cleanup := func() { _ = os.RemoveAll(backupDir) }
backupPath := filepath.Join(backupDir, "backup.db")
if err := database.BackupSQLite(backupPath); err != nil {
cleanup()
return "", nil, err
}
return backupPath, cleanup, nil
}
// BackupFilename returns the filename for a database backup, named after the
@@ -1463,11 +1464,12 @@ func (s *ServerService) GetMigration() ([]byte, string, error) {
return data, "x-ui.db", nil
}
// SQLite panel: checkpoint so the .db reflects the latest writes, then dump.
if err := database.Checkpoint(); err != nil {
backupPath, cleanup, err := s.backupSQLite()
if err != nil {
return nil, "", err
}
data, err := database.DumpSQLiteToBytes(config.GetDBPath())
defer cleanup()
data, err := database.DumpSQLiteToBytes(backupPath)
if err != nil {
return nil, "", err
}
+37 -1
View File
@@ -37,6 +37,7 @@ const (
DefaultSubClashUserAgentRegex = `(?i)(clash|mihomo)`
DefaultSubJsonUserAgentRegex = ``
DefaultRemarkTemplate = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
DefaultTrustedProxyCIDRs = "127.0.0.1/32,::1/128"
maxRegexLength = 2048
)
@@ -61,11 +62,12 @@ var defaultValueMap = map[string]string{
"nodeMtlsClientCAPem": "",
"webBasePath": normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")),
"sessionMaxAge": "360",
"trustedProxyCIDRs": "127.0.0.1/32,::1/128",
"trustedProxyCIDRs": DefaultTrustedProxyCIDRs,
"pageSize": "25",
"expireDiff": "0",
"trafficDiff": "0",
"remarkTemplate": DefaultRemarkTemplate,
"subShowIdentityOnAllLinks": "false",
"timeLocation": "Local",
"tgBotEnable": "false",
"tgBotToken": "",
@@ -649,6 +651,10 @@ func (s *SettingService) GetRemarkTemplate() (string, error) {
return s.getString("remarkTemplate")
}
func (s *SettingService) GetSubShowIdentityOnAllLinks() (bool, error) {
return s.getBool("subShowIdentityOnAllLinks")
}
func (s *SettingService) GetSecret() ([]byte, error) {
secret, err := s.getString("secret")
if secret == defaultValueMap["secret"] {
@@ -1440,3 +1446,33 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
return result, nil
}
var factoryDefaultSecretKeys = map[string]bool{
"tgBotToken": true,
"twoFactorToken": true,
"ldapPassword": true,
"smtpPassword": true,
}
/*
GetFactoryDefaults returns the shipped default value per setting, keyed by
the AllSetting json field name. Unlike GetDefaultSettings (which reports
current effective values), this is defaultValueMap projected through the
AllSetting field set: only keys that exist as an AllSetting json tag are
returned, minus the credential fields in factoryDefaultSecretKeys. Keys
with no AllSetting field (secret, panelGuid, the node mTLS material,
xrayTemplateConfig) are excluded structurally rather than by deny-list.
*/
func (s *SettingService) GetFactoryDefaults() map[string]string {
result := make(map[string]string)
for _, field := range reflect_util.GetFields(reflect.TypeFor[entity.AllSetting]()) {
key := field.Tag.Get("json")
if key == "" || factoryDefaultSecretKeys[key] {
continue
}
if value, ok := defaultValueMap[key]; ok {
result[key] = value
}
}
return result
}
@@ -0,0 +1,83 @@
package service
import (
"reflect"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/util/reflect_util"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
)
func allSettingJSONTags(t *testing.T) map[string]bool {
t.Helper()
tags := make(map[string]bool)
for _, field := range reflect_util.GetFields(reflect.TypeFor[entity.AllSetting]()) {
if tag := field.Tag.Get("json"); tag != "" {
tags[tag] = true
}
}
return tags
}
func TestGetFactoryDefaultsExposesBrowserSafeKeys(t *testing.T) {
defaults := (&SettingService{}).GetFactoryDefaults()
tests := []struct {
key string
want string
}{
{key: "webPort", want: "2053"},
{key: "subPort", want: "2096"},
}
for _, tc := range tests {
t.Run(tc.key, func(t *testing.T) {
got, ok := defaults[tc.key]
if !ok {
t.Fatalf("expected key %q in factory defaults", tc.key)
}
if got != tc.want {
t.Errorf("factory default for %q = %q, want %q", tc.key, got, tc.want)
}
})
}
}
func TestGetFactoryDefaultsOmitsSensitiveMaterial(t *testing.T) {
defaults := (&SettingService{}).GetFactoryDefaults()
for _, key := range []string{
"secret",
"panelGuid",
"nodeMtlsCaCertPem",
"nodeMtlsCaKeyPem",
"nodeMtlsClientCertPem",
"nodeMtlsClientKeyPem",
"xrayTemplateConfig",
"tgBotToken",
"twoFactorToken",
"ldapPassword",
"smtpPassword",
} {
t.Run(key, func(t *testing.T) {
if _, ok := defaults[key]; ok {
t.Errorf("factory defaults must not expose %q", key)
}
})
}
}
func TestGetFactoryDefaultsInvariant(t *testing.T) {
defaults := (&SettingService{}).GetFactoryDefaults()
tags := allSettingJSONTags(t)
for key := range defaults {
t.Run(key, func(t *testing.T) {
if !tags[key] {
t.Errorf("key %q is not an entity.AllSetting json tag", key)
}
if factoryDefaultSecretKeys[key] {
t.Errorf("key %q is in the credential deny-list and must not be returned", key)
}
})
}
}
@@ -0,0 +1,39 @@
package service
import "testing"
func TestSubShowIdentityOnAllLinksDefaultsAndPersists(t *testing.T) {
setupSettingTestDB(t)
s := &SettingService{}
if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || got {
t.Fatalf("missing setting = %t, %v; want false, nil", got, err)
}
settings, err := s.GetAllSetting()
if err != nil {
t.Fatal(err)
}
if settings.SubShowIdentityOnAllLinks {
t.Fatal("GetAllSetting returned true for a missing setting")
}
settings.SubShowIdentityOnAllLinks = true
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
t.Fatal(err)
}
if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || !got {
t.Fatalf("persisted setting = %t, %v; want true, nil", got, err)
}
settings, err = s.GetAllSetting()
if err != nil {
t.Fatal(err)
}
settings.SubShowIdentityOnAllLinks = false
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
t.Fatal(err)
}
if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || got {
t.Fatalf("persisted setting = %t, %v; want false, nil", got, err)
}
}
+2 -2
View File
@@ -494,8 +494,8 @@ func (t *Tgbot) clientInfoMsg(
status := t.I18nBot("tgbot.offline")
isOnline := false
if service.XrayProcess().IsRunning() {
if slices.Contains(service.XrayProcess().GetOnlineClients(), traffic.Email) {
if process := service.XrayProcess(); process != nil && process.IsRunning() {
if slices.Contains(process.GetOnlineClients(), traffic.Email) {
status = t.I18nBot("tgbot.online")
isOnline = true
}
+23 -10
View File
@@ -48,8 +48,13 @@ func (t *Tgbot) SendBackupToAdmins() {
if !t.IsRunning() {
return
}
dbData, err := t.serverService.GetDb()
if err != nil {
logger.Error("Error in getting db backup: ", err)
}
dbFilename := t.serverService.BackupFilename("")
for i, adminId := range adminIds {
t.sendBackup(adminId)
t.sendBackupData(adminId, dbData, dbFilename)
// Add delay between sends to avoid Telegram rate limits
if i < len(adminIds)-1 {
time.Sleep(1 * time.Second)
@@ -105,7 +110,10 @@ func (t *Tgbot) prepareServerUsageInfo() string {
t.lastStatus = t.serverService.GetStatus(t.lastStatus)
t.setCachedStatus(t.lastStatus)
}
onlines := service.XrayProcess().GetOnlineClients()
var onlines []string
if process := service.XrayProcess(); process != nil {
onlines = process.GetOnlineClients()
}
info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
info += t.I18nBot("tgbot.messages.version", "Version=="+config.GetPanelVersion())
@@ -361,11 +369,12 @@ func (t *Tgbot) notifyExhausted() {
// onlineClients retrieves and sends information about online clients.
func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
if !service.XrayProcess().IsRunning() {
process := service.XrayProcess()
if process == nil || !process.IsRunning() {
return
}
onlines := service.XrayProcess().GetOnlineClients()
onlines := process.GetOnlineClients()
onlinesCount := len(onlines)
output := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(onlinesCount))
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
@@ -400,26 +409,30 @@ func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
// sendBackup sends a backup of the database and configuration files.
func (t *Tgbot) sendBackup(chatId int64) {
dbData, err := t.serverService.GetDb()
if err != nil {
logger.Error("Error in getting db backup: ", err)
}
t.sendBackupData(chatId, dbData, t.serverService.BackupFilename(""))
}
func (t *Tgbot) sendBackupData(chatId int64, dbData []byte, dbFilename string) {
output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
output += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
t.SendMsgToTgbot(chatId, output)
// Send database backup (SQLite file, or a pg_dump archive on PostgreSQL)
dbData, err := t.serverService.GetDb()
if err == nil {
dbFilename := t.serverService.BackupFilename("")
if dbData != nil {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
document := tu.Document(
tu.ID(chatId),
tu.FileFromBytes(dbData, dbFilename),
)
_, err = bot.SendDocument(ctx, document)
_, err := bot.SendDocument(ctx, document)
cancel()
if err != nil {
logger.Error("Error in uploading backup: ", err)
}
} else {
logger.Error("Error in getting db backup: ", err)
}
// Small delay between file sends
+97 -51
View File
@@ -21,13 +21,44 @@ import (
)
var (
p *xray.Process
lock sync.Mutex
isNeedXrayRestart atomic.Bool // Indicates that restart was requested for Xray
isManuallyStopped atomic.Bool // Indicates that Xray was stopped manually from the panel
result string
xrayState xrayLifecycle
)
type xrayLifecycle struct {
mu sync.RWMutex
process *xray.Process
result string
}
func (s *xrayLifecycle) snapshot() (*xray.Process, string) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.process, s.result
}
func (s *xrayLifecycle) replace(process *xray.Process) {
s.mu.Lock()
s.process = process
s.result = ""
s.mu.Unlock()
}
func (s *xrayLifecycle) storeResult(process *xray.Process, result string) {
s.mu.Lock()
if s.process == process && s.result == "" {
s.result = result
}
s.mu.Unlock()
}
func currentXrayProcess() *xray.Process {
process, _ := xrayState.snapshot()
return process
}
// XrayService provides business logic for Xray process management.
// It handles starting, stopping, restarting Xray, and managing its configuration.
type XrayService struct {
@@ -39,23 +70,25 @@ type XrayService struct {
// IsXrayRunning checks if the Xray process is currently running.
func (s *XrayService) IsXrayRunning() bool {
return p != nil && p.IsRunning()
process := currentXrayProcess()
return process != nil && process.IsRunning()
}
// XrayProcess returns the current Xray process instance (may be nil when Xray
// is not running). It exposes the package-level process to callers outside this
// package (e.g. the tgbot subpackage) without changing access semantics.
// is not running). It exposes the lifecycle snapshot to callers outside this
// package (e.g. the tgbot subpackage).
func XrayProcess() *xray.Process {
return p
return currentXrayProcess()
}
// GetXrayErr returns the error from the Xray process, if any.
func (s *XrayService) GetXrayErr() error {
if p == nil {
process := currentXrayProcess()
if process == nil {
return nil
}
err := p.GetErr()
err := process.GetErr()
if err == nil {
return nil
}
@@ -71,17 +104,15 @@ func (s *XrayService) GetXrayErr() error {
// GetXrayResult returns the result string from the Xray process.
func (s *XrayService) GetXrayResult() string {
if result != "" {
return result
process, cachedResult := xrayState.snapshot()
if cachedResult != "" {
return cachedResult
}
if s.IsXrayRunning() {
return ""
}
if p == nil {
if process == nil || process.IsRunning() {
return ""
}
result = p.GetResult()
result := process.GetResult()
if runtime.GOOS == "windows" && result == "exit status 1" {
// exit status 1 on Windows means that Xray process was killed
@@ -89,15 +120,17 @@ func (s *XrayService) GetXrayResult() string {
return ""
}
xrayState.storeResult(process, result)
return result
}
// GetXrayVersion returns the version of the running Xray process.
func (s *XrayService) GetXrayVersion() string {
if p == nil {
process := currentXrayProcess()
if process == nil {
return "Unknown"
}
return p.GetXrayVersion()
return process.GetXrayVersion()
}
// RemoveIndex removes an element at the specified index from a slice.
@@ -157,7 +190,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
enableMap[clientTraffic.Email] = clientTraffic.Enable
}
var finalClients []any
finalClients := make([]any, 0, len(dbClients))
var wgPeers []any
for i := range dbClients {
c := dbClients[i]
@@ -282,6 +315,12 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
delete(stream, "finalmask")
}
dropEmptyRandPackets(stream["finalmask"])
if dropped := stripIncompleteXmcMasks(stream); dropped > 0 {
logger.Warningf("Inbound %q: dropping %d XMC finalmask mask(s) without complete Minecraft profiles — reconfigure them to restore the obfuscation (see XTLS/Xray-core#6487)", inbound.Tag, dropped)
}
// xray-core v26.6.22 (#6258) renamed the XHTTP session keys and
// kept no fallback. Lift legacy sessionPlacement/sessionKey onto the
// new names here so inbounds stored before the rename keep working
@@ -937,12 +976,13 @@ func stripDisabledRules(routerCfg json_util.RawMessage) json_util.RawMessage {
// GetXrayTraffic fetches the current traffic statistics from the running Xray process.
func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) {
if !s.IsXrayRunning() {
process := currentXrayProcess()
if process == nil || !process.IsRunning() {
err := errors.New("xray is not running")
logger.Debug("Attempted to fetch Xray traffic, but Xray is not running:", err)
return nil, nil, err
}
apiPort := p.GetAPIPort()
apiPort := process.GetAPIPort()
if err := s.xrayAPI.Init(apiPort); err != nil {
logger.Debug("Failed to initialize Xray API:", err)
return nil, nil, err
@@ -965,13 +1005,14 @@ func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic,
// core as unsupported until the next restart, while transient errors leave the
// capability undecided so a flaky poll can't lock in legacy mode.
func (s *XrayService) GetOnlineUsers() ([]xray.OnlineUser, bool, error) {
if !s.IsXrayRunning() {
process := currentXrayProcess()
if process == nil || !process.IsRunning() {
return nil, false, nil
}
if p.OnlineAPISupport() == xray.OnlineAPIUnsupported {
if process.OnlineAPISupport() == xray.OnlineAPIUnsupported {
return nil, false, nil
}
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
logger.Debug("Failed to initialize Xray API:", err)
return nil, false, err
}
@@ -980,15 +1021,15 @@ func (s *XrayService) GetOnlineUsers() ([]xray.OnlineUser, bool, error) {
users, err := s.xrayAPI.GetOnlineUsers()
if err != nil {
if xray.IsUnimplementedErr(err) {
p.SetOnlineAPISupport(xray.OnlineAPIUnsupported)
process.SetOnlineAPISupport(xray.OnlineAPIUnsupported)
logger.Info("xray core does not support the online-stats API; falling back to traffic-delta onlines and access-log IP limit")
return nil, false, nil
}
logger.Debug("Failed to fetch Xray online users:", err)
return nil, false, err
}
if p.OnlineAPISupport() == xray.OnlineAPIUnknown {
p.SetOnlineAPISupport(xray.OnlineAPISupported)
if process.OnlineAPISupport() == xray.OnlineAPIUnknown {
process.SetOnlineAPISupport(xray.OnlineAPISupported)
logger.Info("xray core supports the online-stats API; using connection-based onlines and access-log-free IP limit")
}
return users, true, nil
@@ -1010,13 +1051,14 @@ type BalancerStatus struct {
// balancers alongside live ones.
func (s *XrayService) GetBalancersStatus(tags []string) ([]BalancerStatus, error) {
statuses := make([]BalancerStatus, 0, len(tags))
if !s.IsXrayRunning() {
process := currentXrayProcess()
if process == nil || !process.IsRunning() {
for _, tag := range tags {
statuses = append(statuses, BalancerStatus{Tag: tag})
}
return statuses, nil
}
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
return nil, err
}
defer s.xrayAPI.Close()
@@ -1043,7 +1085,8 @@ func (s *XrayService) GetBalancersStatus(tags []string) ([]BalancerStatus, error
// another balancer, the override resolves to the loopback outbound that
// routes traffic through the target balancer via the routing rules.
func (s *XrayService) OverrideBalancer(tag, target string) error {
if !s.IsXrayRunning() {
process := currentXrayProcess()
if process == nil || !process.IsRunning() {
return errors.New("xray is not running")
}
if target != "" {
@@ -1055,7 +1098,7 @@ func (s *XrayService) OverrideBalancer(tag, target string) error {
target = resolved
}
}
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
return err
}
defer s.xrayAPI.Close()
@@ -1101,10 +1144,11 @@ func (s *XrayService) resolveOverrideTarget(target string) (string, error) {
// TestRoute asks the running core which outbound its router picks for the
// described connection.
func (s *XrayService) TestRoute(req xray.RouteTestRequest) (*xray.RouteTestResult, error) {
if !s.IsXrayRunning() {
process := currentXrayProcess()
if process == nil || !process.IsRunning() {
return nil, errors.New("xray is not running")
}
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
if err := s.xrayAPI.Init(process.GetAPIPort()); err != nil {
return nil, err
}
defer s.xrayAPI.Close()
@@ -1130,23 +1174,24 @@ func (s *XrayService) RestartXray(isForce bool) error {
return err
}
if s.IsXrayRunning() {
configUnchanged := p.GetConfig().Equals(xrayConfig)
process := currentXrayProcess()
if process != nil && process.IsRunning() {
configUnchanged := process.GetConfig().Equals(xrayConfig)
if !isForce && configUnchanged && !isNeedXrayRestart.Load() {
logger.Debug("It does not need to restart Xray")
return nil
}
if !isForce && !configUnchanged && s.tryHotApply(xrayConfig) {
if !isForce && !configUnchanged && s.tryHotApply(process, xrayConfig) {
logger.Info("Xray config changes applied through the core API, no restart needed")
return nil
}
_ = p.Stop()
_ = process.Stop()
}
p = xray.NewProcess(xrayConfig)
result = ""
process = xray.NewProcess(xrayConfig)
xrayState.replace(process)
s.xrayAPI.StatsLastValues = nil
err = p.Start()
err = process.Start()
if err != nil {
return err
}
@@ -1160,19 +1205,19 @@ func (s *XrayService) RestartXray(isForce bool) error {
// instance now matches newCfg; on any failure it returns false and the
// caller falls back to a full process restart, which cleans up whatever was
// partially applied. Callers must hold the package-level lock.
func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
oldCfg := p.GetConfig()
func (s *XrayService) tryHotApply(process *xray.Process, newCfg *xray.Config) bool {
oldCfg := process.GetConfig()
diff, ok := xray.ComputeHotDiff(oldCfg, newCfg)
if !ok {
logger.Debug("hot apply: config change is not API-applicable, falling back to restart")
return false
}
if diff.Empty() {
p.SetConfig(newCfg)
process.SetConfig(newCfg)
return true
}
apiPort := p.GetAPIPort()
apiPort := process.GetAPIPort()
if apiPort <= 0 {
return false
}
@@ -1230,7 +1275,7 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
}
}
p.SetConfig(newCfg)
process.SetConfig(newCfg)
return true
}
@@ -1291,8 +1336,9 @@ func (s *XrayService) StopXray() error {
defer lock.Unlock()
isManuallyStopped.Store(true)
logger.Debug("Attempting to stop Xray...")
if s.IsXrayRunning() {
return p.Stop()
process := currentXrayProcess()
if process != nil && process.IsRunning() {
return process.Stop()
}
return errors.New("xray is not running")
}
@@ -1304,14 +1350,14 @@ func (s *XrayService) SetToNeedRestart() {
// GetXrayAPIPort returns the port the local xray process is listening on
// for its gRPC HandlerService, or 0 when xray isn't currently running.
// Exposed for the runtime package's LocalRuntime adapter — runtime can't
// reach into the package-level `p` directly without a service-package
// import cycle.
// Exposed for the runtime package's LocalRuntime adapter without a
// service-package import cycle.
func (s *XrayService) GetXrayAPIPort() int {
if p == nil || !p.IsRunning() {
process := currentXrayProcess()
if process == nil || !process.IsRunning() {
return 0
}
return p.GetAPIPort()
return process.GetAPIPort()
}
// IsNeedRestartAndSetFalse checks if restart is needed and resets the flag to false.
@@ -0,0 +1,109 @@
package service
import (
"encoding/json"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func seedVlessInbound(t *testing.T, tag string, port int, clients []model.Client) {
t.Helper()
setupSettingTestDB(t)
db := database.GetDB()
in := &model.Inbound{
Tag: tag,
Enable: true,
Port: port,
Protocol: model.VLESS,
Settings: `{"clients":[],"decryption":"none"}`,
}
if err := db.Create(in).Error; err != nil {
t.Fatalf("create vless inbound: %v", err)
}
svc := ClientService{}
if err := svc.SyncInbound(nil, in.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
}
func disableClients(t *testing.T, emails ...string) {
t.Helper()
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("email IN ?", emails).
Update("enable", false).Error; err != nil {
t.Fatalf("disable clients: %v", err)
}
}
func emittedClients(t *testing.T, tag string) (any, bool) {
t.Helper()
svc := &XrayService{}
cfg, err := svc.GetXrayConfig()
if err != nil {
t.Fatalf("GetXrayConfig: %v", err)
}
for i := range cfg.InboundConfigs {
if cfg.InboundConfigs[i].Tag != tag {
continue
}
var s map[string]any
if err := json.Unmarshal([]byte(cfg.InboundConfigs[i].Settings), &s); err != nil {
t.Fatalf("unmarshal emitted settings: %v", err)
}
v, ok := s["clients"]
return v, ok
}
t.Fatalf("inbound %q not found in generated config", tag)
return nil, false
}
func TestGetXrayConfig_EmptyClientListIsArrayNotNull(t *testing.T) {
seedVlessInbound(t, "vless-empty", 43101, []model.Client{
{Email: "gone@x", ID: "11111111-1111-1111-1111-111111111111", Enable: true},
})
disableClients(t, "gone@x")
value, present := emittedClients(t, "vless-empty")
if !present {
t.Fatal("the clients key must be present on a vless inbound")
}
if value == nil {
t.Fatal(`settings.clients must be [] when every client is filtered out, got null`)
}
list, ok := value.([]any)
if !ok {
t.Fatalf("settings.clients must be an array, got %T", value)
}
if len(list) != 0 {
t.Fatalf("a disabled client must not reach the config, got %d entries", len(list))
}
}
func TestGetXrayConfig_EnabledClientsStillEmitted(t *testing.T) {
seedVlessInbound(t, "vless-mixed", 43102, []model.Client{
{Email: "live@x", ID: "22222222-2222-2222-2222-222222222222", Enable: true},
{Email: "off@x", ID: "33333333-3333-3333-3333-333333333333", Enable: true},
})
disableClients(t, "off@x")
value, _ := emittedClients(t, "vless-mixed")
list, ok := value.([]any)
if !ok {
t.Fatalf("settings.clients must be an array, got %T", value)
}
if len(list) != 1 {
t.Fatalf("expected only the enabled client, got %d entries: %#v", len(list), list)
}
entry, ok := list[0].(map[string]any)
if !ok {
t.Fatalf("client entry must be an object, got %T", list[0])
}
if entry["email"] != "live@x" {
t.Errorf("wrong client emitted: %#v", entry)
}
if entry["id"] != "22222222-2222-2222-2222-222222222222" {
t.Errorf("client id not carried through: %#v", entry)
}
}
@@ -0,0 +1,68 @@
package service
import (
"sync"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func TestXrayLifecycleSnapshotDoesNotOverwriteNewerResult(t *testing.T) {
state := xrayLifecycle{}
first := xray.NewProcess(&xray.Config{})
second := xray.NewProcess(&xray.Config{})
state.replace(first)
state.storeResult(first, "first result")
process, result := state.snapshot()
if process != first || result != "first result" {
t.Fatalf("snapshot = (%p, %q), want (%p, %q)", process, result, first, "first result")
}
state.replace(second)
state.storeResult(first, "old result")
process, result = state.snapshot()
if process != second {
t.Fatal("snapshot returned the replaced process")
}
if result != "" {
t.Fatalf("snapshot result = %q, want empty", result)
}
}
func TestXrayLifecycleConcurrentStatusResultAndTrafficReads(t *testing.T) {
previousProcess, previousResult := xrayState.snapshot()
t.Cleanup(func() {
xrayState.mu.Lock()
xrayState.process = previousProcess
xrayState.result = previousResult
xrayState.mu.Unlock()
})
first := xray.NewProcess(&xray.Config{})
second := xray.NewProcess(&xray.Config{})
service := XrayService{}
var wg sync.WaitGroup
wg.Go(func() {
for range 200 {
xrayState.replace(first)
xrayState.replace(second)
}
})
for range 4 {
wg.Go(func() {
for range 200 {
_ = service.IsXrayRunning()
_ = service.GetXrayResult()
_, _, err := service.GetXrayTraffic()
if err == nil || err.Error() != "xray is not running" {
t.Errorf("GetXrayTraffic error = %v, want xray is not running", err)
}
}
})
}
wg.Wait()
}
+2 -2
View File
@@ -62,8 +62,8 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
return common.NewError("xray template config invalid: outbounds is not an array:", err)
}
coreVersion := "Unknown"
if p != nil {
coreVersion = p.GetXrayVersion()
if process := currentXrayProcess(); process != nil {
coreVersion = process.GetXrayVersion()
}
for _, outbound := range outbounds {
if err := xray.ValidateOutboundConfig(outbound); err != nil {
+25 -227
View File
@@ -48,7 +48,6 @@
"copySuccess": "اتنسخ بنجاح",
"sure": "متأكد؟",
"encryption": "تشفير",
"useIPv4ForHost": "استخدم IPv4 للمضيف",
"transmission": "نقل",
"host": "المضيف",
"path": "المسار",
@@ -74,18 +73,9 @@
"twoFactorCode": "الكود",
"remained": "المتبقي",
"security": "أمان",
"secAlertTitle": "تنبيه أمني",
"secAlertSsl": "الاتصال ده مش آمن. ابعد عن إدخال معلومات حساسة لغاية ما تشغل TLS لحماية البيانات.",
"secAlertConf": "بعض الإعدادات معرضة لهجمات. ينصح بتعزيز بروتوكولات الأمان عشان تمنع الاختراقات المحتملة.",
"secAlertSSL": "البانل مش مؤمن. حمّل شهادة TLS لحماية البيانات.",
"secAlertPanelPort": "بورت البانل الافتراضي معرض للخطر. ياريت تغير لبورت عشوائي أو محدد.",
"secAlertPanelURI": "مسار URI الافتراضي للبانل مش آمن. ياريت تضبط مسار URI معقد.",
"secAlertSubURI": "مسار URI الافتراضي للاشتراك مش آمن. ياريت تضبط مسار URI معقد.",
"secAlertSubJsonURI": "مسار URI الافتراضي لاشتراك JSON مش آمن. ياريت تضبط مسار URI معقد.",
"emptyDnsDesc": "مفيش سيرفر DNS مضاف.",
"emptyFakeDnsDesc": "مفيش سيرفر Fake DNS مضاف.",
"emptyBalancersDesc": "مفيش موازن تحميل مضاف.",
"emptyReverseDesc": "مفيش بروكسي عكسي مضاف.",
"somethingWentWrong": "حدث خطأ ما",
"subscription": {
"title": "معلومات الاشتراك",
@@ -106,8 +96,6 @@
},
"menu": {
"theme": "الثيم",
"dark": "داكن",
"ultraDark": "داكن جدًا",
"dashboard": "نظرة عامة",
"inbounds": "الواردات",
"clients": "العملاء",
@@ -118,12 +106,12 @@
"routing": "التوجيه",
"outbounds": "الصادرات",
"apiDocs": "توثيق API",
"logout": "تسجيل خروج",
"link": "إدارة",
"donate": "تبرع",
"hosts": "المضيفات",
"docs": "التوثيق",
"openMenu": "فتح القائمة"
"openMenu": "فتح القائمة",
"pinSidebar": "تثبيت الشريط الجانبي",
"unpinSidebar": "إلغاء تثبيت الشريط الجانبي"
},
"pages": {
"login": {
@@ -139,20 +127,15 @@
}
},
"index": {
"title": "نظرة عامة",
"cpu": "المعالج",
"logicalProcessors": "المعالجات المنطقية",
"frequency": "التردد",
"swap": "التبديل",
"storage": "تخزين",
"memory": "الذاكرة",
"threads": "خيوط",
"xrayStatus": "Xray",
"stopXray": "إيقاف",
"restartXray": "إعادة تشغيل",
"xraySwitch": "النسخة",
"xrayUpdates": "تحديثات Xray",
"xraySwitchClick": "اختار النسخة اللي عايز تتحول لها.",
"xraySwitchClickDesk": "اختار بحذر، النسخ القديمة ممكن ما تتوافقش مع الإعدادات الحالية.",
"updatePanel": "تحديث البانل",
"panelUpdateDesc": "ده هيحدث 3X-UI لآخر إصدار وهيعيد تشغيل خدمة البانل.",
@@ -164,13 +147,10 @@
"currentCommit": "الكومِت الحالي",
"latestCommit": "أحدث كومِت",
"updateChannelChanged": "تم تغيير قناة التحديث",
"upToDate": "محدث",
"xrayStatusUnknown": "مش معروف",
"xrayStatusRunning": "شغالة",
"xrayStatusStop": "متوقفة",
"xrayStatusError": "خطأ",
"xrayErrorPopoverTitle": "حصل خطأ أثناء تشغيل Xray",
"operationHours": "مدة التشغيل",
"systemHistoryTitle": "تاريخ النظام",
"historyTitleCpu": "استخدام المعالج",
"historyTitleMem": "استخدام الذاكرة",
@@ -188,7 +168,6 @@
"historyTabLoad": "الحِمل",
"historyTabConnections": "الاتصالات",
"historyTabDiskUsage": "استخدام القرص",
"charts": "الرسوم البيانية",
"xrayMetricsTitle": "مقاييس Xray",
"xrayTitleHeap": "ذاكرة الكومة المخصصة",
"xrayTitleSys": "الذاكرة المحجوزة من نظام التشغيل",
@@ -211,25 +190,19 @@
"xrayObservatoryDead": "غير متصل",
"xrayObservatoryLastSeen": "آخر مشاهدة",
"xrayObservatoryLastTry": "آخر محاولة",
"trendLast2Min": "آخر دقيقتين",
"systemLoad": "تحميل النظام",
"systemLoadDesc": "متوسط تحميل النظام في الدقائق 1, 5, و15",
"connectionCount": "إحصائيات الاتصال",
"ipAddresses": "عناوين IP",
"toggleIpVisibility": "بدل إظهار IP",
"overallSpeed": "السرعة الكلية",
"upload": "رفع",
"download": "تنزيل",
"totalData": "إجمالي البيانات",
"sent": "مرسل",
"received": "مستقبل",
"documentation": "التوثيق",
"xraySwitchVersionDialog": "هل تريد حقًا تغيير إصدار Xray؟",
"xraySwitchVersionDialogDesc": "سيؤدي هذا إلى تغيير إصدار Xray إلى #version#.",
"xraySwitchVersionPopover": "تم تحديث Xray بنجاح",
"panelUpdateDialog": "هل فعلاً عايز تحدث البانل؟",
"panelUpdateDialogDesc": "ده هيحدث 3X-UI للإصدار #version# وهيعيد تشغيل البانل.",
"panelUpdateCheckPopover": "فشل التحقق من تحديث البانل",
"panelUpdateStartedPopover": "بدأ تحديث البانل",
"panelUpdateFailedTitle": "فشل تحديث البانل",
"panelUpdateFailedDesc": "لم يكتمل التحديث بنجاح. تحقق من سجلات الخادم، أو نفّذ الأمر «x-ui update» من سطر الأوامر.",
@@ -258,7 +231,6 @@
"accessLogs": "سجلات الوصول",
"autoUpdate": "تحديث تلقائي",
"config": "الإعدادات",
"backup": "نسخ احتياطي",
"backupTitle": "نسخ احتياطي واستعادة",
"exportDatabase": "اخزن نسخة",
"exportDatabaseDesc": "اضغط عشان تحمل ملف .db يحتوي على نسخة احتياطية لقاعدة البيانات الحالية على جهازك. نفس الملف ممكن كمان يترجع على لوحة شغالة بـ PostgreSQL.",
@@ -273,10 +245,20 @@
"exportDatabasePgDesc": "انقر لتنزيل نسخة PostgreSQL (.dump) من قاعدة بياناتك الحالية إلى جهازك.",
"importDatabasePgDesc": "انقر لاختيار ورفع نسخة احتياطية من PostgreSQL (.dump) أو قاعدة بيانات SQLite (.db) أو ملف ترحيل SQLite لاستعادة قاعدة البيانات. سيؤدي هذا إلى استبدال جميع البيانات الحالية.",
"migrationDownload": "تنزيل ملف الترحيل",
"migrationDownloadPgDesc": "انقر لتنزيل قاعدة بيانات SQLite بامتداد .db مبنية من بيانات PostgreSQL الخاصة بك، جاهزة لتشغيل هذه اللوحة على SQLite."
"migrationDownloadPgDesc": "انقر لتنزيل قاعدة بيانات SQLite بامتداد .db مبنية من بيانات PostgreSQL الخاصة بك، جاهزة لتشغيل هذه اللوحة على SQLite.",
"avg": "متوسط",
"peak": "ذروة",
"free": "متاح",
"openSockets": "مقابس مفتوحة",
"throughputSub": "إجمالي الواجهة",
"avgWindow": "متوسط الفترة",
"healthWarm": "{list} — مرتفع قليلاً",
"healthCritical": "{list} — حرج",
"panel": "اللوحة",
"threads": "الخيوط",
"uptime": "مدة التشغيل"
},
"inbounds": {
"title": "الواردات",
"totalDownUp": "إجمالي المرسل/المستقبل",
"totalUsage": "إجمالي الاستخدام",
"inboundCount": "عدد الإدخالات",
@@ -288,21 +270,11 @@
"localPanel": "بانل محلي",
"fallbacks": {
"title": "Fallbacks",
"help": "عند وصول اتصال إلى هذا الـ inbound لا يطابق أي عميل، يتم توجيهه إلى مكان آخر. اختر inbound فرعيًا أدناه لملء حقول التوجيه (SNI / ALPN / Path / xver) تلقائيًا من نقله، أو اترك القائمة فارغة واضبط Dest مباشرةً (مثل 8080 أو 127.0.0.1:8080) للتوجيه إلى خادم خارجي مثل Nginx. يجب أن يستمع كل inbound فرعي على 127.0.0.1 مع security=none.",
"empty": "لا توجد fallbacks بعد",
"add": "إضافة fallback",
"pickInbound": "اختر inbound",
"matchAny": "أي",
"destPlaceholder": "تلقائي (listen:port للفرع)",
"rederive": "إعادة الملء من الفرع",
"rederived": "تم إعادة الملء من الفرع",
"editAdvanced": "تحرير حقول التوجيه",
"hideAdvanced": "إخفاء المتقدم",
"quickAddAll": "إضافة سريعة لكل الـ inbounds المؤهلة",
"quickAdded": "تمت إضافة {n} fallback",
"quickAddedNone": "لا توجد inbounds جديدة مؤهلة للإضافة",
"routesWhen": "يوجَّه عندما",
"defaultCatchAll": "افتراضي — يلتقط أي شيء آخر",
"needsTls": "تصبح الـ Fallbacks متاحة بعد اختيار TLS أو Reality في تبويب الأمان (فقط VLESS/Trojan عبر RAW)."
},
"protocol": "بروتوكول",
@@ -310,8 +282,6 @@
"portMap": "تعيين المنفذ",
"traffic": "حركة المرور",
"speed": "السرعة",
"details": "تفاصيل",
"transportConfig": "النقل",
"expireDate": "المدة",
"createdAt": "تاريخ الإنشاء",
"updatedAt": "تاريخ التحديث",
@@ -319,8 +289,6 @@
"addInbound": "أضف إدخال",
"generalActions": "إجراءات عامة",
"modifyInbound": "تعديل الإدخال",
"deleteInbound": "حذف الإدخال",
"deleteInboundContent": "متأكد إنك عايز تحذف الإدخال؟",
"deleteConfirmTitle": "حذف الإدخال \"{remark}\"؟",
"deleteConfirmContent": "سيؤدي هذا إلى إزالة الإدخال وجميع عملائه. لا يمكن التراجع.",
"resetConfirmTitle": "إعادة تعيين ترافيك \"{remark}\"؟",
@@ -364,8 +332,6 @@
"exportAllLinksFileName": "جميع-الواردات",
"exportAllSubsFileName": "جميع-الواردات-Subs",
"inboundJsonTitle": "JSON الوارد",
"deleteClient": "حذف العميل",
"deleteClientContent": "متأكد إنك عايز تحذف العميل؟",
"resetTrafficContent": "متأكد إنك عايز تعيد ضبط الترافيك؟",
"copyLink": "انسخ الرابط",
"address": "العنوان",
@@ -376,36 +342,19 @@
"meansNoLimit": "= غير محدود. (الوحدة: GB)",
"totalFlow": "إجمالي التدفق",
"leaveBlankToNeverExpire": "سيبها فاضية عشان ماتنتهيش",
"noRecommendKeepDefault": "ننصح باستخدام الافتراضي",
"certificatePath": "مسار الملف",
"certificateContent": "محتوى الملف",
"publicKey": "المفتاح العام",
"privatekey": "المفتاح الخاص",
"clickOnQRcode": "اضغط على كود QR للنسخ",
"client": "عميل",
"export": "تصدير كل الروابط",
"clone": "استنساخ",
"cloneInbound": "استنساخ الإدخال",
"cloneInboundContent": "كل إعدادات الإدخال ده، غير البورت، IP الاستماع، والعملاء، هتتطبق على الاستنساخ.",
"cloneInboundOk": "استنساخ",
"resetAllTraffic": "إعادة ضبط ترافيك كل الإدخالات",
"resetAllTrafficTitle": "إعادة ضبط ترافيك كل الإدخالات",
"resetAllTrafficContent": "متأكد إنك عايز تعيد ضبط الترافيك لكل الإدخالات؟",
"resetInboundClientTraffics": "إعادة ضبط ترافيك العملاء",
"resetInboundClientTrafficTitle": "إعادة ضبط ترافيك العملاء",
"resetInboundClientTrafficContent": "متأكد إنك عايز تعيد ضبط ترافيك عملاء الإدخال ده؟",
"resetAllClientTraffics": "إعادة ضبط ترافيك كل العملاء",
"resetAllClientTrafficTitle": "إعادة ضبط ترافيك كل العملاء",
"resetAllClientTrafficContent": "متأكد إنك عايز تعيد ضبط ترافيك كل العملاء؟",
"delDepletedClients": "حذف العملاء اللي خلصت",
"delDepletedClientsTitle": "حذف العملاء اللي خلصت",
"delDepletedClientsContent": "متأكد إنك عايز تحذف كل العملاء اللي خلصت؟",
"email": "البريد",
"emailDesc": "ادخل إيميل فريد.",
"IPLimit": "تحديد IP",
"IPLimitDesc": "بيعطل الإدخال لو العدد زاد عن القيمة المحددة. (0 = تعطيل)",
"IPLimitlog": "سجل IP",
"IPLimitlogDesc": "سجل تاريخ الـ IPs. (عشان تفعل الإدخال بعد التعطيل، امسح السجل)",
"IPLimitlogclear": "امسح السجل",
"setDefaultCert": "استخدم شهادة البانل",
"setDefaultCertEmpty": "لا توجد شهادة معدّة للوحة. عينّ واحدة من الإعدادات أولاً.",
@@ -438,20 +387,15 @@
"sniffing": "Sniffing",
"sniffingHelp": "غلاف كتلة sniffing في Xray:",
"stream": "Stream",
"streamHelp": "غلاف كتلة stream في Xray:",
"jsonErrorPrefix": "JSON متقدم"
"streamHelp": "غلاف كتلة stream في Xray:"
},
"telegramDesc": "ادخل ID شات Telegram. (استخدم '/id' في البوت) أو ({'@'}userinfobot)",
"subscriptionDesc": "عشان تلاقي رابط الاشتراك، ادخل على 'التفاصيل'. وكمان ممكن تستخدم نفس الاسم لعدة عملاء.",
"subSortIndex": "ترتيب الاشتراك",
"same": "نفسه",
"inboundInfo": "معلومات الإدخال",
"exportInbound": "تصدير الإدخال",
"import": "استيراد",
"importInbound": "استيراد إدخال",
"periodicTrafficResetTitle": "إعادة تعيين حركة المرور",
"periodicTrafficResetDesc": "إعادة تعيين عداد حركة المرور تلقائيًا في فترات محددة",
"lastReset": "آخر إعادة تعيين",
"periodicTrafficResetDay": "يوم إعادة التعيين الشهري",
"periodicTrafficReset": {
"never": "أبداً",
"daily": "يومياً",
@@ -463,7 +407,6 @@
"obtain": "تم الحصول عليه",
"updateSuccess": "تم التحديث بنجاح",
"logCleanSuccess": "تم مسح السجل",
"inboundsUpdateSuccess": "تم تحديث الواردات بنجاح",
"inboundUpdateSuccess": "تم تحديث الوارد بنجاح",
"inboundCreateSuccess": "تم إنشاء الوارد بنجاح",
"bulkDeleted": "تم حذف {count} إدخال",
@@ -473,7 +416,6 @@
"inboundClientDeleteSuccess": "تم حذف عميل وارد",
"inboundClientUpdateSuccess": "تم تحديث عميل وارد",
"savedNodeOfflineWillSync": "تم الحفظ محليًا. إحدى العُقد الداعمة غير متصلة أو معطّلة — ستتم مزامنة التغيير بمجرد إعادة الاتصال.",
"delDepletedClientsSuccess": "تم حذف جميع العملاء المستنفذين",
"resetAllClientTrafficSuccess": "تم إعادة تعيين كل حركة المرور من العميل",
"resetAllTrafficSuccess": "تم إعادة تعيين كل حركة المرور",
"resetInboundClientTrafficSuccess": "تم إعادة تعيين حركة المرور",
@@ -636,6 +578,10 @@
"maxTimeDiff": "أقصى فرق زمن (ms)",
"minClientVer": "أدنى إصدار للعميل",
"maxClientVer": "أقصى إصدار للعميل",
"minClientVerHint": "تركه فارغًا لا يعني بلا قيود: سيفرض Xray-core الحد الأدنى المدمج في إصدار النواة الذي تشغّله (26.3.27 في الإصدارات الحالية) ويرفض العملاء الذين يبلغون عن إصدار أقدم — بما في ذلك النوى الخارجية مثل Mihomo و sing-box. القيمة 1.0.0 تقبلها، مقابل السماح ببصمات TLS قديمة.",
"maxClientVerHint": "تركه فارغًا يعني بلا حد أقصى. إذا عُيّن، يجب ألا يقل عن الحد الأدنى الفعلي — أدنى إصدار للعميل، أو الحد الأدنى المدمج في Xray-core عندما يكون ذلك الحقل فارغًا — وإلا سيُرفض جميع العملاء.",
"clientVerInvalid": "يجب أن يتكون إصدار العميل من ثلاثة أرقام كحد أقصى مفصولة بنقاط، كل منها 0-255 (مثل 26.3.27)",
"maxClientVerBelowMin": "أقصى إصدار للعميل يجب ألا يقل عن أدنى إصدار للعميل",
"shortIds": "Short IDs",
"realityTargetHint": "مطلوب. يجب أن يتضمّن منفذًا (مثل example.com:443). بدون منفذ يرفض Xray-core البدء.",
"realityTargetRequired": "هدف REALITY مطلوب",
@@ -712,23 +658,6 @@
"peerNumber": "Peer {n}",
"peerNumberConfig": "تكوين Peer {n}"
},
"stream": {
"general": {
"request": "طلب",
"response": "رد",
"name": "اسم",
"value": "قيمة"
},
"tcp": {
"version": "نسخة",
"method": "طريقة",
"path": "المسار",
"status": "الحالة",
"statusDescription": "وصف الحالة",
"requestHeader": "رأس الطلب",
"responseHeader": "رأس الرد"
}
},
"sniffingDestOverride": "تجاوز الوجهة"
},
"clients": {
@@ -742,24 +671,9 @@
"addExternalSubscription": "إضافة اشتراك خارجي",
"noExternalLinks": "لا توجد روابط خارجية بعد.",
"noExternalSubscriptions": "لا توجد اشتراكات خارجية بعد.",
"add": "إضافة عميل",
"edit": "تعديل العميل",
"submitAdd": "إضافة عميل",
"submitEdit": "حفظ التغييرات",
"clientCount": "عدد العملاء",
"bulk": "إضافة مجمعة",
"copyFromInbound": "نسخ العملاء من الاتصال الوارد",
"copyToInbound": "نسخ العملاء إلى",
"copySelected": "نسخ المحدد",
"copySource": "المصدر",
"copyEmailPreview": "معاينة البريد الناتج",
"copySelectSourceFirst": "يرجى تحديد اتصال وارد مصدر أولاً.",
"copyResult": "نتيجة النسخ",
"copyResultSuccess": "تم النسخ بنجاح",
"copyResultNone": "لا شيء للنسخ: لم يتم تحديد عملاء أو أن المصدر فارغ",
"copyResultErrors": "أخطاء النسخ",
"copyFlowLabel": "Flow للعملاء الجدد (VLESS)",
"copyFlowHint": "يُطبَّق على جميع العملاء المنسوخين. اتركه فارغًا للتخطي.",
"selectAll": "تحديد الكل",
"clearAll": "مسح الكل",
"method": "الطريقة",
@@ -770,7 +684,6 @@
"postfix": "لاحقة",
"delayedStart": "البدء بعد أول استخدام",
"expireDays": "المدة (أيام)",
"days": "يوم",
"renew": "تجديد تلقائي",
"renewDesc": "تجديد تلقائي بعد انتهاء الصلاحية. (0 = تعطيل) (الوحدة: يوم)",
"renewDays": "تجديد تلقائي (أيام)",
@@ -793,7 +706,6 @@
"sortExpiringSoonest": "الأقرب انتهاءً",
"has": "يملك",
"hasNot": "لا يملك",
"title": "العملاء",
"actions": "الإجراءات",
"totalGB": "حد البيانات (جيجابايت)",
"totalGBDesc": "حصة البيانات لهذا العميل. 0 = غير محدود.",
@@ -821,8 +733,6 @@
"addClient": "إضافة عميل",
"qrCode": "رمز QR",
"clientInfo": "معلومات العميل",
"delete": "حذف",
"reset": "إعادة ضبط حركة المرور",
"editClient": "تعديل العميل",
"client": "العميل",
"enabled": "مفعّل",
@@ -836,13 +746,11 @@
"noLinks": "لا توجد روابط للمشاركة — قم بإرفاق هذا العميل بأحد الاتصالات الواردة الداعمة للبروتوكول أولاً.",
"link": "الرابط",
"resetNotPossible": "قم بإرفاق هذا العميل بأحد الاتصالات الواردة أولاً.",
"general": "عام",
"resetAllTraffics": "إعادة ضبط حركة مرور كل العملاء",
"resetAllTrafficsTitle": "إعادة ضبط حركة مرور كل العملاء؟",
"resetAllTrafficsContent": "يُعاد ضبط عدّاد الإرسال/الاستقبال لكل عميل إلى الصفر. لا تتأثر الحصص ومواعيد الانتهاء. لا يمكن التراجع.",
"deleteConfirmTitle": "حذف العميل {email}؟",
"deleteConfirmContent": "سيؤدي هذا إلى إزالة العميل من جميع الاتصالات الواردة المرتبطة وحذف سجل حركة مروره. لا يمكن التراجع.",
"deleteSelected": "حذف ({count})",
"adjustSelected": "تعديل ({count})",
"subLinksSelected": "روابط الاشتراك ({count})",
"addToGroupTitle": "إضافة {count} عميل إلى مجموعة",
@@ -864,12 +772,10 @@
"bulkDisableConfirmTitle": "تعطيل {count} عميل؟",
"bulkDisableConfirmContent": "يُعطّل كل عميل محدد على جميع الإدخالات المرفقة. يفقدون الوصول فورًا لكن تُحفَظ سجلاتهم وحركة بياناتهم.",
"selectedCount": "{count} محدد",
"attachSelected": "إرفاق ({count})",
"attachToInboundsTitle": "إرفاق {count} عميل بالواردات",
"attachToInboundsDesc": "يربط {count} عميل المحدد (نفس UUID/كلمة المرور والمرور المشترك) بالواردات المختارة. يحتفظون بارتباطاتهم الحالية.",
"attachToInboundsTargets": "الواردات الهدف",
"attachToInboundsNoTargets": "لا توجد واردات متعددة المستخدمين للارتباط.",
"detachSelected": "فصل ({count})",
"detach": "فصل",
"detachFromInboundsTitle": "فصل {count} عميل من الواردات",
"detachFromInboundsDesc": "يزيل {count} عميل المحدد من الواردات المختارة. الأزواج التي لم يكن العميل مرتبطاً بها يتم تخطيها بصمت. تُحفظ سجلات العملاء (استخدم Delete للإزالة الكاملة).",
@@ -931,8 +837,6 @@
"reverseTagPlaceholder": "Reverse tag اختياري",
"telegramId": "معرّف مستخدم تلغرام",
"telegramIdPlaceholder": "معرّف مستخدم تلغرام رقمي (0 = لا شيء)",
"created": "تاريخ الإنشاء",
"updated": "تاريخ التحديث",
"ipLimit": "حد IP",
"toasts": {
"deleted": "تم حذف العميل",
@@ -955,7 +859,6 @@
}
},
"groups": {
"title": "المجموعات",
"name": "الاسم",
"clientCount": "العملاء",
"totalGroups": "إجمالي المجموعات",
@@ -997,7 +900,6 @@
"removeFromGroupResult": "تمت إزالة {count} عميل من {name}."
},
"nodes": {
"title": "النودز",
"addNode": "إضافة نود",
"editNode": "تحرير العقدة",
"totalNodes": "إجمالي النودز",
@@ -1016,8 +918,6 @@
"apiTokenPlaceholder": "التوكن من صفحة إعدادات البانل البعيد",
"apiTokenHint": "البانل البعيد بيعرض توكن API بتاعه في المصادقة → توكن API.",
"apiTokenKeepHint": "اتركه فارغًا للإبقاء على التوكن الحالي",
"regenerate": "تجديد التوكن",
"regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟",
"allowPrivateAddress": "السماح بالعنوان الخاص",
"allowPrivateAddressHint": "التفعيل فقط للعقد على شبكة خاصة أو VPN.",
"outboundTag": "اتصال صادر",
@@ -1049,7 +949,6 @@
"updatePanel": "تحديث اللوحة",
"updateSelected": "تحديث المحدد ({count})",
"updateAvailable": "تحديث متاح",
"upToDate": "محدّث",
"updateConfirmTitle": "تحديث {count} عقدة إلى أحدث إصدار؟",
"updateConfirmContent": "كل عقدة محددة ستنزّل أحدث إصدار وتعيد التشغيل عليه. يتم تحديث العقد المفعّلة والمتصلة فقط.",
"updateDevChannel": "التحديث إلى قناة التطوير (أحدث كومِت)",
@@ -1113,6 +1012,7 @@
"pinFetchFailed": "تعذّر جلب الشهادة"
},
"settings": {
"defaultTag": "افتراضي",
"title": "إعدادات البانل",
"save": "حفظ",
"infoDesc": "كل تغيير هتعمله هنا لازم يتخزن. ياريت تعيد تشغيل البانل عشان التعديلات تتفعل.",
@@ -1441,6 +1341,8 @@
"eventMemoryHigh": "ارتفاع استخدام الذاكرة (%)",
"remarkTemplate": "قالب الملاحظة",
"remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه.",
"subShowIdentityOnAllLinks": "إظهار الهوية في كل رابط",
"subShowIdentityOnAllLinksDesc": "عند التفعيل، يبقى {{EMAIL}} و{{USERNAME}} في ملاحظة كل رابط في محتوى الاشتراك. تظل رموز الاستخدام في الرابط الأول فقط.",
"validation": {
"pathLeadingSlash": "يجب أن يبدأ المسار بالرمز /"
},
@@ -1448,13 +1350,8 @@
"secretClearUndo": "تراجع عن المسح"
},
"xray": {
"title": "إعدادات Xray",
"save": "احفظ",
"restart": "إعادة تشغيل Xray",
"restartSuccess": "تم إعادة تشغيل Xray بنجاح",
"restartOutputTitle": "مخرجات إعادة تشغيل Xray",
"restartConfirmTitle": "إعادة تشغيل xray؟",
"restartConfirmContent": "يعيد تحميل خدمة xray بالتكوين المحفوظ.",
"stopSuccess": "تم إيقاف Xray بنجاح",
"restartError": "حدث خطأ أثناء إعادة تشغيل Xray.",
"stopError": "حدث خطأ أثناء إيقاف Xray.",
@@ -1464,7 +1361,6 @@
"generalConfigsDesc": "الخيارات دي هتحدد التعديلات العامة.",
"logConfigs": "السجل",
"logConfigsDesc": "السجلات ممكن تأثر على كفاءة السيرفر. ننصح بتفعيلها بحكمة لما تكون محتاجها.",
"blockConfigsDesc": "الخيارات دي هتحجب الترافيك بناءً على بروتوكولات ومواقع محددة.",
"basicRouting": "توجيه أساسي",
"blockConnectionsConfigsDesc": "الخيارات دي هتحجب الترافيك بناءً على الدولة المطلوبة.",
"directConnectionsConfigsDesc": "الاتصال المباشر بيضمن إن الترافيك المعين مايمرش من سيرفر تاني.",
@@ -1474,10 +1370,6 @@
"directdomains": "اتصالات مباشرة للدومينات",
"ipv4Routing": "توجيه IPv4",
"ipv4RoutingDesc": "الخيارات دي هتوجه الترافيك بناءً على وجهة معينة عبر IPv4.",
"warpRouting": "توجيه WARP",
"warpRoutingDesc": "الخيارات دي هتوجه الترافيك بناءً على وجهة معينة عبر WARP.",
"nordRouting": "توجيه NordVPN",
"nordRoutingDesc": "الخيارات دي هتوجه الترافيك بناءً على وجهة معينة عبر NordVPN.",
"Template": "قالب إعدادات Xray المتقدم",
"TemplateDesc": "ملف إعدادات Xray النهائي هيتولد بناءً على القالب ده.",
"FreedomStrategy": "استراتيجية بروتوكول الحرية",
@@ -1491,10 +1383,7 @@
"outboundTestUrlDesc": "الرابط المستخدم عند اختبار اتصال المخرج",
"Torrent": "حظر بروتوكول التورنت",
"Inbounds": "الواردات",
"InboundsDesc": "قبول العملاء المعينين.",
"Outbounds": "الصادرات",
"OutboundSubscriptions": "اشتراكات الصادرات",
"OutboundSubscriptionsDesc": "استورد الصادرات من روابط اشتراك بعيدة (vmess/vless/trojan/ss/...). الوسوم بتفضل ثابتة عشان تستخدمها في موازنات التحميل وقواعد التوجيه. التحديثات بتتم تلقائياً.",
"importRules": "استيراد القواعد",
"exportRules": "تصدير القواعد",
"importOutbounds": "استيراد الصادرات",
@@ -1521,9 +1410,7 @@
"routeTesterMatchedOutbound": "الصادر المطابق",
"routeTesterViaBalancer": "عبر موازن التحميل",
"routeTesterDefaultOutbound": "ما في قاعدة توجيه اتطابقت — الترافيك رايح للصادر الافتراضي (الأول).",
"OutboundsDesc": "حدد مسار الترافيك الصادر.",
"Routings": "قواعد التوجيه",
"RoutingsDesc": "أولوية كل قاعدة مهمة جداً!",
"completeTemplate": "الكل",
"logLevel": "مستوى السجلات",
"logLevelDesc": "مستوى السجل الخاص بالأخطاء، اللي بيوضح المعلومات المطلوبة للتسجيل.",
@@ -1537,13 +1424,9 @@
"maskAddressDesc": "إخفاء عنوان الـ IP؛ لو مفعل، هيستبدل تلقائياً عنوان IP اللي بيظهر في السجل.",
"statistics": "إحصائيات",
"statsInboundUplink": "إحصائيات رفع الإدخال",
"statsInboundUplinkDesc": "تفعيل جمع الإحصائيات لترافيك الرفع لكل بروكسي من الإدخالات.",
"statsInboundDownlink": "إحصائيات تنزيل الإدخال",
"statsInboundDownlinkDesc": "تفعيل جمع الإحصائيات لترافيك التنزيل لكل بروكسي من الإدخالات.",
"statsOutboundUplink": "إحصائيات رفع المخرجات",
"statsOutboundUplinkDesc": "تفعيل جمع الإحصائيات لترافيك الرفع لكل بروكسي من المخرجات.",
"statsOutboundDownlink": "إحصائيات تنزيل المخرجات",
"statsOutboundDownlinkDesc": "تفعيل جمع الإحصائيات لترافيك التنزيل لكل بروكسي من المخرجات.",
"connectionLimits": "حدود الاتصال",
"connectionLimitsDesc": "سياسات على مستوى الاتصال لمستوى المستخدم 0. اترك الحقل فارغًا لاستخدام القيمة الافتراضية لـ Xray.",
"connIdle": "مهلة الخمول",
@@ -1553,18 +1436,10 @@
"bufferSizePlaceholder": "تلقائي",
"seconds": "ثانية",
"rules": {
"first": "أول",
"last": "آخر",
"up": "فوق",
"down": "تحت",
"source": "المصدر",
"dest": "الوجهة",
"inbound": "إدخال",
"outbound": "مخرج",
"balancer": "موازن",
"info": "معلومات",
"add": "أضف قاعدة",
"edit": "عدل القاعدة",
"useComma": "عناصر مفصولة بفواصل"
},
"routing": {
@@ -1605,7 +1480,6 @@
"ruleN": "القاعدة {n}",
"action": "الإجراء",
"redirect": "Redirect",
"fragment": "Fragment",
"finalRules": "القواعد النهائية",
"overrideXrayPrivateIp": "تجاوز حظر IP الخاص الافتراضي في Xray",
"blockDelay": "تأخير الحظر (ms)",
@@ -1631,43 +1505,17 @@
"keepAliveInterval": "فاصل keep alive",
"markFwmark": "Mark (fwmark)",
"interface": "الواجهة",
"ipv6Only": "IPv6 فقط",
"acceptProxyProtocol": "قبول proxy protocol",
"proxyProtocol": "Proxy protocol",
"tcpUserTimeoutMs": "TCP user timeout (ms)",
"tcpKeepAliveIdleS": "TCP keep-alive idle (ثانية)"
},
"outbound": {
"addOutbound": "أضف مخرج",
"addReverse": "أضف عكسي",
"editOutbound": "عدل المخرج",
"editReverse": "عدل العكسي",
"reverseTag": "وسم العكسي",
"reverseTagDesc": "وسم الخروج لبروكسي VLESS العكسي البسيط. اتركه فارغاً لتعطيله.",
"reverseTagPlaceholder": "وسم الخروج (اتركه فارغاً للتعطيل)",
"tag": "الوسم",
"tagDesc": "تاج فريد",
"address": "العنوان",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"reverse": "عكسي",
"domain": "النطاق",
"type": "النوع",
"bridge": "Bridge",
"portal": "Portal",
"link": "الرابط",
"intercon": "تواصل",
"settings": "إعدادات",
"accountInfo": "معلومات الحساب",
"outboundStatus": "حالة المخرج",
"sendThrough": "أرسل من خلال",
"targetStrategy": "استراتيجية الوجهة",
"test": "اختبار",
"testResult": "نتيجة الاختبار",
"testing": "جاري اختبار الاتصال...",
"testSuccess": "الاختبار ناجح",
"testFailed": "فشل الاختبار",
"testError": "فشل اختبار المخرج",
"modeRealDelay": "التأخير الفعلي",
"testModeTooltip": "TCP: فحص dial سريع. HTTP: طلب كامل عبر xray. التأخير الفعلي: الوقت الكامل شاملاً إنشاء الاتصال.",
"testAll": "اختبار الكل",
@@ -1675,14 +1523,10 @@
"breakdownConnect": "اتصال البروكسي",
"breakdownTls": "TLS عبر الصادر",
"breakdownTtfb": "أول بايت",
"nordvpn": "NordVPN",
"accessToken": "رمز الوصول",
"country": "الدولة",
"server": "الخادم",
"city": "المدينة",
"allCities": "كل المدن",
"privateKey": "المفتاح الخاص",
"load": "الحمل",
"moveToTop": "نقل إلى الأعلى"
},
"outboundSub": {
@@ -1712,16 +1556,11 @@
"active": "الاشتراكات النشطة",
"empty": "مفيش اشتراكات لسه. أضف واحد من فوق.",
"colRemark": "ملاحظة",
"colPrefix": "بادئة",
"colInterval": "الفاصل",
"colLastFetch": "آخر جلب",
"colEnabled": "مفعّل",
"auto": "تلقائي",
"never": "أبداً",
"yes": "نعم",
"no": "لا",
"refreshNow": "حدّث الآن",
"lastError": "آخر خطأ",
"deleteConfirm": "تحذف الاشتراك ده؟",
"restartHint": "بعد الإضافة أو التحديث، أعد تشغيل Xray (أو استنى إعادة التحميل التلقائي اللي جاية) عشان تفعّل الصادرات.",
"fromSubsTitle": "من اشتراكات الصادرات (للقراءة فقط)",
@@ -1738,8 +1577,6 @@
"tabBalancerSettings": "إعدادات الموازن",
"tabObservatory": "المرصد",
"observatory": {
"title": "المرصد",
"burstTitle": "مرصد Burst",
"autoManaged": "تتم إدارة المراصد تلقائيًا من الموازنات لديك. اضبط طريقة الفحص بالأسفل؛ تتبع الوجهات الصادرة المراقَبة محدِّدات الموازن.",
"emptyHint": "لا يوجد مرصد اتصال نشط. تتم إضافة واحد تلقائيًا عند إنشاء موازن Least Ping أو Least Load — أو موازن Random / Round-robin مع fallback — حتى تتمكن الموازنات المعتمدة على المرصد من فحص صحة الوجهات الصادرة قبل اختيار الهدف.",
"mixedLegacy": "يحتوي هذا الإعداد على Observatory و Burst Observatory معًا. يستخدم Xray مرصدًا عالميًا واحدًا، لذلك هذه الحالة القديمة المختلطة غير مدعومة؛ حفظ الموازنات سيحوّلها إلى مرصد واحد.",
@@ -1773,12 +1610,8 @@
"balancerRemoved": "الموازن {tag} — أُزيل (لا توجد أهداف متبقية)"
},
"balancer": {
"addBalancer": "أضف موازن تحميل",
"editBalancer": "عدل موازن التحميل",
"balancerStrategy": "استراتيجية الموازن",
"balancerSelectors": "المحددات",
"tag": "الوسم",
"tagDesc": "تاج فريد",
"tagDuplicate": "الوسم مستخدم بالفعل من قبل موازن آخر",
"tagPlaceholder": "وسم موازن فريد",
"selector": "المحدد",
@@ -1790,7 +1623,6 @@
"tolerance": "التحمل",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "ماينفعش تستخدم balancerTag و outboundTag مع بعض. لو اتستخدموا مع بعض، outboundTag هو اللي هيشتغل.",
"costMatch": "نمط الوسم",
"costValue": "الوزن",
"costRegexp": "مطابقة تعبير نمطي",
@@ -1805,7 +1637,6 @@
"publicKey": "المفتاح العام",
"allowedIPs": "عناوين IP المسموح بها",
"endpoint": "النهاية",
"psk": "المفتاح المشترك",
"domainStrategy": "استراتيجية الدومين"
},
"amneziawg": {
@@ -1843,10 +1674,7 @@
"i1Hint": "خاص بـ AmneziaWG 2.0 فقط. اتركه فارغًا للتوافق مع الإصدار 1.x."
},
"tun": {
"nameDesc": "اسم واجهة TUN. القيمة الافتراضية هي 'xray0'",
"mtuDesc": "وحدة النقل الأقصى. الحد الأقصى لحجم حزم البيانات. القيمة الافتراضية هي 1500",
"userLevel": "مستوى المستخدم",
"userLevelDesc": "ستستخدم جميع الاتصالات المُرسلة عبر هذا الإدخال مستوى المستخدم هذا. القيمة الافتراضية هي 0"
"userLevel": "مستوى المستخدم"
},
"nord": {
"accessToken": "Access token",
@@ -1931,7 +1759,6 @@
},
"fakedns": {
"add": "أضف Fake DNS",
"edit": "عدل Fake DNS",
"ipPool": "نطاق IP Pool",
"poolSize": "حجم المجموعة"
},
@@ -2067,7 +1894,6 @@
"add": "إضافة",
"month": "شهر",
"months": "أشهر",
"day": "يوم",
"days": "أيام",
"hours": "ساعات",
"minutes": "دقائق",
@@ -2106,7 +1932,6 @@
"userSaved": "✅ حفظت بيانات مستخدم Telegram.",
"loginSuccess": "✅ تسجيل الدخول للبانل تم بنجاح.\r\n",
"loginFailed": "❗️فشل محاولة تسجيل الدخول للبانل.\r\n",
"2faFailed": "فشل 2FA",
"report": "🕰 التقارير المجدولة: {{ .RunTime }}\r\n",
"datetime": "⏰ التاريخ والوقت: {{ .DateTime }}\r\n",
"hostname": "💻 المضيف: {{ .Hostname }}\r\n",
@@ -2139,7 +1964,6 @@
"download": "🔽 التنزيل: ↓{{ .Download }}\r\n",
"total": "📊 الإجمالي: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
"TGUser": "👤 مستخدم Telegram: {{ .TelegramID }}\r\n",
"exhaustedMsg": "🚨 نفذ {{ .Type }}:\r\n",
"exhaustedCount": "🚨 عدد النفاذ لـ {{ .Type }}:\r\n",
"onlinesCount": "🌐 العملاء الأونلاين: {{ .Count }}\r\n",
"disabled": "🛑 معطل: {{ .Disabled }}\r\n",
@@ -2148,16 +1972,10 @@
"refreshedOn": "\r\n📋🔄 اتحدّث في: {{ .Time }}\r\n\r\n",
"yes": "✅ أيوه",
"no": "❌ لا",
"received_id": "🔑📥 الـ ID اتحدث.",
"received_password": "🔑📥 الباسورد اتحدث.",
"received_email": "📧📥 الإيميل اتحدث.",
"received_comment": "💬📥 التعليق اتحدث.",
"id_prompt": "🔑 الـ ID الافتراضي: {{ .ClientId }}\n\nادخل الـ ID بتاعك.",
"pass_prompt": "🔑 الباسورد الافتراضي: {{ .ClientPassword }}\n\nادخل الباسورد بتاعك.",
"email_prompt": "📧 الإيميل الافتراضي: {{ .ClientEmail }}\n\nادخل الإيميل بتاعك.",
"comment_prompt": "💬 التعليق الافتراضي: {{ .ClientComment }}\n\nادخل تعليقك.",
"inbound_client_data_id": "🔄 الدخول: {{ .InboundRemark }}\n\n🔑 المعرف: {{ .ClientId }}\n📧 البريد الإلكتروني: {{ .ClientEmail }}\n📊 الترافيك: {{ .ClientTraffic }}\n📅 تاريخ الانتهاء: {{ .ClientExp }}\n🌐 حدّ IP: {{ .IpLimit }}\n💬 تعليق: {{ .ClientComment }}\n\nدلوقتي تقدر تضيف العميل على الدخول!",
"inbound_client_data_pass": "🔄 الدخول: {{ .InboundRemark }}\n\n🔑 كلمة المرور: {{ .ClientPass }}\n📧 البريد الإلكتروني: {{ .ClientEmail }}\n📊 الترافيك: {{ .ClientTraffic }}\n📅 تاريخ الانتهاء: {{ .ClientExp }}\n🌐 حدّ IP: {{ .IpLimit }}\n💬 تعليق: {{ .ClientComment }}\n\nدلوقتي تقدر تضيف العميل على الدخول!",
"cancel": "❌ العملية اتلغت! \n\nممكن تبدأ من /start في أي وقت. 🔄",
"error_add_client": "⚠️ خطأ:\n\n {{ .error }}",
"using_default_value": "تمام، هشيل على القيمة الافتراضية. 😊",
@@ -2174,8 +1992,6 @@
"eventXrayCrashError": "الخطأ: {{ .Error }}",
"eventNodeDown": "العقدة {{ .Name }} غير متصلة",
"eventNodeUp": "العقدة {{ .Name }} متصلة",
"eventCPUHigh": "ارتفاع استخدام المعالج",
"eventCPUHighDetail": "المعالج: {{ .Detail }}",
"eventLoginFallback": "فشل تسجيل الدخول من {{ .Source }}",
"memoryThreshold": "استخدام الذاكرة {{ .Percent }}% يتجاوز الحد {{ .Threshold }}%"
},
@@ -2216,11 +2032,8 @@
"submitDisable": "إرسال كمعطّل ☑️",
"submitEnable": "إرسال كمفعّل ✅",
"use_default": "🏷️ استخدام الإعدادات الافتراضية",
"change_id": "⚙️🔑 ID",
"change_password": "⚙️🔑 كلمة السر",
"change_email": "⚙️📧 البريد",
"change_comment": "⚙️💬 تعليق",
"change_flow": "⚙️🚦 Flow",
"ResetAllTraffics": "إعادة ضبط جميع الترافيك",
"SortedTrafficUsageReport": "تقرير استخدام الترافيك المرتب"
},
@@ -2249,31 +2062,16 @@
}
},
"email": {
"subjectOutboundDown": "الصادر {{ .Tag }} غير متصل",
"subjectOutboundUp": "الصادر {{ .Tag }} متصل",
"subjectXrayCrash": "تعطّل Xray",
"subjectCPUHigh": "ارتفاع استخدام المعالج",
"subjectLoginSuccess": "نجح تسجيل الدخول",
"subjectLoginFailed": "فشل تسجيل الدخول",
"titleOutboundDown": "الصادر غير متصل",
"titleOutboundUp": "الصادر متصل",
"titleXrayCrash": "تعطّل Xray",
"titleCPUHigh": "ارتفاع استخدام المعالج",
"titleLoginSuccess": "نجح تسجيل الدخول",
"titleLoginFailed": "فشل تسجيل الدخول",
"labelStatus": "الحالة",
"labelOutbound": "الصادر",
"labelNode": "العقدة",
"labelError": "الخطأ",
"labelDelay": "التأخير",
"labelDetail": "التفاصيل",
"labelUsername": "اسم المستخدم",
"labelIP": "IP",
"labelReason": "السبب",
"labelSource": "المصدر",
"labelTime": "الوقت",
"statusCrashed": "متعطّل",
"statusRunning": "يعمل",
"statusHigh": "مرتفع",
"statusSuccess": "نجاح",
"statusFailed": "فشل",
+25 -227
View File
@@ -48,7 +48,6 @@
"copySuccess": "Copied successfully",
"sure": "Sure",
"encryption": "Encryption",
"useIPv4ForHost": "Use IPv4 for host",
"transmission": "Transmission",
"host": "Host",
"path": "Path",
@@ -74,18 +73,9 @@
"twoFactorCode": "Code",
"remained": "Remaining",
"security": "Security",
"secAlertTitle": "Security Alert",
"secAlertSsl": "This connection is not secure. Please avoid entering sensitive information until TLS is activated for data protection.",
"secAlertConf": "Certain settings are vulnerable to attacks. It is recommended to reinforce security protocols to prevent potential breaches.",
"secAlertSSL": "Panel lacks secure connection. Please install TLS certificate for data protection.",
"secAlertPanelPort": "Panel default port is vulnerable. Please configure a random or specific port.",
"secAlertPanelURI": "Panel default URI path is insecure. Please configure a complex URI path.",
"secAlertSubURI": "Subscription default URI path is insecure. Please configure a complex URI path.",
"secAlertSubJsonURI": "Subscription JSON default URI path is insecure. Please configure a complex URI path.",
"emptyDnsDesc": "No added DNS servers.",
"emptyFakeDnsDesc": "No added Fake DNS servers.",
"emptyBalancersDesc": "No added balancers.",
"emptyReverseDesc": "No added reverse proxies.",
"somethingWentWrong": "Something went wrong",
"subscription": {
"title": "Subscription info",
@@ -106,8 +96,6 @@
},
"menu": {
"theme": "Theme",
"dark": "Dark",
"ultraDark": "Ultra Dark",
"dashboard": "Overview",
"inbounds": "Inbounds",
"clients": "Clients",
@@ -119,11 +107,11 @@
"routing": "Routing",
"outbounds": "Outbounds",
"apiDocs": "API Docs",
"logout": "Log Out",
"link": "Manage",
"donate": "Donate",
"docs": "Documentation",
"openMenu": "Open menu"
"openMenu": "Open menu",
"pinSidebar": "Pin sidebar",
"unpinSidebar": "Unpin sidebar"
},
"pages": {
"login": {
@@ -139,20 +127,15 @@
}
},
"index": {
"title": "Overview",
"cpu": "CPU",
"logicalProcessors": "Logical Processors",
"frequency": "Frequency",
"swap": "Swap",
"storage": "Storage",
"memory": "RAM",
"threads": "Threads",
"xrayStatus": "Xray",
"stopXray": "Stop",
"restartXray": "Restart",
"xraySwitch": "Version",
"xrayUpdates": "Xray Updates",
"xraySwitchClick": "Choose the version you want to switch to.",
"xraySwitchClickDesk": "Choose carefully, as older versions may not be compatible with current configurations.",
"updatePanel": "Update Panel",
"panelUpdateDesc": "This will update 3X-UI itself to the latest release and restart the panel service.",
@@ -164,13 +147,10 @@
"currentCommit": "Current commit",
"latestCommit": "Latest commit",
"updateChannelChanged": "Update channel changed",
"upToDate": "Up to date",
"xrayStatusUnknown": "Unknown",
"xrayStatusRunning": "Running",
"xrayStatusStop": "Stopped",
"xrayStatusError": "Error",
"xrayErrorPopoverTitle": "An error occurred while running Xray",
"operationHours": "Uptime",
"systemHistoryTitle": "System History",
"historyTitleCpu": "CPU Usage",
"historyTitleMem": "Memory Usage",
@@ -188,7 +168,6 @@
"historyTabLoad": "Load",
"historyTabConnections": "Connections",
"historyTabDiskUsage": "Disk Usage",
"charts": "Charts",
"xrayMetricsTitle": "Xray Metrics",
"xrayTitleHeap": "Allocated Heap Memory",
"xrayTitleSys": "Memory Reserved from OS",
@@ -211,25 +190,19 @@
"xrayObservatoryDead": "Down",
"xrayObservatoryLastSeen": "Last seen",
"xrayObservatoryLastTry": "Last try",
"trendLast2Min": "Last 2 minutes",
"systemLoad": "System Load",
"systemLoadDesc": "System load average for the past 1, 5, and 15 minutes",
"connectionCount": "Connection Stats",
"ipAddresses": "IP Addresses",
"toggleIpVisibility": "Toggle visibility of the IP",
"overallSpeed": "Overall Speed",
"upload": "Upload",
"download": "Download",
"totalData": "Total Data",
"sent": "Sent",
"received": "Received",
"documentation": "Documentation",
"xraySwitchVersionDialog": "Do you really want to change the Xray version?",
"xraySwitchVersionDialogDesc": "This will change the Xray version to #version#.",
"xraySwitchVersionPopover": "Xray updated successfully",
"panelUpdateDialog": "Do you really want to update the panel?",
"panelUpdateDialogDesc": "This will update 3X-UI to #version# and restart the panel service.",
"panelUpdateCheckPopover": "Panel update check failed",
"panelUpdateStartedPopover": "Panel update started",
"panelUpdateFailedTitle": "Panel update failed",
"panelUpdateFailedDesc": "The update did not finish successfully. Check the server logs, or run 'x-ui update' from the command line.",
@@ -258,7 +231,6 @@
"accessLogs": "Access Logs",
"autoUpdate": "Auto Update",
"config": "Config",
"backup": "Backup",
"backupTitle": "Backup & Restore",
"exportDatabase": "Back Up",
"exportDatabaseDesc": "Click to download a .db file containing a backup of your current database to your device. The same file can also be restored into a panel running on PostgreSQL.",
@@ -273,10 +245,20 @@
"exportDatabasePgDesc": "Click to download a PostgreSQL dump (.dump) of your current database to your device.",
"importDatabasePgDesc": "Click to select and upload a PostgreSQL backup (.dump), a SQLite database (.db), or a SQLite migration dump to restore your database. This replaces all current data.",
"migrationDownload": "Download Migration",
"migrationDownloadPgDesc": "Click to download a .db SQLite database built from your PostgreSQL data, ready to run this panel on SQLite."
"migrationDownloadPgDesc": "Click to download a .db SQLite database built from your PostgreSQL data, ready to run this panel on SQLite.",
"avg": "avg",
"peak": "peak",
"free": "free",
"openSockets": "open sockets",
"throughputSub": "Interface total",
"avgWindow": "Avg over window",
"healthWarm": "{list} — running warm",
"healthCritical": "{list} — critical",
"panel": "Panel",
"threads": "Threads",
"uptime": "Uptime"
},
"inbounds": {
"title": "Inbounds",
"totalDownUp": "Total Sent/Received",
"totalUsage": "Total Usage",
"inboundCount": "Total Inbounds",
@@ -288,21 +270,11 @@
"localPanel": "Local panel",
"fallbacks": {
"title": "Fallbacks",
"help": "When a connection on this inbound does not match any client, route it elsewhere. Pick a child inbound below to auto-fill the routing fields (SNI / ALPN / path / xver) from its transport, or leave the picker empty and set Dest directly (e.g. 8080 or 127.0.0.1:8080) to route to an external server such as Nginx. Each child inbound should listen on 127.0.0.1 with security=none.",
"empty": "No fallbacks yet",
"add": "Add fallback",
"pickInbound": "Pick an inbound",
"matchAny": "any",
"destPlaceholder": "auto (child listen:port)",
"rederive": "Re-fill from child",
"rederived": "Re-filled from child",
"editAdvanced": "Edit routing fields",
"hideAdvanced": "Hide advanced",
"quickAddAll": "Quick add all eligible",
"quickAdded": "Added {n} fallback(s)",
"quickAddedNone": "No new eligible inbounds to add",
"routesWhen": "Routes when",
"defaultCatchAll": "Default — catches anything else",
"needsTls": "Fallbacks become available once Security is set to TLS or Reality on the Security tab (VLESS/Trojan over RAW only)."
},
"protocol": "Protocol",
@@ -310,8 +282,6 @@
"portMap": "Port Mapping",
"traffic": "Traffic",
"speed": "Speed",
"details": "Details",
"transportConfig": "Transport",
"expireDate": "Duration",
"createdAt": "Created",
"updatedAt": "Updated",
@@ -319,8 +289,6 @@
"addInbound": "Add Inbound",
"generalActions": "General Actions",
"modifyInbound": "Modify Inbound",
"deleteInbound": "Delete Inbound",
"deleteInboundContent": "Are you sure you want to delete this inbound?",
"deleteConfirmTitle": "Delete inbound \"{remark}\"?",
"deleteConfirmContent": "This removes the inbound and all its clients. This cannot be undone.",
"resetConfirmTitle": "Reset traffic for \"{remark}\"?",
@@ -364,8 +332,6 @@
"exportAllLinksFileName": "All-Inbounds",
"exportAllSubsFileName": "All-Inbounds-Subs",
"inboundJsonTitle": "Inbound JSON",
"deleteClient": "Delete Client",
"deleteClientContent": "Are you sure you want to delete this client?",
"resetTrafficContent": "Are you sure you want to reset traffic?",
"copyLink": "Copy URL",
"address": "Address",
@@ -376,36 +342,19 @@
"meansNoLimit": "= Unlimited. (unit: GB)",
"totalFlow": "Total Flow",
"leaveBlankToNeverExpire": "Leave blank to never expire",
"noRecommendKeepDefault": "It is recommended to keep the default",
"certificatePath": "File Path",
"certificateContent": "File Content",
"publicKey": "Public Key",
"privatekey": "Private Key",
"clickOnQRcode": "Click on QR Code to Copy",
"client": "Client",
"export": "Export All URLs",
"clone": "Clone",
"cloneInbound": "Clone",
"cloneInboundContent": "All settings of this inbound, except Port, Listening IP, and Clients, will be applied to the clone.",
"cloneInboundOk": "Clone",
"resetAllTraffic": "Reset Traffic for All Inbounds",
"resetAllTrafficTitle": "Reset Traffic for All Inbounds",
"resetAllTrafficContent": "Are you sure you want to reset the traffic of all inbounds?",
"resetInboundClientTraffics": "Reset Clients' Traffic",
"resetInboundClientTrafficTitle": "Reset Clients' Traffic",
"resetInboundClientTrafficContent": "Are you sure you want to reset the traffic of this inbound's clients?",
"resetAllClientTraffics": "Reset All Clients' Traffic",
"resetAllClientTrafficTitle": "Reset All Clients' Traffic",
"resetAllClientTrafficContent": "Are you sure you want to reset the traffic of all clients?",
"delDepletedClients": "Delete Depleted Clients",
"delDepletedClientsTitle": "Delete Depleted Clients",
"delDepletedClientsContent": "Are you sure you want to delete all the depleted clients?",
"email": "Email",
"emailDesc": "Please provide a unique email address.",
"IPLimit": "IP Limit",
"IPLimitDesc": "Disables inbound if the count exceeds the set value. (0 = disable)",
"IPLimitlog": "IP Log",
"IPLimitlogDesc": "The IP history log. (to re-enable the inbound after disabling, clear the log)",
"IPLimitlogclear": "Clear the Log",
"setDefaultCert": "Set Cert from Panel",
"setDefaultCertEmpty": "No certificate is configured for the panel. Set one under Settings first.",
@@ -438,20 +387,15 @@
"sniffing": "Sniffing",
"sniffingHelp": "Xray sniffing block wrapper:",
"stream": "Stream",
"streamHelp": "Xray stream block wrapper:",
"jsonErrorPrefix": "Advanced JSON"
"streamHelp": "Xray stream block wrapper:"
},
"telegramDesc": "Please provide Telegram Chat ID. (use '/id' command in the bot) or ({'@'}userinfobot)",
"subscriptionDesc": "To find your subscription URL, navigate to the 'Details'. Additionally, you can use the same name for several clients.",
"subSortIndex": "Sub order",
"same": "Same",
"inboundInfo": "Inbound Information",
"exportInbound": "Export Inbound",
"import": "Import",
"importInbound": "Import an Inbound",
"periodicTrafficResetTitle": "Traffic Reset",
"periodicTrafficResetDesc": "Automatically reset traffic counter at specified intervals",
"lastReset": "Last Reset",
"periodicTrafficResetDay": "Monthly reset day",
"periodicTrafficReset": {
"never": "Never",
"daily": "Daily",
@@ -463,7 +407,6 @@
"obtain": "Obtain",
"updateSuccess": "The update was successful.",
"logCleanSuccess": "The log has been cleared.",
"inboundsUpdateSuccess": "Inbounds have been successfully updated.",
"inboundUpdateSuccess": "Inbound has been successfully updated.",
"inboundCreateSuccess": "Inbound has been successfully created.",
"bulkDeleted": "{count} inbounds deleted",
@@ -473,7 +416,6 @@
"inboundClientDeleteSuccess": "Inbound client has been deleted.",
"inboundClientUpdateSuccess": "Inbound client has been updated.",
"savedNodeOfflineWillSync": "Saved locally. A backing node is offline or disabled — the change will sync once it reconnects.",
"delDepletedClientsSuccess": "All depleted clients have been deleted.",
"resetAllClientTrafficSuccess": "Traffic for all clients has been reset.",
"resetAllTrafficSuccess": "All traffic has been reset.",
"resetInboundClientTrafficSuccess": "Traffic has been reset.",
@@ -648,6 +590,10 @@
"maxTimeDiff": "Max Time Diff (ms)",
"minClientVer": "Min Client Ver",
"maxClientVer": "Max Client Ver",
"minClientVerHint": "Empty does not mean unrestricted: Xray-core then enforces the built-in minimum of the core build you run (26.3.27 in current releases) and rejects clients that report an older version — including third-party cores such as Mihomo and sing-box. Set 1.0.0 to accept them, at the cost of admitting outdated TLS fingerprints.",
"maxClientVerHint": "Empty means no upper limit. If set, it must not be lower than the effective minimum — Min Client Ver, or Xray-core's built-in minimum when that field is empty — otherwise every client is rejected.",
"clientVerInvalid": "Client version must be up to three dot-separated numbers, each 0-255 (e.g. 26.3.27)",
"maxClientVerBelowMin": "Max Client Ver must not be lower than Min Client Ver",
"shortIds": "Short IDs",
"realityTargetHint": "Required. Must include a port (e.g. example.com:443). Without a port Xray-core refuses to start.",
"realityTargetRequired": "REALITY target is required",
@@ -712,23 +658,6 @@
"peerNumber": "Peer {n}",
"peerNumberConfig": "Peer {n} config"
},
"stream": {
"general": {
"request": "Request",
"response": "Response",
"name": "Name",
"value": "Value"
},
"tcp": {
"version": "Version",
"method": "Method",
"path": "Path",
"status": "Status",
"statusDescription": "Status Desc",
"requestHeader": "Request Header",
"responseHeader": "Response Header"
}
},
"sniffingDestOverride": "Destination override"
},
"clients": {
@@ -742,24 +671,9 @@
"addExternalSubscription": "Add External Subscription",
"noExternalLinks": "No external links yet.",
"noExternalSubscriptions": "No external subscriptions yet.",
"add": "Add Client",
"edit": "Edit Client",
"submitAdd": "Add Client",
"submitEdit": "Save Changes",
"clientCount": "Number of Clients",
"bulk": "Add Bulk",
"copyFromInbound": "Copy Clients from Inbound",
"copyToInbound": "Copy clients to",
"copySelected": "Copy Selected",
"copySource": "Source",
"copyEmailPreview": "Resulting email preview",
"copySelectSourceFirst": "Please select a source inbound first.",
"copyResult": "Copy result",
"copyResultSuccess": "Copied successfully",
"copyResultNone": "Nothing to copy: no clients selected or source is empty",
"copyResultErrors": "Copy errors",
"copyFlowLabel": "Flow for new clients (VLESS)",
"copyFlowHint": "Applied to all copied clients. Leave empty to skip.",
"selectAll": "Select all",
"clearAll": "Clear all",
"method": "Method",
@@ -770,7 +684,6 @@
"postfix": "Postfix",
"delayedStart": "Start After First Use",
"expireDays": "Duration (days)",
"days": "Day(s)",
"renew": "Auto Renew",
"renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)",
"renewDays": "Auto Renew (days)",
@@ -793,7 +706,6 @@
"sortExpiringSoonest": "Expiring soonest",
"has": "Has",
"hasNot": "Doesn't have",
"title": "Clients",
"actions": "Actions",
"totalGB": "Traffic Limit (GB)",
"totalGBDesc": "Data quota for this client. 0 = unlimited.",
@@ -821,8 +733,6 @@
"addClient": "Add Client",
"qrCode": "QR Code",
"clientInfo": "Client Information",
"delete": "Delete",
"reset": "Reset Traffic",
"editClient": "Edit Client",
"client": "Client",
"enabled": "Enabled",
@@ -836,13 +746,11 @@
"noLinks": "No shareable links — attach this client to a protocol-capable inbound first.",
"link": "Link",
"resetNotPossible": "Attach this client to an inbound first.",
"general": "General",
"resetAllTraffics": "Reset all client traffic",
"resetAllTrafficsTitle": "Reset all client traffic?",
"resetAllTrafficsContent": "Every client's up/down counter drops to zero. Quotas and expiry are not affected. This cannot be undone.",
"deleteConfirmTitle": "Delete client {email}?",
"deleteConfirmContent": "This removes the client from every attached inbound and drops its traffic record. This cannot be undone.",
"deleteSelected": "Delete ({count})",
"adjustSelected": "Adjust ({count})",
"subLinksSelected": "Sub links ({count})",
"addToGroupTitle": "Add {count} client(s) to a group",
@@ -864,12 +772,10 @@
"bulkDisableConfirmTitle": "Disable {count} clients?",
"bulkDisableConfirmContent": "Disables each selected client on every attached inbound. They lose access immediately but their records and traffic are kept.",
"selectedCount": "{count} selected",
"attachSelected": "Attach ({count})",
"attachToInboundsTitle": "Attach {count} client(s) to inbound(s)",
"attachToInboundsDesc": "Attaches the selected {count} client(s) (same UUID/password and shared traffic) to the chosen inbound(s). They keep their existing attachments too.",
"attachToInboundsTargets": "Target inbounds",
"attachToInboundsNoTargets": "No multi-user inbounds available to attach to.",
"detachSelected": "Detach ({count})",
"detach": "Detach",
"detachFromInboundsTitle": "Detach {count} client(s) from inbound(s)",
"detachFromInboundsDesc": "Removes the selected {count} client(s) from the chosen inbound(s). Pairs where the client wasn't attached are silently skipped. Client records are kept (use Delete to remove fully).",
@@ -931,8 +837,6 @@
"reverseTagPlaceholder": "Optional reverse tag",
"telegramId": "Telegram user ID",
"telegramIdPlaceholder": "Numeric Telegram user ID (0 = none)",
"created": "Created",
"updated": "Updated",
"ipLimit": "IP limit",
"toasts": {
"deleted": "Client deleted",
@@ -955,7 +859,6 @@
}
},
"groups": {
"title": "Groups",
"name": "Name",
"clientCount": "Clients",
"totalGroups": "Total groups",
@@ -1114,7 +1017,6 @@
}
},
"nodes": {
"title": "Nodes",
"addNode": "Add Node",
"editNode": "Edit Node",
"totalNodes": "Total Nodes",
@@ -1133,8 +1035,6 @@
"apiTokenPlaceholder": "Token from the remote panel's Settings page",
"apiTokenHint": "The remote panel exposes its API token under Authentication → API Token.",
"apiTokenKeepHint": "Leave blank to keep the current token",
"regenerate": "Regenerate Token",
"regenerateConfirm": "Regenerating invalidates the current token. Any central panel using it will lose access until updated. Continue?",
"allowPrivateAddress": "Allow private address",
"allowPrivateAddressHint": "Enable only for nodes on a private network or VPN.",
"outboundTag": "Connection outbound",
@@ -1166,7 +1066,6 @@
"updatePanel": "Update Panel",
"updateSelected": "Update Selected ({count})",
"updateAvailable": "Update available",
"upToDate": "Up to date",
"updateConfirmTitle": "Update {count} node(s) to the latest version?",
"updateConfirmContent": "Each selected node downloads the latest release and restarts onto it. Only enabled, online nodes are updated.",
"updateDevChannel": "Update to Dev channel (latest commit)",
@@ -1230,6 +1129,7 @@
"pinFetchFailed": "Could not fetch the certificate"
},
"settings": {
"defaultTag": "Default",
"title": "Panel Settings",
"save": "Save",
"infoDesc": "Every change made here needs to be saved. Please restart the panel to apply changes.",
@@ -1267,6 +1167,8 @@
"panelOutboundPh": "Direct connection",
"remarkTemplate": "Remark Template",
"remarkTemplateDesc": "When set, this replaces the remark model for every subscription link — write your own format with the variable tokens (use the button to insert them). Leave empty to use the model above.",
"subShowIdentityOnAllLinks": "Show identity on every link",
"subShowIdentityOnAllLinksDesc": "When enabled, {{EMAIL}} and {{USERNAME}} stay on every subscription-body remark. Usage tokens still appear on the first link only.",
"datepicker": "Calendar Type",
"datepickerPlaceholder": "Select date",
"datepickerDescription": "Scheduled tasks will run based on this calendar.",
@@ -1565,13 +1467,8 @@
"secretClearUndo": "Undo clear"
},
"xray": {
"title": "Xray Configs",
"save": "Save",
"restart": "Restart Xray",
"restartSuccess": "Xray has been successfully relaunched.",
"restartOutputTitle": "Xray restart output",
"restartConfirmTitle": "Restart xray?",
"restartConfirmContent": "Reloads the xray service with the saved configuration.",
"stopSuccess": "Xray has been successfully stopped.",
"restartError": "There was an error when rebooting the Xray.",
"stopError": "There was an error when stopping the Xray.",
@@ -1581,7 +1478,6 @@
"generalConfigsDesc": "These options will determine general adjustments.",
"logConfigs": "Log",
"logConfigsDesc": "Logs may affect your server's efficiency. It is recommended to enable them wisely only when needed.",
"blockConfigsDesc": "These options will block traffic based on specific requested protocols and websites.",
"basicRouting": "Basic Routing",
"blockConnectionsConfigsDesc": "These options will block traffic based on the specific requested country.",
"directConnectionsConfigsDesc": "A direct connection ensures that specific traffic is not routed through another server.",
@@ -1591,10 +1487,6 @@
"directdomains": "Direct Domains",
"ipv4Routing": "IPv4 Routing",
"ipv4RoutingDesc": "These options will route traffic based on a specific destination via IPv4.",
"warpRouting": "WARP Routing",
"warpRoutingDesc": "These options will route traffic based on a specific destination via WARP.",
"nordRouting": "NordVPN Routing",
"nordRoutingDesc": "These options will route traffic based on a specific destination via NordVPN.",
"Template": "Advanced Xray Configuration Template",
"TemplateDesc": "The final Xray config file will be generated based on this template.",
"FreedomStrategy": "Freedom Protocol Strategy",
@@ -1608,10 +1500,7 @@
"outboundTestUrlDesc": "URL used when testing outbound connectivity.",
"Torrent": "Block BitTorrent Protocol",
"Inbounds": "Inbounds",
"InboundsDesc": "Accepting the specific clients.",
"Outbounds": "Outbounds",
"OutboundSubscriptions": "Outbound Subscriptions",
"OutboundSubscriptionsDesc": "Import outbounds from remote subscription URLs (vmess/vless/trojan/ss/...). Tags are kept stable for use in balancers and routing rules. Updates are automatic.",
"Balancers": "Balancers",
"balancerTagRequired": "Tag is required",
"balancerSelectorRequired": "Pick at least one outbound",
@@ -1630,9 +1519,7 @@
"routeTesterMatchedOutbound": "Matched outbound",
"routeTesterViaBalancer": "via balancer",
"routeTesterDefaultOutbound": "No routing rule matched — traffic goes to the default (first) outbound.",
"OutboundsDesc": "Set the outgoing traffic pathway.",
"Routings": "Routing Rules",
"RoutingsDesc": "The priority of each rule is important!",
"importRules": "Import Rules",
"exportRules": "Export Rules",
"importOutbounds": "Import Outbounds",
@@ -1651,13 +1538,9 @@
"maskAddressDesc": "IP address mask, when enabled, will automatically replace the IP address that appears in the log.",
"statistics": "Statistics",
"statsInboundUplink": "Inbound Upload Statistics",
"statsInboundUplinkDesc": "Enables the statistics collection for upstream traffic of all inbound proxies.",
"statsInboundDownlink": "Inbound Download Statistics",
"statsInboundDownlinkDesc": "Enables the statistics collection for downstream traffic of all inbound proxies.",
"statsOutboundUplink": "Outbound Upload Statistics",
"statsOutboundUplinkDesc": "Enables the statistics collection for upstream traffic of all outbound proxies.",
"statsOutboundDownlink": "Outbound Download Statistics",
"statsOutboundDownlinkDesc": "Enables the statistics collection for downstream traffic of all outbound proxies.",
"metricsListen": "Metrics Endpoint",
"metricsListenDesc": "Expose Xray's Prometheus-style metrics on this address:port (e.g. 127.0.0.1:11111). Leave empty to disable. Bind to localhost and reverse-proxy it — it is unauthenticated.",
"metricsTag": "Metrics Tag",
@@ -1670,18 +1553,10 @@
"bufferSizePlaceholder": "auto",
"seconds": "seconds",
"rules": {
"first": "First",
"last": "Last",
"up": "Up",
"down": "Down",
"source": "Source",
"dest": "Destination",
"inbound": "Inbound",
"outbound": "Outbound",
"balancer": "Balancer",
"info": "Info",
"add": "Add Rule",
"edit": "Edit Rule",
"useComma": "Comma-separated list"
},
"routing": {
@@ -1722,7 +1597,6 @@
"ruleN": "Rule {n}",
"action": "Action",
"redirect": "Redirect",
"fragment": "Fragment",
"finalRules": "Final Rules",
"overrideXrayPrivateIp": "Override Xray's default private-IP block",
"blockDelay": "Block delay (ms)",
@@ -1748,43 +1622,17 @@
"keepAliveInterval": "Keep alive interval",
"markFwmark": "Mark (fwmark)",
"interface": "Interface",
"ipv6Only": "IPv6 only",
"acceptProxyProtocol": "Accept proxy protocol",
"proxyProtocol": "Proxy protocol",
"tcpUserTimeoutMs": "TCP user timeout (ms)",
"tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
},
"outbound": {
"addOutbound": "Add Outbound",
"addReverse": "Add Reverse",
"editOutbound": "Edit Outbound",
"editReverse": "Edit Reverse",
"reverseTag": "Reverse Tag",
"reverseTagDesc": "VLESS simple reverse proxy tag. Leave empty to disable.",
"reverseTagPlaceholder": "reverse tag (leave empty to disable)",
"tag": "Tag",
"tagDesc": "Unique Tag",
"address": "Address",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"reverse": "Reverse",
"domain": "Domain",
"type": "Type",
"bridge": "Bridge",
"portal": "Portal",
"link": "Link",
"intercon": "Interconnection",
"settings": "Settings",
"accountInfo": "Account Information",
"outboundStatus": "Outbound Status",
"sendThrough": "Send Through",
"targetStrategy": "Target Strategy",
"test": "Test",
"testResult": "Test Result",
"testing": "Testing connection...",
"testSuccess": "Test successful",
"testFailed": "Test failed",
"testError": "Failed to test outbound",
"modeRealDelay": "Real delay",
"testModeTooltip": "TCP: fast dial-only probe. HTTP: full request through xray. Real delay: total time including connection setup.",
"testAll": "Test all",
@@ -1792,14 +1640,10 @@
"breakdownConnect": "Proxy connect",
"breakdownTls": "TLS via outbound",
"breakdownTtfb": "First byte",
"nordvpn": "NordVPN",
"accessToken": "Access Token",
"country": "Country",
"server": "Server",
"city": "City",
"allCities": "All Cities",
"privateKey": "Private Key",
"load": "Load",
"moveToTop": "Move to top"
},
"outboundSub": {
@@ -1829,16 +1673,11 @@
"active": "Active subscriptions",
"empty": "No subscriptions yet. Add one above.",
"colRemark": "Remark",
"colPrefix": "Prefix",
"colInterval": "Interval",
"colLastFetch": "Last fetch",
"colEnabled": "Enabled",
"auto": "auto",
"never": "never",
"yes": "Yes",
"no": "No",
"refreshNow": "Refresh now",
"lastError": "Last error",
"deleteConfirm": "Delete this subscription?",
"restartHint": "After adding or refreshing, restart Xray (or wait for the next auto-reload) to make the outbounds active.",
"fromSubsTitle": "From outbound subscriptions (read-only)",
@@ -1855,8 +1694,6 @@
"tabBalancerSettings": "Balancer Settings",
"tabObservatory": "Observatory",
"observatory": {
"title": "Observatory",
"burstTitle": "Burst Observatory",
"autoManaged": "Observers are managed automatically from your balancers. Tune how they probe below — the watched outbounds follow your balancer selectors.",
"emptyHint": "No connection observer is active. One is added automatically when you create a Least Ping or Least Load balancer — or a Random / Round-robin balancer with a fallback — so observer-backed balancers can check outbound health before choosing a target.",
"mixedLegacy": "This config contains both Observatory and Burst Observatory. Xray uses one global observer, so this mixed legacy state is not supported; saving balancers will normalize it to one observer.",
@@ -1890,12 +1727,8 @@
"balancerRemoved": "Balancer {tag} — removed (no targets left)"
},
"balancer": {
"addBalancer": "Add Balancer",
"editBalancer": "Edit Balancer",
"balancerStrategy": "Strategy",
"balancerSelectors": "Selectors",
"tag": "Tag",
"tagDesc": "Unique Tag",
"tagDuplicate": "Tag already used by another balancer",
"tagPlaceholder": "unique balancer tag",
"selector": "Selector",
@@ -1912,7 +1745,6 @@
"tolerance": "Tolerance",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "It is not possible to use balancerTag and outboundTag at the same time. If used at the same time, only outboundTag will work.",
"costMatch": "Tag pattern",
"costValue": "Weight",
"costRegexp": "Regular expression match"
@@ -1922,7 +1754,6 @@
"publicKey": "Public Key",
"allowedIPs": "Allowed IPs",
"endpoint": "Endpoint",
"psk": "PreShared Key",
"domainStrategy": "Domain Strategy"
},
"amneziawg": {
@@ -1960,10 +1791,7 @@
"i1Hint": "AmneziaWG 2.0 only. Leave empty for a 1.x-compatible config."
},
"tun": {
"nameDesc": "The name of the TUN interface. Default is 'xray0'",
"mtuDesc": "Maximum Transmission Unit. The maximum size of data packets. Default is 1500",
"userLevel": "User Level",
"userLevelDesc": "All connections made through this inbound will use this user level. Default is 0"
"userLevel": "User Level"
},
"nord": {
"accessToken": "Access token",
@@ -2048,7 +1876,6 @@
},
"fakedns": {
"add": "Add Fake DNS",
"edit": "Edit Fake DNS",
"ipPool": "IP Pool Subnet",
"poolSize": "Pool Size"
},
@@ -2067,7 +1894,6 @@
"add": "Add",
"month": "Month",
"months": "Months",
"day": "Day",
"days": "Days",
"hours": "Hours",
"minutes": "Minutes",
@@ -2106,7 +1932,6 @@
"userSaved": "✅ Telegram User saved.",
"loginSuccess": "✅ Logged in to the panel successfully.\r\n",
"loginFailed": "❗️Login attempt to the panel failed.\r\n",
"2faFailed": "2FA Failed",
"report": "🕰 Scheduled Reports: {{ .RunTime }}\r\n",
"datetime": "⏰ Date&Time: {{ .DateTime }}\r\n",
"hostname": "💻 Host: {{ .Hostname }}\r\n",
@@ -2139,7 +1964,6 @@
"download": "🔽 Download: ↓{{ .Download }}\r\n",
"total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
"TGUser": "👤 Telegram User: {{ .TelegramID }}\r\n",
"exhaustedMsg": "🚨 Exhausted {{ .Type }}:\r\n",
"exhaustedCount": "🚨 Exhausted {{ .Type }} count:\r\n",
"onlinesCount": "🌐 Online Clients: {{ .Count }}\r\n",
"disabled": "🛑 Disabled: {{ .Disabled }}\r\n",
@@ -2148,16 +1972,10 @@
"refreshedOn": "\r\n📋🔄 Refreshed On: {{ .Time }}\r\n\r\n",
"yes": "✅ Yes",
"no": "❌ No",
"received_id": "🔑📥 ID updated.",
"received_password": "🔑📥 Password updated.",
"received_email": "📧📥 Email updated.",
"received_comment": "💬📥 Comment updated.",
"id_prompt": "🔑 Default ID: {{ .ClientId }}\n\nEnter your ID.",
"pass_prompt": "🔑 Default Password: {{ .ClientPassword }}\n\nEnter your password.",
"email_prompt": "📧 Default Email: {{ .ClientEmail }}\n\nEnter your email.",
"comment_prompt": "💬 Default Comment: {{ .ClientComment }}\n\nEnter your comment.",
"inbound_client_data_id": "🔄 Inbound: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Email: {{ .ClientEmail }}\n📊 Traffic: {{ .ClientTraffic }}\n📅 Expire Date: {{ .ClientExp }}\n🌐 IP Limit: {{ .IpLimit }}\n💬 Comment: {{ .ClientComment }}\n\nYou can add the client to inbound now!",
"inbound_client_data_pass": "🔄 Inbound: {{ .InboundRemark }}\n\n🔑 Password: {{ .ClientPass }}\n📧 Email: {{ .ClientEmail }}\n📊 Traffic: {{ .ClientTraffic }}\n📅 Expire Date: {{ .ClientExp }}\n🌐 IP Limit: {{ .IpLimit }}\n💬 Comment: {{ .ClientComment }}\n\nYou can add the client to inbound now!",
"cancel": "❌ Process Canceled! \n\nYou can /start again anytime. 🔄",
"error_add_client": "⚠️ Error:\n\n {{ .error }}",
"using_default_value": "Okay, I'll stick with the default value. 😊",
@@ -2174,8 +1992,6 @@
"eventXrayCrashError": "Error: {{ .Error }}",
"eventNodeDown": "Node {{ .Name }} is DOWN",
"eventNodeUp": "Node {{ .Name }} is UP",
"eventCPUHigh": "CPU high",
"eventCPUHighDetail": "CPU: {{ .Detail }}",
"eventLoginFallback": "Login failed from {{ .Source }}",
"memoryThreshold": "Memory Load {{ .Percent }}% exceeds the threshold of {{ .Threshold }}%"
},
@@ -2216,11 +2032,8 @@
"submitDisable": "Submit As Disable ☑️",
"submitEnable": "Submit As Enable ✅",
"use_default": "🏷️ Use default",
"change_id": "⚙️🔑 ID",
"change_password": "⚙️🔑 Password",
"change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Comment",
"change_flow": "⚙️🚦 Flow",
"ResetAllTraffics": "Reset All Traffic",
"SortedTrafficUsageReport": "Sorted Traffic Usage Report"
},
@@ -2249,31 +2062,16 @@
}
},
"email": {
"subjectOutboundDown": "Outbound {{ .Tag }} is DOWN",
"subjectOutboundUp": "Outbound {{ .Tag }} is UP",
"subjectXrayCrash": "Xray CRASHED",
"subjectCPUHigh": "CPU high",
"subjectLoginSuccess": "Login successful",
"subjectLoginFailed": "Login failed",
"titleOutboundDown": "Outbound DOWN",
"titleOutboundUp": "Outbound UP",
"titleXrayCrash": "Xray CRASHED",
"titleCPUHigh": "CPU high",
"titleLoginSuccess": "Login successful",
"titleLoginFailed": "Login failed",
"labelStatus": "Status",
"labelOutbound": "Outbound",
"labelNode": "Node",
"labelError": "Error",
"labelDelay": "Delay",
"labelDetail": "Detail",
"labelUsername": "Username",
"labelIP": "IP",
"labelReason": "Reason",
"labelSource": "Source",
"labelTime": "Time",
"statusCrashed": "CRASHED",
"statusRunning": "Running",
"statusHigh": "HIGH",
"statusSuccess": "SUCCESS",
"statusFailed": "FAILED",
+25 -227
View File
@@ -48,7 +48,6 @@
"copySuccess": "Copiado exitosamente",
"sure": "Seguro",
"encryption": "Encriptación",
"useIPv4ForHost": "Usar IPv4 para el host",
"transmission": "Transmisión",
"host": "Host",
"path": "Ruta",
@@ -74,18 +73,9 @@
"twoFactorCode": "Código",
"remained": "Restante",
"security": "Seguridad",
"secAlertTitle": "Alerta de Seguridad",
"secAlertSsl": "Esta conexión no es segura. Por favor, evite ingresar información sensible hasta que se active TLS para la protección de datos.",
"secAlertConf": "Ciertas configuraciones son vulnerables a ataques. Se recomienda reforzar los protocolos de seguridad para prevenir posibles violaciones.",
"secAlertSSL": "El panel carece de una conexión segura. Por favor, instale un certificado TLS para la protección de datos.",
"secAlertPanelPort": "El puerto predeterminado del panel es vulnerable. Por favor, configure un puerto aleatorio o específico.",
"secAlertPanelURI": "La ruta URI predeterminada del panel no es segura. Por favor, configure una ruta URI compleja.",
"secAlertSubURI": "La ruta URI predeterminada de la suscripción no es segura. Por favor, configure una ruta URI compleja.",
"secAlertSubJsonURI": "La ruta URI JSON predeterminada de la suscripción no es segura. Por favor, configure una ruta URI compleja.",
"emptyDnsDesc": "No hay servidores DNS añadidos.",
"emptyFakeDnsDesc": "No hay servidores Fake DNS añadidos.",
"emptyBalancersDesc": "No hay balanceadores añadidos.",
"emptyReverseDesc": "No hay proxies inversos añadidos.",
"somethingWentWrong": "Algo salió mal",
"subscription": {
"title": "Información de suscripción",
@@ -106,8 +96,6 @@
},
"menu": {
"theme": "Tema",
"dark": "Oscuro",
"ultraDark": "Ultra Oscuro",
"dashboard": "Estado del Sistema",
"inbounds": "Entradas",
"clients": "Clientes",
@@ -118,12 +106,12 @@
"routing": "Enrutamiento",
"outbounds": "Salidas",
"apiDocs": "Documentación de la API",
"logout": "Cerrar Sesión",
"link": "Gestionar",
"donate": "Donar",
"hosts": "Hosts",
"docs": "Documentación",
"openMenu": "Abrir menú"
"openMenu": "Abrir menú",
"pinSidebar": "Fijar barra lateral",
"unpinSidebar": "Desfijar barra lateral"
},
"pages": {
"login": {
@@ -139,20 +127,15 @@
}
},
"index": {
"title": "Estado del Sistema",
"cpu": "CPU",
"logicalProcessors": "Procesadores lógicos",
"frequency": "Frecuencia",
"swap": "Swap",
"storage": "Almacenamiento",
"memory": "Memoria",
"threads": "Hilos",
"xrayStatus": "Xray",
"stopXray": "Detener",
"restartXray": "Reiniciar",
"xraySwitch": "Versión",
"xrayUpdates": "Actualizaciones de Xray",
"xraySwitchClick": "Elige la versión a la que deseas cambiar.",
"xraySwitchClickDesk": "Elige sabiamente, ya que las versiones anteriores pueden no ser compatibles con las configuraciones actuales.",
"updatePanel": "Actualizar panel",
"panelUpdateDesc": "Esto actualizará 3X-UI a la última versión y reiniciará el servicio del panel.",
@@ -164,13 +147,10 @@
"currentCommit": "Commit actual",
"latestCommit": "Último commit",
"updateChannelChanged": "Canal de actualización cambiado",
"upToDate": "Actualizado",
"xrayStatusUnknown": "Desconocido",
"xrayStatusRunning": "En ejecución",
"xrayStatusStop": "Detenido",
"xrayStatusError": "Error",
"xrayErrorPopoverTitle": "Se produjo un error al ejecutar Xray",
"operationHours": "Tiempo de Funcionamiento",
"systemHistoryTitle": "Historial del Sistema",
"historyTitleCpu": "Uso de CPU",
"historyTitleMem": "Uso de Memoria",
@@ -188,7 +168,6 @@
"historyTabLoad": "Carga",
"historyTabConnections": "Conexiones",
"historyTabDiskUsage": "Uso de Disco",
"charts": "Gráficos",
"xrayMetricsTitle": "Métricas de Xray",
"xrayTitleHeap": "Memoria Heap Asignada",
"xrayTitleSys": "Memoria Reservada del SO",
@@ -211,25 +190,19 @@
"xrayObservatoryDead": "Caído",
"xrayObservatoryLastSeen": "Visto por última vez",
"xrayObservatoryLastTry": "Último intento",
"trendLast2Min": "Últimos 2 minutos",
"systemLoad": "Carga del Sistema",
"systemLoadDesc": "promedio de carga del sistema en los últimos 1, 5 y 15 minutos",
"connectionCount": "Número de Conexiones",
"ipAddresses": "Direcciones IP",
"toggleIpVisibility": "Alternar visibilidad de la IP",
"overallSpeed": "Velocidad general",
"upload": "Subida",
"download": "Descargar",
"totalData": "Datos totales",
"sent": "Enviado",
"received": "Recibido",
"documentation": "Documentación",
"xraySwitchVersionDialog": "¿Realmente deseas cambiar la versión de Xray?",
"xraySwitchVersionDialogDesc": "Esto cambiará la versión de Xray a #version#.",
"xraySwitchVersionPopover": "Xray se actualizó correctamente",
"panelUpdateDialog": "¿Deseas actualizar el panel?",
"panelUpdateDialogDesc": "Esto actualizará 3X-UI a la versión #version# y reiniciará el servicio del panel.",
"panelUpdateCheckPopover": "Fallo al comprobar actualización del panel",
"panelUpdateStartedPopover": "Actualización del panel iniciada",
"panelUpdateFailedTitle": "Error al actualizar el panel",
"panelUpdateFailedDesc": "La actualización no se completó correctamente. Revisa los registros del servidor o ejecuta 'x-ui update' desde la línea de comandos.",
@@ -258,7 +231,6 @@
"accessLogs": "Registros de acceso",
"autoUpdate": "Actualización automática",
"config": "Configuración",
"backup": "Copia de seguridad",
"backupTitle": "Copia & Restauración",
"exportDatabase": "Copia de seguridad",
"exportDatabaseDesc": "Haz clic para descargar un archivo .db que contiene una copia de seguridad de tu base de datos actual en tu dispositivo. El mismo archivo también puede restaurarse en un panel que funcione con PostgreSQL.",
@@ -273,10 +245,20 @@
"exportDatabasePgDesc": "Haz clic para descargar un volcado de PostgreSQL (.dump) de tu base de datos actual en tu dispositivo.",
"importDatabasePgDesc": "Haz clic para seleccionar y subir una copia de seguridad de PostgreSQL (.dump), una base de datos SQLite (.db) o un volcado de migración de SQLite para restaurar tu base de datos. Esto reemplaza todos los datos actuales.",
"migrationDownload": "Descargar migración",
"migrationDownloadPgDesc": "Haz clic para descargar una base de datos SQLite .db creada a partir de tus datos de PostgreSQL, lista para ejecutar este panel en SQLite."
"migrationDownloadPgDesc": "Haz clic para descargar una base de datos SQLite .db creada a partir de tus datos de PostgreSQL, lista para ejecutar este panel en SQLite.",
"avg": "media",
"peak": "pico",
"free": "libre",
"openSockets": "sockets abiertos",
"throughputSub": "Total de la interfaz",
"avgWindow": "Media del periodo",
"healthWarm": "{list} — algo elevado",
"healthCritical": "{list} — crítico",
"panel": "Panel",
"threads": "Hilos",
"uptime": "Tiempo activo"
},
"inbounds": {
"title": "Entradas",
"totalDownUp": "Subidas/Descargas Totales",
"totalUsage": "Uso Total",
"inboundCount": "Número de Entradas",
@@ -288,21 +270,11 @@
"localPanel": "Panel local",
"fallbacks": {
"title": "Fallbacks",
"help": "Cuando una conexión en este inbound no coincide con ningún cliente, redirígela a otro lugar. Elige un inbound hijo abajo para rellenar automáticamente los campos de enrutamiento (SNI / ALPN / Path / xver) desde su transporte, o deja el selector vacío y define Dest directamente (p. ej. 8080 o 127.0.0.1:8080) para redirigir a un servidor externo como Nginx. Cada inbound hijo debe escuchar en 127.0.0.1 con security=none.",
"empty": "Aún no hay fallbacks",
"add": "Añadir fallback",
"pickInbound": "Selecciona un inbound",
"matchAny": "cualquiera",
"destPlaceholder": "automático (listen:puerto del hijo)",
"rederive": "Rellenar desde el hijo",
"rederived": "Rellenado desde el hijo",
"editAdvanced": "Editar campos de enrutamiento",
"hideAdvanced": "Ocultar avanzado",
"quickAddAll": "Añadir todos los elegibles",
"quickAdded": "Se añadieron {n} fallback(s)",
"quickAddedNone": "No hay nuevos inbounds elegibles",
"routesWhen": "Enruta cuando",
"defaultCatchAll": "Por defecto — captura cualquier otra cosa",
"needsTls": "Los fallbacks estarán disponibles al seleccionar TLS o Reality en la pestaña de Seguridad (solo VLESS/Trojan sobre RAW)."
},
"protocol": "Protocolo",
@@ -310,8 +282,6 @@
"portMap": "Asignación de puertos",
"traffic": "Tráfico",
"speed": "Velocidad",
"details": "Detalles",
"transportConfig": "Transporte",
"expireDate": "Fecha de Expiración",
"createdAt": "Creado",
"updatedAt": "Actualizado",
@@ -319,8 +289,6 @@
"addInbound": "Agregar Entrada",
"generalActions": "Acciones Generales",
"modifyInbound": "Modificar Entrada",
"deleteInbound": "Eliminar Entrada",
"deleteInboundContent": "¿Confirmar eliminación de entrada?",
"deleteConfirmTitle": "¿Eliminar el inbound \"{remark}\"?",
"deleteConfirmContent": "Esto elimina el inbound y todos sus clientes. No se puede deshacer.",
"resetConfirmTitle": "¿Restablecer el tráfico de \"{remark}\"?",
@@ -364,8 +332,6 @@
"exportAllLinksFileName": "Todas-las-entradas",
"exportAllSubsFileName": "Todas-las-entradas-Subs",
"inboundJsonTitle": "JSON de entrada",
"deleteClient": "Eliminar cliente",
"deleteClientContent": "¿Está seguro de que desea eliminar el cliente?",
"resetTrafficContent": "¿Confirmar restablecimiento de tráfico?",
"copyLink": "Copiar Enlace",
"address": "Dirección",
@@ -376,36 +342,19 @@
"meansNoLimit": "= Ilimitado. (unidad: GB)",
"totalFlow": "Flujo Total",
"leaveBlankToNeverExpire": "Dejar en Blanco para Nunca Expirar",
"noRecommendKeepDefault": "No hay requisitos especiales para mantener la configuración predeterminada",
"certificatePath": "Ruta Cert",
"certificateContent": "Datos Cert",
"publicKey": "Clave Pública",
"privatekey": "Clave Privada",
"clickOnQRcode": "Haz clic en el Código QR para Copiar",
"client": "Cliente",
"export": "Exportar Enlaces",
"clone": "Clonar",
"cloneInbound": "Clonar Entradas",
"cloneInboundContent": "Se aplicarán todas las configuraciones de esta entrada, excepto el Puerto, la IP de Escucha y los Clientes, al clon.",
"cloneInboundOk": "Clonar",
"resetAllTraffic": "Restablecer Tráfico de Todas las Entradas",
"resetAllTrafficTitle": "Restablecer tráfico de todas las entradas",
"resetAllTrafficContent": "¿Estás seguro de que deseas restablecer el tráfico de todas las entradas?",
"resetInboundClientTraffics": "Restablecer Tráfico de Clientes",
"resetInboundClientTrafficTitle": "Restablecer todo el tráfico de clientes",
"resetInboundClientTrafficContent": "¿Estás seguro de que deseas restablecer todo el tráfico para los clientes de esta entrada?",
"resetAllClientTraffics": "Restablecer Tráfico de Todos los Clientes",
"resetAllClientTrafficTitle": "Restablecer todo el tráfico de clientes",
"resetAllClientTrafficContent": "¿Estás seguro de que deseas restablecer todo el tráfico para todos los clientes?",
"delDepletedClients": "Eliminar Clientes Agotados",
"delDepletedClientsTitle": "Eliminar clientes agotados",
"delDepletedClientsContent": "¿Estás seguro de que deseas eliminar todos los clientes agotados?",
"email": "Email",
"emailDesc": "Por favor proporciona una dirección de correo electrónico única.",
"IPLimit": "Límite de IP",
"IPLimitDesc": "Desactiva la entrada si la cantidad supera el valor ingresado (ingresa 0 para desactivar el límite de IP).",
"IPLimitlog": "Registro de IP",
"IPLimitlogDesc": "Registro de historial de IPs (antes de habilitar la entrada después de que haya sido desactivada por el límite de IP, debes borrar el registro).",
"IPLimitlogclear": "Limpiar el Registro",
"setDefaultCert": "Establecer certificado desde el panel",
"setDefaultCertEmpty": "No hay certificado configurado para el panel. Configura uno en Ajustes primero.",
@@ -438,20 +387,15 @@
"sniffing": "Sniffing",
"sniffingHelp": "Envoltorio del bloque sniffing de Xray:",
"stream": "Stream",
"streamHelp": "Envoltorio del bloque stream de Xray:",
"jsonErrorPrefix": "JSON avanzado"
"streamHelp": "Envoltorio del bloque stream de Xray:"
},
"telegramDesc": "Por favor, proporciona el ID de Chat de Telegram. (usa el comando '/id' en el bot) o ({'@'}userinfobot)",
"subscriptionDesc": "Puedes encontrar tu enlace de suscripción en Detalles, también puedes usar el mismo nombre para varias configuraciones.",
"subSortIndex": "Orden sub",
"same": "misma",
"inboundInfo": "Información de entrada",
"exportInbound": "Exportación entrante",
"import": "Importar",
"importInbound": "Importar un entrante",
"periodicTrafficResetTitle": "Reset de Tráfico",
"periodicTrafficResetDesc": "Reiniciar automáticamente el contador de tráfico en intervalos especificados",
"lastReset": "Último reinicio",
"periodicTrafficResetDay": "Día de reinicio mensual",
"periodicTrafficReset": {
"never": "Nunca",
"daily": "Diariamente",
@@ -463,7 +407,6 @@
"obtain": "Recibir",
"updateSuccess": "La actualización fue exitosa",
"logCleanSuccess": "El registro ha sido limpiado",
"inboundsUpdateSuccess": "Entradas actualizadas correctamente",
"inboundUpdateSuccess": "Entrada actualizada correctamente",
"inboundCreateSuccess": "Entrada creada correctamente",
"bulkDeleted": "{count} inbounds eliminados",
@@ -473,7 +416,6 @@
"inboundClientDeleteSuccess": "Cliente de entrada eliminado",
"inboundClientUpdateSuccess": "Cliente de entrada actualizado",
"savedNodeOfflineWillSync": "Guardado localmente. Un nodo de respaldo está desconectado o deshabilitado: el cambio se sincronizará cuando vuelva a conectarse.",
"delDepletedClientsSuccess": "Todos los clientes con tráfico agotado fueron eliminados",
"resetAllClientTrafficSuccess": "Todo el tráfico del cliente ha sido reiniciado",
"resetAllTrafficSuccess": "Todo el tráfico ha sido reiniciado",
"resetInboundClientTrafficSuccess": "El tráfico ha sido reiniciado",
@@ -657,6 +599,10 @@
"maxTimeDiff": "Máx. diferencia de tiempo (ms)",
"minClientVer": "Mín. versión cliente",
"maxClientVer": "Máx. versión cliente",
"minClientVerHint": "Vacío no significa sin restricción: Xray-core aplica entonces el mínimo integrado de la build del núcleo en uso (26.3.27 en las versiones actuales) y rechaza a los clientes que reportan una versión anterior, incluidos núcleos de terceros como Mihomo y sing-box. Con 1.0.0 se aceptan, a costa de admitir huellas TLS obsoletas.",
"maxClientVerHint": "Vacío significa sin límite superior. Si se establece, no debe ser inferior al mínimo efectivo — la versión mínima del cliente o, si ese campo está vacío, el mínimo integrado de Xray-core — o todos los clientes serán rechazados.",
"clientVerInvalid": "La versión del cliente debe tener hasta tres números separados por puntos, cada uno 0-255 (p. ej. 26.3.27)",
"maxClientVerBelowMin": "La versión máxima del cliente no debe ser inferior a la versión mínima",
"shortIds": "Short IDs",
"realityTargetHint": "Obligatorio. Debe incluir un puerto (p. ej. example.com:443). Sin puerto, Xray-core no arranca.",
"realityTargetRequired": "El destino REALITY es obligatorio",
@@ -712,23 +658,6 @@
"peerNumber": "Peer {n}",
"peerNumberConfig": "Config Peer {n}"
},
"stream": {
"general": {
"request": "Pedido",
"response": "Respuesta",
"name": "Nombre",
"value": "Valor"
},
"tcp": {
"version": "Versión",
"method": "Método",
"path": "Ruta",
"status": "Estado",
"statusDescription": "Descripción de la Situación",
"requestHeader": "Encabezado de solicitud",
"responseHeader": "Encabezado de respuesta"
}
},
"sniffingDestOverride": "Anulación de destino"
},
"clients": {
@@ -742,24 +671,9 @@
"addExternalSubscription": "Añadir suscripción externa",
"noExternalLinks": "Aún no hay enlaces externos.",
"noExternalSubscriptions": "Aún no hay suscripciones externas.",
"add": "Añadir cliente",
"edit": "Editar cliente",
"submitAdd": "Añadir cliente",
"submitEdit": "Guardar cambios",
"clientCount": "Número de clientes",
"bulk": "Añadir en lote",
"copyFromInbound": "Copiar clientes desde inbound",
"copyToInbound": "Copiar clientes a",
"copySelected": "Copiar selección",
"copySource": "Origen",
"copyEmailPreview": "Vista previa del correo resultante",
"copySelectSourceFirst": "Selecciona primero un inbound de origen.",
"copyResult": "Resultado de la copia",
"copyResultSuccess": "Copiado correctamente",
"copyResultNone": "Nada que copiar: no hay clientes seleccionados o el origen está vacío",
"copyResultErrors": "Errores de copia",
"copyFlowLabel": "Flow para clientes nuevos (VLESS)",
"copyFlowHint": "Se aplica a todos los clientes copiados. Déjalo vacío para omitir.",
"selectAll": "Seleccionar todo",
"clearAll": "Limpiar todo",
"method": "Método",
@@ -770,7 +684,6 @@
"postfix": "Sufijo",
"delayedStart": "Iniciar tras el primer uso",
"expireDays": "Duración (días)",
"days": "Día(s)",
"renew": "Renovación automática",
"renewDesc": "Renovación automática tras la expiración. (0 = desactivado) (unidad: día)",
"renewDays": "Renovación automática (días)",
@@ -793,7 +706,6 @@
"sortExpiringSoonest": "Caducidad más próxima",
"has": "Tiene",
"hasNot": "No tiene",
"title": "Clientes",
"actions": "Acciones",
"totalGB": "Límite de tráfico (GB)",
"totalGBDesc": "Cuota de datos para este cliente. 0 = ilimitado.",
@@ -821,8 +733,6 @@
"addClient": "Añadir cliente",
"qrCode": "Código QR",
"clientInfo": "Información del cliente",
"delete": "Eliminar",
"reset": "Restablecer tráfico",
"editClient": "Editar cliente",
"client": "Cliente",
"enabled": "Habilitado",
@@ -836,13 +746,11 @@
"noLinks": "No hay enlaces compartibles — asocia primero este cliente a un inbound con protocolo válido.",
"link": "Enlace",
"resetNotPossible": "Asocia primero este cliente a un inbound.",
"general": "General",
"resetAllTraffics": "Restablecer tráfico de todos los clientes",
"resetAllTrafficsTitle": "¿Restablecer tráfico de todos los clientes?",
"resetAllTrafficsContent": "El contador de subida/bajada de cada cliente vuelve a cero. Las cuotas y la expiración no se modifican. Esta acción no se puede deshacer.",
"deleteConfirmTitle": "¿Eliminar al cliente {email}?",
"deleteConfirmContent": "Esto elimina al cliente de cada inbound asociado y descarta su registro de tráfico. No se puede deshacer.",
"deleteSelected": "Eliminar ({count})",
"adjustSelected": "Ajustar ({count})",
"subLinksSelected": "Enlaces sub ({count})",
"addToGroupTitle": "Añadir {count} cliente(s) a un grupo",
@@ -864,12 +772,10 @@
"bulkDisableConfirmTitle": "¿Deshabilitar {count} clientes?",
"bulkDisableConfirmContent": "Deshabilita cada cliente seleccionado en todos los inbounds asociados. Pierden el acceso de inmediato, pero se conservan sus registros y su tráfico.",
"selectedCount": "{count} seleccionado(s)",
"attachSelected": "Asociar ({count})",
"attachToInboundsTitle": "Asociar {count} cliente(s) a entrada(s)",
"attachToInboundsDesc": "Asocia los {count} cliente(s) seleccionados (mismo UUID/contraseña y tráfico compartido) a las entradas elegidas. Mantienen sus asociaciones existentes.",
"attachToInboundsTargets": "Entradas objetivo",
"attachToInboundsNoTargets": "No hay entradas multiusuario disponibles para asociar.",
"detachSelected": "Desasociar ({count})",
"detach": "Desasociar",
"detachFromInboundsTitle": "Desasociar {count} cliente(s) de entrada(s)",
"detachFromInboundsDesc": "Quita los {count} cliente(s) seleccionados de las entradas elegidas. Las parejas donde el cliente no estaba asociado se omiten silenciosamente. Los registros de los clientes se conservan (usa Delete para eliminar por completo).",
@@ -931,8 +837,6 @@
"reverseTagPlaceholder": "Reverse tag opcional",
"telegramId": "ID de usuario de Telegram",
"telegramIdPlaceholder": "ID numérico de usuario de Telegram (0 = ninguno)",
"created": "Creado",
"updated": "Actualizado",
"ipLimit": "Límite de IP",
"toasts": {
"deleted": "Cliente eliminado",
@@ -955,7 +859,6 @@
}
},
"groups": {
"title": "Grupos",
"name": "Nombre",
"clientCount": "Clientes",
"totalGroups": "Total de grupos",
@@ -997,7 +900,6 @@
"removeFromGroupResult": "Quitados {count} cliente(s) de {name}."
},
"nodes": {
"title": "Nodos",
"addNode": "Agregar nodo",
"editNode": "Editar nodo",
"totalNodes": "Total de nodos",
@@ -1016,8 +918,6 @@
"apiTokenPlaceholder": "Token desde la página de Configuración del panel remoto",
"apiTokenHint": "El panel remoto expone su token de API en Configuraciones de Seguridad → Token de API.",
"apiTokenKeepHint": "Déjalo en blanco para mantener el token actual",
"regenerate": "Regenerar token",
"regenerateConfirm": "Regenerar invalida el token actual. Cualquier panel central que lo use perderá el acceso hasta que se actualice. ¿Continuar?",
"allowPrivateAddress": "Permitir dirección privada",
"allowPrivateAddressHint": "Habilitar solo para nodos en una red privada o VPN.",
"outboundTag": "Outbound de conexión",
@@ -1049,7 +949,6 @@
"updatePanel": "Actualizar panel",
"updateSelected": "Actualizar seleccionados ({count})",
"updateAvailable": "Actualización disponible",
"upToDate": "Actualizado",
"updateConfirmTitle": "¿Actualizar {count} nodo(s) a la última versión?",
"updateConfirmContent": "Cada nodo seleccionado descarga la última versión y se reinicia con ella. Solo se actualizan los nodos habilitados y en línea.",
"updateDevChannel": "Actualizar al canal de desarrollo (último commit)",
@@ -1113,6 +1012,7 @@
"pinFetchFailed": "No se pudo obtener el certificado"
},
"settings": {
"defaultTag": "Predeterminado",
"title": "Configuraciones",
"save": "Guardar",
"infoDesc": "Cada cambio realizado aquí debe ser guardado. Por favor, reinicie el panel para aplicar los cambios.",
@@ -1441,6 +1341,8 @@
"eventMemoryHigh": "Uso de memoria alto (%)",
"remarkTemplate": "Plantilla de notas",
"remarkTemplateDesc": "Cuando se define, esto reemplaza el modelo de notas para cada enlace de suscripción — escribe tu propio formato con los tokens de variable (usa el botón para insertarlos). Déjalo vacío para usar el modelo anterior.",
"subShowIdentityOnAllLinks": "Mostrar identidad en cada enlace",
"subShowIdentityOnAllLinksDesc": "Si está activado, {{EMAIL}} y {{USERNAME}} permanecen en la nota de cada enlace del cuerpo de la suscripción. Los tokens de uso siguen solo en el primer enlace.",
"validation": {
"pathLeadingSlash": "La ruta debe comenzar con /"
},
@@ -1448,13 +1350,8 @@
"secretClearUndo": "Deshacer borrado"
},
"xray": {
"title": "Xray Configuración",
"save": "Guardar configuración",
"restart": "Reiniciar Xray",
"restartSuccess": "Xray se ha reiniciado correctamente",
"restartOutputTitle": "Salida del reinicio de Xray",
"restartConfirmTitle": "¿Reiniciar xray?",
"restartConfirmContent": "Recarga el servicio xray con la configuración guardada.",
"stopSuccess": "Xray se ha detenido correctamente",
"restartError": "Ocurrió un error al reiniciar Xray.",
"stopError": "Ocurrió un error al detener Xray.",
@@ -1464,7 +1361,6 @@
"generalConfigsDesc": "Estas opciones proporcionarán ajustes generales.",
"logConfigs": "Registro",
"logConfigsDesc": "Los registros pueden afectar la eficiencia de su servidor. Se recomienda habilitarlos sabiamente solo en caso de sus necesidades.",
"blockConfigsDesc": "Estas opciones evitarán que los usuarios se conecten a protocolos y sitios web específicos.",
"basicRouting": "Enrutamiento Básico",
"blockConnectionsConfigsDesc": "Estas opciones bloquearán el tráfico según el país solicitado específico.",
"directConnectionsConfigsDesc": "Una conexión directa asegura que el tráfico específico no sea enrutado a través de otro servidor.",
@@ -1474,10 +1370,6 @@
"directdomains": "Dominios Directos",
"ipv4Routing": "Enrutamiento IPv4",
"ipv4RoutingDesc": "Estas opciones solo enrutarán a los dominios objetivo a través de IPv4.",
"warpRouting": "Enrutamiento WARP",
"warpRoutingDesc": "Precaución: Antes de usar estas opciones, instale WARP en modo de proxy socks5 en su servidor siguiendo los pasos en el GitHub del panel. WARP enrutará el tráfico a los sitios web a través de los servidores de Cloudflare.",
"nordRouting": "Enrutamiento NordVPN",
"nordRoutingDesc": "Estas opciones enrutarán el tráfico basado en un destino específico a través de NordVPN.",
"Template": "Plantilla de Configuración de Xray",
"TemplateDesc": "Genera el archivo de configuración final de Xray basado en esta plantilla.",
"FreedomStrategy": "Configurar Estrategia para el Protocolo Freedom",
@@ -1491,10 +1383,7 @@
"outboundTestUrlDesc": "URL usada al probar la conectividad del outbound",
"Torrent": "Prohibir Uso de BitTorrent",
"Inbounds": "Entradas",
"InboundsDesc": "Cambia la plantilla de configuración para aceptar clientes específicos.",
"Outbounds": "Salidas",
"OutboundSubscriptions": "Suscripciones de salida",
"OutboundSubscriptionsDesc": "Importa salidas desde URLs de suscripción remotas (vmess/vless/trojan/ss/...). Las etiquetas se mantienen estables para usarlas en balanceadores y reglas de enrutamiento. Las actualizaciones son automáticas.",
"Balancers": "Equilibradores",
"balancerTagRequired": "La etiqueta es obligatoria",
"balancerSelectorRequired": "Elige al menos una salida",
@@ -1513,9 +1402,7 @@
"routeTesterMatchedOutbound": "Salida coincidente",
"routeTesterViaBalancer": "vía balanceador",
"routeTesterDefaultOutbound": "Ninguna regla de enrutamiento coincidió — el tráfico va a la salida predeterminada (primera).",
"OutboundsDesc": "Cambia la plantilla de configuración para definir formas de salida para este servidor.",
"Routings": "Reglas de enrutamiento",
"RoutingsDesc": "¡La prioridad de cada regla es importante!",
"completeTemplate": "Todo",
"logLevel": "Nivel de registro",
"logLevelDesc": "El nivel de registro para registros de errores, que indica la información que debe registrarse.",
@@ -1529,13 +1416,9 @@
"maskAddressDesc": "Máscara de dirección IP, cuando se habilita, reemplazará automáticamente la dirección IP que aparece en el registro.",
"statistics": "Estadísticas",
"statsInboundUplink": "Estadísticas de Subida de Entrada",
"statsInboundUplinkDesc": "Habilita la recopilación de estadísticas para el tráfico ascendente de todos los proxies de entrada.",
"statsInboundDownlink": "Estadísticas de Bajada de Entrada",
"statsInboundDownlinkDesc": "Habilita la recopilación de estadísticas para el tráfico descendente de todos los proxies de entrada.",
"statsOutboundUplink": "Estadísticas de Subida de Salida",
"statsOutboundUplinkDesc": "Habilita la recopilación de estadísticas para el tráfico ascendente de todos los proxies de salida.",
"statsOutboundDownlink": "Estadísticas de Bajada de Salida",
"statsOutboundDownlinkDesc": "Habilita la recopilación de estadísticas para el tráfico descendente de todos los proxies de salida.",
"connectionLimits": "Límites de conexión",
"connectionLimitsDesc": "Políticas a nivel de conexión para el nivel de usuario 0. Deja un campo vacío para usar el valor predeterminado de Xray.",
"connIdle": "Tiempo de inactividad",
@@ -1553,18 +1436,10 @@
"metricsListenDesc": "Expone las métricas estilo Prometheus de Xray en esta dirección:puerto (por ejemplo, 127.0.0.1:11111). Déjalo vacío para deshabilitarlo. Vincúlalo a localhost y ponlo tras un proxy inverso — no está autenticado.",
"metricsTag": "Etiqueta de métricas",
"rules": {
"first": "Primero",
"last": "Último",
"up": "Arriba",
"down": "Abajo",
"source": "Fuente",
"dest": "Destino",
"inbound": "Entrante",
"outbound": "Saliente",
"balancer": "Equilibrador",
"info": "Info",
"add": "Agregar Regla",
"edit": "Editar Regla",
"useComma": "Elementos separados por comas"
},
"routing": {
@@ -1605,7 +1480,6 @@
"ruleN": "Regla {n}",
"action": "Acción",
"redirect": "Redirect",
"fragment": "Fragment",
"finalRules": "Reglas finales",
"overrideXrayPrivateIp": "Sobrescribir el bloqueo de IP privada por defecto de Xray",
"blockDelay": "Retraso de bloqueo (ms)",
@@ -1631,43 +1505,17 @@
"keepAliveInterval": "Intervalo keep alive",
"markFwmark": "Mark (fwmark)",
"interface": "Interfaz",
"ipv6Only": "Solo IPv6",
"acceptProxyProtocol": "Aceptar proxy protocol",
"proxyProtocol": "Proxy protocol",
"tcpUserTimeoutMs": "TCP user timeout (ms)",
"tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
},
"outbound": {
"addOutbound": "Agregar salida",
"addReverse": "Agregar reverso",
"editOutbound": "Editar salida",
"editReverse": "Editar reverso",
"reverseTag": "Etiqueta Reverso",
"reverseTagDesc": "Etiqueta de salida del proxy inverso simple VLESS. Dejar vacío para deshabilitar. Cuando se establece, las conexiones de este cliente pueden usarse como túnel de proxy inverso.",
"reverseTagPlaceholder": "etiqueta de salida (vacío para deshabilitar)",
"tag": "Etiqueta",
"tagDesc": "etiqueta única",
"address": "Dirección",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"reverse": "Reverso",
"domain": "Dominio",
"type": "Tipo",
"bridge": "Bridge",
"portal": "Portal",
"link": "Enlace",
"intercon": "Interconexión",
"settings": "Configuración",
"accountInfo": "Información de la Cuenta",
"outboundStatus": "Estado de Salida",
"sendThrough": "Enviar a través de",
"targetStrategy": "Estrategia de destino",
"test": "Probar",
"testResult": "Resultado de la prueba",
"testing": "Probando conexión...",
"testSuccess": "Prueba exitosa",
"testFailed": "Prueba fallida",
"testError": "Error al probar la salida",
"modeRealDelay": "Retardo real",
"testModeTooltip": "TCP: sonda rápida solo de dial. HTTP: petición completa a través de xray. Retardo real: tiempo total incluyendo el establecimiento de la conexión.",
"testAll": "Probar todo",
@@ -1675,14 +1523,10 @@
"breakdownConnect": "Conexión al proxy",
"breakdownTls": "TLS vía salida",
"breakdownTtfb": "Primer byte",
"nordvpn": "NordVPN",
"accessToken": "Token de acceso",
"country": "País",
"server": "Servidor",
"city": "Ciudad",
"allCities": "Todas las ciudades",
"privateKey": "Clave privada",
"load": "Carga",
"moveToTop": "Mover al principio"
},
"outboundSub": {
@@ -1712,16 +1556,11 @@
"active": "Suscripciones activas",
"empty": "Aún no hay suscripciones. Añade una arriba.",
"colRemark": "Notas",
"colPrefix": "Prefijo",
"colInterval": "Intervalo",
"colLastFetch": "Última descarga",
"colEnabled": "Habilitado",
"auto": "auto",
"never": "nunca",
"yes": "Sí",
"no": "No",
"refreshNow": "Actualizar ahora",
"lastError": "Último error",
"deleteConfirm": "¿Eliminar esta suscripción?",
"restartHint": "Después de añadir o actualizar, reinicia Xray (o espera a la próxima recarga automática) para activar las salidas.",
"fromSubsTitle": "Desde suscripciones de salida (solo lectura)",
@@ -1738,8 +1577,6 @@
"tabBalancerSettings": "Ajustes del balanceador",
"tabObservatory": "Observatorio",
"observatory": {
"title": "Observatorio",
"burstTitle": "Observatorio Burst",
"autoManaged": "Los observadores se gestionan automáticamente a partir de tus balanceadores. Ajusta abajo cómo sondean; las salidas vigiladas siguen los selectores del balanceador.",
"emptyHint": "No hay ningún observador de conexión activo. Se añade uno automáticamente al crear un balanceador Least Ping o Least Load —o un balanceador Random / Round-robin con fallback— para que los balanceadores que usan observador puedan comprobar la salud de las salidas antes de elegir un destino.",
"mixedLegacy": "Esta configuración contiene Observatory y Burst Observatory a la vez. Xray usa un único observador global, por lo que este estado mixto heredado no está soportado; al guardar balanceadores se normalizará a un solo observador.",
@@ -1773,12 +1610,8 @@
"balancerRemoved": "Balanceador {tag} — eliminado (sin destinos restantes)"
},
"balancer": {
"addBalancer": "Agregar equilibrador",
"editBalancer": "Editar balanceador",
"balancerStrategy": "Estrategia",
"balancerSelectors": "Selectores",
"tag": "Etiqueta",
"tagDesc": "etiqueta única",
"tagDuplicate": "Etiqueta ya usada por otro balanceador",
"tagPlaceholder": "etiqueta única de balanceador",
"selector": "Selector",
@@ -1790,7 +1623,6 @@
"tolerance": "Tolerancia",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "No es posible utilizar balancerTag y outboundTag al mismo tiempo. Si se utilizan al mismo tiempo, sólo funcionará outboundTag.",
"costMatch": "Patrón de etiqueta",
"costValue": "Peso",
"costRegexp": "Coincidencia por expresión regular",
@@ -1805,7 +1637,6 @@
"publicKey": "Llave pública",
"allowedIPs": "IP permitidas",
"endpoint": "Punto final",
"psk": "Clave precompartida",
"domainStrategy": "Estrategia de dominio"
},
"amneziawg": {
@@ -1843,10 +1674,7 @@
"i1Hint": "Solo para AmneziaWG 2.0. Déjalo vacío para compatibilidad con 1.x."
},
"tun": {
"nameDesc": "El nombre de la interfaz TUN. El valor predeterminado es 'xray0'",
"mtuDesc": "Unidad Máxima de Transmisión. El tamaño máximo de los paquetes de datos. El valor predeterminado es 1500",
"userLevel": "Nivel de Usuario",
"userLevelDesc": "Todas las conexiones realizadas a través de este entrada utilizarán este nivel de usuario. El valor predeterminado es 0"
"userLevel": "Nivel de Usuario"
},
"nord": {
"accessToken": "Access token",
@@ -1931,7 +1759,6 @@
},
"fakedns": {
"add": "Agregar DNS Falso",
"edit": "Editar DNS Falso",
"ipPool": "Subred del grupo de IP",
"poolSize": "Tamaño del grupo"
},
@@ -2067,7 +1894,6 @@
"add": "Añadir",
"month": "Mes",
"months": "Meses",
"day": "Día",
"days": "Días",
"hours": "Horas",
"minutes": "Minutos",
@@ -2106,7 +1932,6 @@
"userSaved": "✅ Usuario de Telegram guardado.",
"loginSuccess": "✅ Has iniciado sesión en el panel con éxito.\r\n",
"loginFailed": "❗️ Falló el inicio de sesión en el panel.\r\n",
"2faFailed": "Error de 2FA",
"report": "🕰 Informes programados: {{ .RunTime }}\r\n",
"datetime": "⏰ Fecha y Hora: {{ .DateTime }}\r\n",
"hostname": "💻 Host: {{ .Hostname }}\r\n",
@@ -2139,7 +1964,6 @@
"download": "🔽 Descarga: ↓{{ .Download }}\r\n",
"total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
"TGUser": "👤 Usuario de Telegram: {{ .TelegramID }}\r\n",
"exhaustedMsg": "🚨 Agotado {{ .Type }}:\r\n",
"exhaustedCount": "🚨 Cantidad de Agotados {{ .Type }}:\r\n",
"onlinesCount": "🌐 Clientes en línea: {{ .Count }}\r\n",
"disabled": "🛑 Desactivado: {{ .Disabled }}\r\n",
@@ -2148,16 +1972,10 @@
"refreshedOn": "\r\n📋🔄 Actualizado en: {{ .Time }}\r\n\r\n",
"yes": "✅ Sí",
"no": "❌ No",
"received_id": "🔑📥 ID actualizado.",
"received_password": "🔑📥 Contraseña actualizada.",
"received_email": "📧📥 Correo electrónico actualizado.",
"received_comment": "💬📥 Comentario actualizado.",
"id_prompt": "🔑 ID predeterminado: {{ .ClientId }}\n\nIntroduce tu ID.",
"pass_prompt": "🔑 Contraseña predeterminada: {{ .ClientPassword }}\n\nIntroduce tu contraseña.",
"email_prompt": "📧 Correo electrónico predeterminado: {{ .ClientEmail }}\n\nIntroduce tu correo electrónico.",
"comment_prompt": "💬 Comentario predeterminado: {{ .ClientComment }}\n\nIntroduce tu comentario.",
"inbound_client_data_id": "🔄 Entrada: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Correo: {{ .ClientEmail }}\n📊 Tráfico: {{ .ClientTraffic }}\n📅 Fecha de expiración: {{ .ClientExp }}\n🌐 Límite de IP: {{ .IpLimit }}\n💬 Comentario: {{ .ClientComment }}\n\n¡Ahora puedes agregar al cliente a la entrada!",
"inbound_client_data_pass": "🔄 Entrada: {{ .InboundRemark }}\n\n🔑 Contraseña: {{ .ClientPass }}\n📧 Correo: {{ .ClientEmail }}\n📊 Tráfico: {{ .ClientTraffic }}\n📅 Fecha de expiración: {{ .ClientExp }}\n🌐 Límite de IP: {{ .IpLimit }}\n💬 Comentario: {{ .ClientComment }}\n\n¡Ahora puedes agregar al cliente a la entrada!",
"cancel": "❌ ¡Proceso cancelado! \n\nPuedes /start de nuevo en cualquier momento. 🔄",
"error_add_client": "⚠️ Error:\n\n {{ .error }}",
"using_default_value": "Está bien, me quedaré con el valor predeterminado. 😊",
@@ -2174,8 +1992,6 @@
"eventXrayCrashError": "Error: {{ .Error }}",
"eventNodeDown": "El nodo {{ .Name }} está CAÍDO",
"eventNodeUp": "El nodo {{ .Name }} está ACTIVO",
"eventCPUHigh": "CPU alta",
"eventCPUHighDetail": "CPU: {{ .Detail }}",
"eventLoginFallback": "Inicio de sesión fallido desde {{ .Source }}",
"memoryThreshold": "Uso de memoria {{ .Percent }}% supera el umbral de {{ .Threshold }}%"
},
@@ -2216,11 +2032,8 @@
"submitDisable": "Enviar como deshabilitado ☑️",
"submitEnable": "Enviar como habilitado ✅",
"use_default": "🏷️ Usar por defecto",
"change_id": "⚙️🔑 ID",
"change_password": "⚙️🔑 Contraseña",
"change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Comentario",
"change_flow": "⚙️🚦 Flow",
"ResetAllTraffics": "Reiniciar todo el tráfico",
"SortedTrafficUsageReport": "Informe de uso de tráfico ordenado"
},
@@ -2249,31 +2062,16 @@
}
},
"email": {
"subjectOutboundDown": "El saliente {{ .Tag }} está CAÍDO",
"subjectOutboundUp": "El saliente {{ .Tag }} está ACTIVO",
"subjectXrayCrash": "Xray se ha BLOQUEADO",
"subjectCPUHigh": "CPU alta",
"subjectLoginSuccess": "Inicio de sesión correcto",
"subjectLoginFailed": "Inicio de sesión fallido",
"titleOutboundDown": "Saliente CAÍDO",
"titleOutboundUp": "Saliente ACTIVO",
"titleXrayCrash": "Xray se ha BLOQUEADO",
"titleCPUHigh": "CPU alta",
"titleLoginSuccess": "Inicio de sesión correcto",
"titleLoginFailed": "Inicio de sesión fallido",
"labelStatus": "Estado",
"labelOutbound": "Saliente",
"labelNode": "Nodo",
"labelError": "Error",
"labelDelay": "Retardo",
"labelDetail": "Detalle",
"labelUsername": "Usuario",
"labelIP": "IP",
"labelReason": "Motivo",
"labelSource": "Origen",
"labelTime": "Hora",
"statusCrashed": "BLOQUEADO",
"statusRunning": "En ejecución",
"statusHigh": "ALTA",
"statusSuccess": "CORRECTO",
"statusFailed": "FALLIDO",
+25 -227
View File
@@ -48,7 +48,6 @@
"copySuccess": "باموفقیت کپی‌شد",
"sure": "مطمئن",
"encryption": "رمزگذاری",
"useIPv4ForHost": "از IPv4 برای میزبان استفاده کنید",
"transmission": "راه‌اتصال",
"host": "میزبان",
"path": "مسیر",
@@ -74,18 +73,9 @@
"twoFactorCode": "کد",
"remained": "باقی‌مانده",
"security": "امنیت",
"secAlertTitle": "هشدار‌امنیتی",
"secAlertSsl": "این‌اتصال‌امن نیست. لطفا‌ تازمانی‌که تی‌ال‌اس برای محافظت از‌ داده‌ها فعال نشده‌است، از وارد کردن اطلاعات حساس خودداری کنید",
"secAlertConf": "تنظیمات خاصی در برابر حملات آسیب پذیر هستند. توصیه می‌شود پروتکل‌های امنیتی را برای جلوگیری از نفوذ احتمالی تقویت کنید",
"secAlertSSL": "پنل فاقد ارتباط امن است. لطفاً یک گواهینامه تی‌ال‌اس برای محافظت از داده‌ها نصب کنید",
"secAlertPanelPort": "استفاده از پورت پیش‌فرض پنل ناامن است. لطفاً یک پورت تصادفی یا خاص تنظیم کنید",
"secAlertPanelURI": "مسیر پیش‌فرض لینک پنل ناامن است. لطفاً یک مسیر پیچیده تنظیم کنید",
"secAlertSubURI": "مسیر پیش‌فرض لینک سابسکریپشن ناامن است. لطفاً یک مسیر پیچیده تنظیم کنید",
"secAlertSubJsonURI": "مسیر پیش‌فرض لینک سابسکریپشن جیسون ناامن است. لطفاً یک مسیر پیچیده تنظیم کنید",
"emptyDnsDesc": "هیچ سرور DNS اضافه نشده است.",
"emptyFakeDnsDesc": "هیچ سرور Fake DNS اضافه نشده است.",
"emptyBalancersDesc": "هیچ بالانسر اضافه نشده است.",
"emptyReverseDesc": "هیچ پروکسی معکوس اضافه نشده است.",
"somethingWentWrong": "مشکلی پیش آمد",
"subscription": {
"title": "اطلاعات سابسکریپشن",
@@ -106,8 +96,6 @@
},
"menu": {
"theme": "تم",
"dark": "تیره",
"ultraDark": "فوق تیره",
"dashboard": "نمای کلی",
"inbounds": "ورودی‌ها",
"clients": "کلاینت‌ها",
@@ -118,12 +106,12 @@
"routing": "مسیریابی",
"outbounds": "خروجی‌ها",
"apiDocs": "مستندات API",
"logout": "خروج",
"link": "مدیریت",
"donate": "حمایت مالی",
"hosts": "میزبان‌ها",
"docs": "مستندات",
"openMenu": "باز کردن منو"
"openMenu": "باز کردن منو",
"pinSidebar": "ثابت کردن نوار کناری",
"unpinSidebar": "برداشتن تثبیت نوار کناری"
},
"pages": {
"login": {
@@ -139,20 +127,15 @@
}
},
"index": {
"title": "نمای کلی",
"cpu": "پردازنده",
"logicalProcessors": "پردازنده‌های منطقی",
"frequency": "فرکانس",
"swap": "سواپ",
"storage": "ذخیره‌سازی",
"memory": "حافظه",
"threads": "نخ‌ها",
"xrayStatus": "Xray",
"stopXray": "توقف",
"restartXray": "راه‌اندازی مجدد",
"xraySwitch": "‌نسخه",
"xrayUpdates": "به‌روزرسانی‌های Xray",
"xraySwitchClick": "نسخه مورد نظر را انتخاب کنید",
"xraySwitchClickDesk": "لطفا بادقت انتخاب کنید. درصورت انتخاب نسخه قدیمی‌تر، امکان ناهماهنگی با پیکربندی فعلی وجود دارد",
"updatePanel": "به‌روزرسانی پنل",
"panelUpdateDesc": "این عملیات 3X-UI را به آخرین نسخه به‌روزرسانی می‌کند و سرویس پنل را مجدداً راه‌اندازی می‌کند.",
@@ -164,13 +147,10 @@
"currentCommit": "کامیت فعلی",
"latestCommit": "آخرین کامیت",
"updateChannelChanged": "کانال به‌روزرسانی تغییر کرد",
"upToDate": "به‌روز",
"xrayStatusUnknown": "ناشناخته",
"xrayStatusRunning": "در حال اجرا",
"xrayStatusStop": "متوقف",
"xrayStatusError": "خطا",
"xrayErrorPopoverTitle": "خطا در هنگام اجرای Xray رخ داد",
"operationHours": "مدت‌کارکرد",
"systemHistoryTitle": "تاریخچه سیستم",
"historyTitleCpu": "مصرف پردازنده",
"historyTitleMem": "مصرف حافظه",
@@ -188,7 +168,6 @@
"historyTabLoad": "بار",
"historyTabConnections": "اتصالات",
"historyTabDiskUsage": "مصرف دیسک",
"charts": "نمودارها",
"xrayMetricsTitle": "متریک‌های Xray",
"xrayTitleHeap": "حافظه‌ی Heap تخصیص‌یافته",
"xrayTitleSys": "حافظه‌ی رزروشده از سیستم‌عامل",
@@ -211,25 +190,19 @@
"xrayObservatoryDead": "غیرفعال",
"xrayObservatoryLastSeen": "آخرین مشاهده",
"xrayObservatoryLastTry": "آخرین تلاش",
"trendLast2Min": "۲ دقیقه اخیر",
"systemLoad": "بارسیستم",
"systemLoadDesc": "میانگین بار سیستم برای 1، 5 و 15 دقیقه گذشته",
"connectionCount": "تعداد کانکشن ها",
"ipAddresses": "آدرس‌های IP",
"toggleIpVisibility": "تغییر وضعیت نمایش IP",
"overallSpeed": "سرعت کلی",
"upload": "آپلود",
"download": "دانلود",
"totalData": "داده‌های کل",
"sent": "ارسال شده",
"received": "دریافت شده",
"documentation": "مستندات",
"xraySwitchVersionDialog": "آیا واقعاً می‌خواهید نسخه Xray را تغییر دهید؟",
"xraySwitchVersionDialogDesc": "این کار نسخه Xray را به #version# تغییر می‌دهد.",
"xraySwitchVersionPopover": "Xray با موفقیت به‌روز شد",
"panelUpdateDialog": "آیا مطمئن هستید که می‌خواهید پنل را به‌روزرسانی کنید؟",
"panelUpdateDialogDesc": "این 3X-UI را به نسخه #version# به‌روزرسانی کرده و سرویس پنل را مجدداً راه‌اندازی می‌کند.",
"panelUpdateCheckPopover": "خطا در بررسی به‌روزرسانی پنل",
"panelUpdateStartedPopover": "به‌روزرسانی پنل آغاز شد",
"panelUpdateFailedTitle": "به‌روزرسانی پنل ناموفق بود",
"panelUpdateFailedDesc": "به‌روزرسانی با موفقیت به پایان نرسید. گزارش‌های سرور را بررسی کنید یا دستور «x-ui update» را از خط فرمان اجرا کنید.",
@@ -258,7 +231,6 @@
"accessLogs": "لاگ‌های دسترسی",
"autoUpdate": "به‌روزرسانی خودکار",
"config": "پیکربندی",
"backup": "پشتیبان‌گیری",
"backupTitle": "پشتیبان‌گیری و بازیابی",
"exportDatabase": "پشتیبان‌گیری",
"exportDatabaseDesc": "برای دانلود یک فایل .db حاوی پشتیبان از پایگاه داده فعلی خود به دستگاهتان کلیک کنید. همین فایل در پنلی که روی PostgreSQL اجرا می‌شود نیز قابل بازیابی است.",
@@ -273,10 +245,20 @@
"exportDatabasePgDesc": "برای دانلود یک دامپ PostgreSQL (.dump) از پایگاه داده فعلی روی دستگاهتان کلیک کنید.",
"importDatabasePgDesc": "برای انتخاب و بارگذاری یک پشتیبان PostgreSQL (.dump)، پایگاه‌دادهٔ SQLite (.db) یا فایل مهاجرت SQLite جهت بازیابی پایگاه داده کلیک کنید. این کار همه داده‌های فعلی را جایگزین می‌کند.",
"migrationDownload": "دانلود فایل مهاجرت",
"migrationDownloadPgDesc": "برای دانلود یک پایگاه‌دادهٔ SQLite با پسوند ‎.db که از داده‌های PostgreSQL شما ساخته می‌شود کلیک کنید؛ آمادهٔ اجرای این پنل روی SQLite."
"migrationDownloadPgDesc": "برای دانلود یک پایگاه‌دادهٔ SQLite با پسوند ‎.db که از داده‌های PostgreSQL شما ساخته می‌شود کلیک کنید؛ آمادهٔ اجرای این پنل روی SQLite.",
"avg": "میانگین",
"peak": "اوج",
"free": "آزاد",
"openSockets": "اتصال باز",
"throughputSub": "کل اینترفیس",
"avgWindow": "میانگین بازه",
"healthWarm": "{list} — رو به گرم شدن",
"healthCritical": "{list} — بحرانی",
"panel": "پنل",
"threads": "نخ‌ها",
"uptime": "مدت کارکرد"
},
"inbounds": {
"title": "ورودی‌ها",
"totalDownUp": "دریافت/ارسال کل",
"totalUsage": "‌‌‌مصرف کل",
"inboundCount": "کل ورودی‌ها",
@@ -288,21 +270,11 @@
"localPanel": "پنل لوکال",
"fallbacks": {
"title": "Fallbackها",
"help": "وقتی اتصالی روی این اینباند با هیچ کلاینتی تطبیق پیدا نمی‌کند، به جای دیگری ارجاع داده می‌شود. یک اینباند فرزند از پایین انتخاب کنید تا فیلدهای مسیریابی (SNI / ALPN / Path / xver) خودکار از روی transport آن پر شود، یا انتخاب‌گر را خالی بگذارید و مقدار Dest را مستقیم تنظیم کنید (مثلاً 8080 یا 127.0.0.1:8080) تا به یک سرور بیرونی مانند Nginx ارجاع شود. هر اینباند فرزند باید روی 127.0.0.1 با security=none گوش بدهد.",
"empty": "هنوز فال‌بکی اضافه نشده",
"add": "افزودن فال‌بک",
"pickInbound": "یک اینباند انتخاب کنید",
"matchAny": "همه",
"destPlaceholder": "خودکار (listen:port فرزند)",
"rederive": "پر کردن مجدد از فرزند",
"rederived": "از فرزند پر شد",
"editAdvanced": "ویرایش فیلدهای مسیریابی",
"hideAdvanced": "بستن پیشرفته",
"quickAddAll": "افزودن سریع همه‌ی موارد واجد شرایط",
"quickAdded": "{n} فال‌بک افزوده شد",
"quickAddedNone": "اینباند جدیدی برای افزودن وجود ندارد",
"routesWhen": "هدایت می‌شود وقتی",
"defaultCatchAll": "پیش‌فرض — همه‌ی موارد دیگر را می‌گیرد",
"needsTls": "فال‌بک‌ها پس از انتخاب TLS یا Reality در برگه‌ی امنیت در دسترس می‌شوند (فقط VLESS/Trojan روی RAW)."
},
"protocol": "پروتکل",
@@ -310,8 +282,6 @@
"portMap": "نگاشت پورت",
"traffic": "ترافیک",
"speed": "سرعت",
"details": "توضیحات",
"transportConfig": "انتقال",
"expireDate": "مدت زمان",
"createdAt": "ایجاد",
"updatedAt": "به‌روزرسانی",
@@ -319,8 +289,6 @@
"addInbound": "افزودن ورودی",
"generalActions": "عملیات کلی",
"modifyInbound": "ویرایش ورودی",
"deleteInbound": "حذف ورودی",
"deleteInboundContent": "آیا مطمئن به حذف ورودی هستید؟",
"deleteConfirmTitle": "اینباند «{remark}» حذف شود؟",
"deleteConfirmContent": "این اینباند و تمام کلاینت‌های آن حذف می‌شود. این عمل غیرقابل بازگشت است.",
"resetConfirmTitle": "ترافیک اینباند «{remark}» صفر شود؟",
@@ -364,8 +332,6 @@
"exportAllLinksFileName": "همه-ورودی‌ها",
"exportAllSubsFileName": "همه-ورودی‌ها-Subs",
"inboundJsonTitle": "JSON ورودی",
"deleteClient": "حذف کاربر",
"deleteClientContent": "آیا مطمئن به حذف کاربر هستید؟",
"resetTrafficContent": "آیا مطمئن به ریست ترافیک هستید؟",
"copyLink": "کپی لینک",
"address": "آدرس",
@@ -376,36 +342,19 @@
"meansNoLimit": "= نامحدود. (واحد: GB)",
"totalFlow": "ترافیک کل",
"leaveBlankToNeverExpire": "برای منقضی‌نشدن خالی‌بگذارید",
"noRecommendKeepDefault": "توصیه‌می‌شود به‌طور پیش‌فرض حفظ‌شود",
"certificatePath": "مسیر فایل",
"certificateContent": "محتوای فایل",
"publicKey": "کلید عمومی",
"privatekey": "کلید خصوصی",
"clickOnQRcode": "برای کپی بر روی کدتصویری کلیک کنید",
"client": "کاربر",
"export": "استخراج لینک‌ها",
"clone": "شبیه‌سازی",
"cloneInbound": "شبیه‌سازی ورودی",
"cloneInboundContent": "همه موارد این ورودی بجز پورت، آی‌پی و کاربر‌ها شبیه‌سازی خواهند شد",
"cloneInboundOk": "ساختن شبیه ساز",
"resetAllTraffic": "ریست ترافیک کل ورودی‌ها",
"resetAllTrafficTitle": "ریست ترافیک کل ورودی‌ها",
"resetAllTrafficContent": "آیا مطمئن به ریست ترافیک تمام ورودی‌ها هستید؟",
"resetInboundClientTraffics": "ریست ترافیک کاربران",
"resetInboundClientTrafficTitle": "ریست ترافیک کاربران",
"resetInboundClientTrafficContent": "آیا مطمئن به ریست ترافیک تمام کاربران این‌ ورودی هستید؟",
"resetAllClientTraffics": "ریست ترافیک کل کاربران",
"resetAllClientTrafficTitle": "ریست ترافیک کل کاربران",
"resetAllClientTrafficContent": "آیا مطمئن به ریست ترافیک تمام کاربران هستید؟",
"delDepletedClients": "حذف کاربران منقضی",
"delDepletedClientsTitle": "حذف کاربران منقضی",
"delDepletedClientsContent": "آیا مطمئن به حذف تمام کاربران منقضی‌شده ‌هستید؟",
"email": "ایمیل",
"emailDesc": "باید یک ایمیل یکتا باشد",
"IPLimit": "محدودیت آی‌پی",
"IPLimitDesc": "(اگر تعداد از مقدار تنظیم شده بیشتر شود، ورودی را غیرفعال می کند. (0 = غیرفعال",
"IPLimitlog": "گزارش‌ها",
"IPLimitlogDesc": "گزارش تاریخچه آی‌پی. برای فعال کردن ورودی پس از غیرفعال شدن، گزارش را پاک کنید",
"IPLimitlogclear": "پاک کردن گزارش‌ها",
"setDefaultCert": "استفاده از گواهی پنل",
"setDefaultCertEmpty": "هیچ گواهی‌ای برای پنل پیکربندی نشده. ابتدا از تنظیمات یکی تعیین کنید.",
@@ -438,20 +387,15 @@
"sniffing": "Sniffing",
"sniffingHelp": "ساختار بلوک sniffing در Xray:",
"stream": "Stream",
"streamHelp": "ساختار بلوک stream در Xray:",
"jsonErrorPrefix": "JSON پیشرفته"
"streamHelp": "ساختار بلوک stream در Xray:"
},
"telegramDesc": "لطفا شناسه گفتگوی تلگرام را وارد کنید. (از دستور '/id' در ربات استفاده کنید) یا ({'@'}userinfobot)",
"subscriptionDesc": "شما می‌توانید لینک سابسکربپشن خودرا در 'جزئیات' پیدا کنید، همچنین می‌توانید از همین نام برای چندین کاربر استفاده‌کنید",
"subSortIndex": "ترتیب اشتراک",
"same": "همسان",
"inboundInfo": "اطلاعات ورودی",
"exportInbound": "استخراج ورودی",
"import": "افزودن",
"importInbound": "افزودن یک ورودی",
"periodicTrafficResetTitle": "بازنشانی ترافیک",
"periodicTrafficResetDesc": "بازنشانی خودکار شمارنده ترافیک در فواصل زمانی مشخص",
"lastReset": "آخرین بازنشانی",
"periodicTrafficResetDay": "روز بازنشانی ماهانه",
"periodicTrafficReset": {
"never": "هرگز",
"daily": "روزانه",
@@ -463,7 +407,6 @@
"obtain": "فراهم‌سازی",
"updateSuccess": "بروزرسانی با موفقیت انجام شد",
"logCleanSuccess": "لاگ پاکسازی شد",
"inboundsUpdateSuccess": "ورودی‌ها با موفقیت به‌روزرسانی شدند",
"inboundUpdateSuccess": "ورودی با موفقیت به‌روزرسانی شد",
"inboundCreateSuccess": "ورودی با موفقیت ایجاد شد",
"bulkDeleted": "{count} اینباند حذف شد",
@@ -473,7 +416,6 @@
"inboundClientDeleteSuccess": "کلاینت ورودی حذف شد",
"inboundClientUpdateSuccess": "کلاینت ورودی به‌روزرسانی شد",
"savedNodeOfflineWillSync": "به‌صورت محلی ذخیره شد. یک نود پشتیبان آفلاین یا غیرفعال است — تغییر پس از اتصال مجدد همگام‌سازی می‌شود.",
"delDepletedClientsSuccess": "تمام کلاینت‌های مصرف شده حذف شدند",
"resetAllClientTrafficSuccess": "تمام ترافیک کلاینت بازنشانی شد",
"resetAllTrafficSuccess": "تمام ترافیک‌ها بازنشانی شدند",
"resetInboundClientTrafficSuccess": "ترافیک بازنشانی شد",
@@ -648,6 +590,10 @@
"maxTimeDiff": "حداکثر اختلاف زمان (ms)",
"minClientVer": "حداقل نسخه کلاینت",
"maxClientVer": "حداکثر نسخه کلاینت",
"minClientVerHint": "خالی بودن به معنای بدون محدودیت نیست: در این حالت Xray-core حداقل داخلیِ نسخهٔ هسته‌ای را که اجرا می‌کنید (در نسخه‌های فعلی 26.3.27) اعمال می‌کند و کلاینت‌هایی را که نسخهٔ قدیمی‌تری اعلام می‌کنند رد می‌کند — از جمله هسته‌های شخص ثالث مانند Mihomo و sing-box. مقدار 1.0.0 آن‌ها را می‌پذیرد، به بهای پذیرش اثر انگشت‌های TLS قدیمی.",
"maxClientVerHint": "خالی یعنی بدون سقف. در صورت تنظیم، نباید از حداقلِ مؤثر — حداقل نسخه کلاینت، و در صورت خالی بودن آن فیلد، حداقل داخلی Xray-core — کمتر باشد، وگرنه همهٔ کلاینت‌ها رد می‌شوند.",
"clientVerInvalid": "نسخهٔ کلاینت باید حداکثر سه عدد جداشده با نقطه باشد، هر یک 0-255 (مثلاً 26.3.27)",
"maxClientVerBelowMin": "حداکثر نسخهٔ کلاینت نباید از حداقل نسخهٔ کلاینت کمتر باشد",
"shortIds": "Short IDها",
"realityTargetHint": "الزامی است. باید شامل پورت باشد (مثلاً example.com:443). بدون پورت، Xray-core اجرا نمی‌شود.",
"realityTargetRequired": "هدف REALITY الزامی است",
@@ -712,23 +658,6 @@
"peerNumber": "Peer {n}",
"peerNumberConfig": "پیکربندی Peer {n}"
},
"stream": {
"general": {
"request": "درخواست",
"response": "پاسخ",
"name": "نام",
"value": "مقدار"
},
"tcp": {
"version": "نسخه",
"method": "متد",
"path": "مسیر",
"status": "وضعیت",
"statusDescription": "توضیحات وضعیت",
"requestHeader": "سربرگ درخواست",
"responseHeader": "سربرگ پاسخ"
}
},
"sniffingDestOverride": "بازنویسی مقصد"
},
"clients": {
@@ -742,24 +671,9 @@
"addExternalSubscription": "افزودن سابسکریپشن خارجی",
"noExternalLinks": "هنوز لینک خارجی‌ای اضافه نشده.",
"noExternalSubscriptions": "هنوز سابسکریپشن خارجی‌ای اضافه نشده.",
"add": "افزودن کلاینت",
"edit": "ویرایش کلاینت",
"submitAdd": "افزودن کلاینت",
"submitEdit": "ذخیره تغییرات",
"clientCount": "تعداد کلاینت‌ها",
"bulk": "افزودن گروهی",
"copyFromInbound": "کپی کلاینت‌ها از اینباند",
"copyToInbound": "کپی کلاینت‌ها به",
"copySelected": "کپی انتخاب‌شده‌ها",
"copySource": "منبع",
"copyEmailPreview": "پیش‌نمایش ایمیل خروجی",
"copySelectSourceFirst": "ابتدا یک اینباند مبدأ انتخاب کنید.",
"copyResult": "نتیجه کپی",
"copyResultSuccess": "با موفقیت کپی شد",
"copyResultNone": "چیزی برای کپی نیست: کلاینتی انتخاب نشده یا منبع خالی است",
"copyResultErrors": "خطاهای کپی",
"copyFlowLabel": "Flow برای کلاینت‌های جدید (VLESS)",
"copyFlowHint": "روی همه کلاینت‌های کپی‌شده اعمال می‌شود. خالی بگذارید تا رد شود.",
"selectAll": "انتخاب همه",
"clearAll": "پاک کردن همه",
"method": "روش",
@@ -770,7 +684,6 @@
"postfix": "پسوند",
"delayedStart": "شروع پس از اولین استفاده",
"expireDays": "مدت اعتبار (روز)",
"days": "روز",
"renew": "تمدید خودکار",
"renewDesc": "تمدید خودکار پس از انقضا. (۰ = غیرفعال) (واحد: روز)",
"renewDays": "تمدید خودکار (روز)",
@@ -793,7 +706,6 @@
"sortExpiringSoonest": "نزدیک‌ترین انقضا",
"has": "دارد",
"hasNot": "ندارد",
"title": "کلاینت‌ها",
"actions": "عملیات",
"totalGB": "سقف حجم (گیگابایت)",
"totalGBDesc": "سهمیه‌ی حجم مصرفی کلاینت. ۰ = نامحدود",
@@ -821,8 +733,6 @@
"addClient": "افزودن کلاینت",
"qrCode": "کد QR",
"clientInfo": "اطلاعات کلاینت",
"delete": "حذف",
"reset": "بازنشانی ترافیک",
"editClient": "ویرایش کلاینت",
"client": "کلاینت",
"enabled": "فعال",
@@ -836,13 +746,11 @@
"noLinks": "لینکی برای اشتراک‌گذاری نیست — ابتدا این کلاینت را به یک اینباند با پروتکل سازگار متصل کنید.",
"link": "لینک",
"resetNotPossible": "ابتدا این کلاینت را به یک اینباند متصل کنید.",
"general": "عمومی",
"resetAllTraffics": "بازنشانی ترافیک همه کلاینت‌ها",
"resetAllTrafficsTitle": "بازنشانی ترافیک همه کلاینت‌ها؟",
"resetAllTrafficsContent": "شمارنده ارسال/دریافت همه کلاینت‌ها به صفر می‌رسد. سهمیه و تاریخ انقضا تغییری نمی‌کند. این عمل غیرقابل بازگشت است.",
"deleteConfirmTitle": "حذف کلاینت {email}؟",
"deleteConfirmContent": "این کلاینت از تمام اینباندهای متصل حذف و سابقه ترافیک آن پاک می‌شود. این عمل غیرقابل بازگشت است.",
"deleteSelected": "حذف ({count})",
"adjustSelected": "تنظیم ({count})",
"subLinksSelected": "لینک‌های اشتراک ({count})",
"addToGroupTitle": "افزودن {count} کاربر به یک گروه",
@@ -864,12 +772,10 @@
"bulkDisableConfirmTitle": "{count} کلاینت غیرفعال شوند؟",
"bulkDisableConfirmContent": "هر کلاینت انتخاب‌شده روی تمام اینباندهای متصل غیرفعال می‌شود. دسترسی آن‌ها بلافاصله قطع می‌شود اما رکورد و ترافیکشان حفظ می‌گردد.",
"selectedCount": "{count} انتخاب‌شده",
"attachSelected": "الصاق ({count})",
"attachToInboundsTitle": "الصاق {count} کاربر به ورودی‌(ها)",
"attachToInboundsDesc": "{count} کاربر انتخاب‌شده (همان UUID/رمز و ترافیک مشترک) را به ورودی‌های انتخابی الصاق می‌کند. الصاق‌های قبلی حفظ می‌شوند.",
"attachToInboundsTargets": "ورودی‌های مقصد",
"attachToInboundsNoTargets": "هیچ ورودی چندکاربره‌ای برای الصاق در دسترس نیست.",
"detachSelected": "جداسازی ({count})",
"detach": "جداسازی",
"detachFromInboundsTitle": "جداسازی {count} کاربر از ورودی‌(ها)",
"detachFromInboundsDesc": "{count} کاربر انتخاب‌شده را از ورودی‌های انتخابی حذف می‌کند. در مواردی که کاربر الصاق نبوده، نادیده گرفته می‌شود. رکورد کاربر حفظ می‌شود (برای حذف کامل از Delete استفاده کنید).",
@@ -931,8 +837,6 @@
"reverseTagPlaceholder": "Reverse tag اختیاری",
"telegramId": "شناسه کاربر تلگرام",
"telegramIdPlaceholder": "شناسه عددی کاربر تلگرام (۰ = هیچ)",
"created": "ساخته‌شده",
"updated": "به‌روزشده",
"ipLimit": "محدودیت IP",
"toasts": {
"deleted": "کلاینت حذف شد",
@@ -955,7 +859,6 @@
}
},
"groups": {
"title": "گروه‌ها",
"name": "نام",
"clientCount": "کاربران",
"totalGroups": "تعداد گروه‌ها",
@@ -997,7 +900,6 @@
"removeFromGroupResult": "{count} کاربر از {name} حذف شد."
},
"nodes": {
"title": "نودها",
"addNode": "افزودن نود",
"editNode": "ویرایش نود",
"totalNodes": "کل نودها",
@@ -1016,8 +918,6 @@
"apiTokenPlaceholder": "توکن از صفحه تنظیمات پنل ریموت",
"apiTokenHint": "پنل ریموت توکن API خودش را در بخش احرازهویت → توکن API نمایش می‌دهد.",
"apiTokenKeepHint": "برای حفظ توکن فعلی خالی بگذارید",
"regenerate": "تولید مجدد توکن",
"regenerateConfirm": "تولید مجدد، توکن فعلی را باطل می‌کند. هر پنل مرکزی‌ای که از این توکن استفاده می‌کند تا زمان به‌روزرسانی، دسترسی‌اش قطع می‌شود. ادامه می‌دهید؟",
"allowPrivateAddress": "اجازه آدرس خصوصی",
"allowPrivateAddressHint": "فقط برای نودهای روی شبکه خصوصی یا VPN فعال شود.",
"outboundTag": "خروجی اتصال",
@@ -1049,7 +949,6 @@
"updatePanel": "به‌روزرسانی پنل",
"updateSelected": "به‌روزرسانی انتخاب‌شده‌ها ({count})",
"updateAvailable": "به‌روزرسانی موجود",
"upToDate": "به‌روز",
"updateConfirmTitle": "{count} نود به آخرین نسخه به‌روزرسانی شوند؟",
"updateConfirmContent": "هر نود انتخاب‌شده آخرین نسخه را دانلود و روی آن ری‌استارت می‌شود. فقط نودهای فعال و آنلاین به‌روزرسانی می‌شوند.",
"updateDevChannel": "به‌روزرسانی به کانال دِو (آخرین کامیت)",
@@ -1113,6 +1012,7 @@
"pinFetchFailed": "دریافت گواهی ممکن نشد"
},
"settings": {
"defaultTag": "پیش‌فرض",
"title": "تنظیمات پنل",
"save": "ذخیره",
"infoDesc": "برای اعمال تغییرات در این بخش باید پس از ذخیره کردن، پنل را ریستارت کنید",
@@ -1150,6 +1050,8 @@
"panelOutboundPh": "اتصال مستقیم",
"remarkTemplate": "قالب ریمارک",
"remarkTemplateDesc": "اگر پر شود، جای مدلِ ریمارک را برای همه‌ی لینک‌های اشتراک می‌گیرد — فرمت دلخواهت را با توکن‌های متغیر بنویس (از دکمه برای درج استفاده کن). خالی = استفاده از مدلِ بالا.",
"subShowIdentityOnAllLinks": "نمایش هویت در همه لینک‌ها",
"subShowIdentityOnAllLinksDesc": "در صورت فعال بودن، {{EMAIL}} و {{USERNAME}} در یادداشت هر لینک بدنه اشتراک باقی می‌مانند. توکن‌های مصرف همچنان فقط در لینک اول نمایش داده می‌شوند.",
"datepicker": "نوع تقویم",
"datepickerPlaceholder": "انتخاب تاریخ",
"datepickerDescription": "وظایف برنامه ریزی شده بر اساس این تقویم اجرا می‌شود",
@@ -1448,13 +1350,8 @@
"secretClearUndo": "لغو پاک کردن"
},
"xray": {
"title": "پیکربندی ایکس‌ری",
"save": "ذخیره",
"restart": "راه‌اندازی مجدد Xray",
"restartSuccess": "Xray با موفقیت راه‌اندازی مجدد شد",
"restartOutputTitle": "خروجی راه‌اندازی مجدد Xray",
"restartConfirmTitle": "راه‌اندازی مجدد xray؟",
"restartConfirmContent": "سرویس xray با پیکربندی ذخیره‌شده دوباره بارگذاری می‌شود.",
"stopSuccess": "Xray با موفقیت متوقف شد",
"restartError": "خطا در راه‌اندازی مجدد Xray.",
"stopError": "خطا در توقف Xray.",
@@ -1464,7 +1361,6 @@
"generalConfigsDesc": "این گزینه‌ها استراتژی کلی ترافیک را تعیین می‌کنند",
"logConfigs": "لاگ",
"logConfigsDesc": "گزارش‌ها ممکن است بر کارایی سرور شما تأثیر بگذارد. توصیه می شود فقط در صورت نیاز آن را عاقلانه فعال کنید",
"blockConfigsDesc": "این گزینه‌ها ترافیک را بر اساس پروتکل‌های درخواستی خاص، و وب سایت‌ها مسدود می‌کند",
"basicRouting": "مسیریابی پایه",
"blockConnectionsConfigsDesc": "این گزینه‌ها ترافیک را بر اساس کشور درخواست‌شده خاص مسدود می‌کنند.",
"directConnectionsConfigsDesc": "یک اتصال مستقیم تضمین می‌کند که ترافیک خاص از طریق سرور دیگری مسیریابی نشود.",
@@ -1474,10 +1370,6 @@
"directdomains": "دامنه‌های مستقیم",
"ipv4Routing": "IPv4 مسیریابی",
"ipv4RoutingDesc": "این گزینه‌ها ترافیک را از طریق آی‌پی نسخه4 سرور، به مقصد هدایت می‌کند",
"warpRouting": "WARP مسیریابی",
"warpRoutingDesc": "این گزینه‌ها ترافیک‌ را از طریق وارپ کلادفلر به مقصد هدایت می‌کند",
"nordRouting": "مسیریابی NordVPN",
"nordRoutingDesc": "این گزینه‌ها ترافیک را بر اساس مقصد خاص از طریق NordVPN مسیریابی می‌کنند.",
"Template": "‌پیکربندی پیشرفته الگو ایکس‌ری",
"TemplateDesc": "فایل پیکربندی نهایی ایکس‌ری بر اساس این الگو ایجاد می‌شود",
"FreedomStrategy": "Freedom استراتژی پروتکل",
@@ -1491,10 +1383,7 @@
"outboundTestUrlDesc": "آدرسی که برای تست اتصال خروجی استفاده می‌شود.",
"Torrent": "مسدودسازی پروتکل بیت‌تورنت",
"Inbounds": "ورودی‌ها",
"InboundsDesc": "پذیرش کلاینت خاص",
"Outbounds": "خروجی‌ها",
"OutboundSubscriptions": "سابسکریپشن‌های خروجی",
"OutboundSubscriptionsDesc": "خروجی‌ها را از آدرس‌های سابسکریپشن راه‌دور (vmess/vless/trojan/ss/...) وارد کنید. تگ‌ها ثابت می‌مانند تا در بالانسرها و قوانین مسیریابی قابل استفاده باشند. به‌روزرسانی‌ها به‌صورت خودکار انجام می‌شوند.",
"Balancers": "بالانسرها",
"balancerTagRequired": "تگ الزامی است",
"balancerSelectorRequired": "حداقل یک خروجی انتخاب کنید",
@@ -1513,9 +1402,7 @@
"routeTesterMatchedOutbound": "خروجی منطبق",
"routeTesterViaBalancer": "از طریق بالانسر",
"routeTesterDefaultOutbound": "هیچ قانونی منطبق نشد — ترافیک به خروجی پیش‌فرض (اولین خروجی) می‌رود.",
"OutboundsDesc": "مسیر ترافیک خروجی را تنظیم کنید",
"Routings": "قوانین مسیریابی",
"RoutingsDesc": "اولویت هر قانون مهم است",
"importRules": "ورود قوانین",
"exportRules": "خروج قوانین",
"importOutbounds": "ورود خروجی‌ها",
@@ -1534,13 +1421,9 @@
"maskAddressDesc": "پوشش آدرس IP، هنگامی که فعال می‌شود، به طور خودکار آدرس IP که در لاگ ظاهر می‌شود را جایگزین می‌کند.",
"statistics": "آمار",
"statsInboundUplink": "آمار آپلود ورودی",
"statsInboundUplinkDesc": "جمع‌آوری آمار برای ترافیک بالارو (آپلود) تمام پروکسی‌های ورودی را فعال می‌کند.",
"statsInboundDownlink": "آمار دانلود ورودی",
"statsInboundDownlinkDesc": "جمع‌آوری آمار برای ترافیک پایین‌رو (دانلود) تمام پروکسی‌های ورودی را فعال می‌کند.",
"statsOutboundUplink": "آمار آپلود خروجی",
"statsOutboundUplinkDesc": "جمع‌آوری آمار برای ترافیک بالارو (آپلود) تمام پروکسی‌های خروجی را فعال می‌کند.",
"statsOutboundDownlink": "آمار دانلود خروجی",
"statsOutboundDownlinkDesc": "جمع‌آوری آمار برای ترافیک پایین‌رو (دانلود) تمام پروکسی‌های خروجی را فعال می‌کند.",
"metricsListen": "نقطه پایانی متریک",
"metricsListenDesc": "متریک‌های Xray (سبک Prometheus) را روی این آدرس:پورت در دسترس قرار می‌دهد (مثلاً 127.0.0.1:11111). برای غیرفعال‌کردن خالی بگذارید. روی localhost ببندید و با ریورس‌پروکسی ارائه دهید — احراز هویت ندارد.",
"metricsTag": "تگ متریک",
@@ -1553,18 +1436,10 @@
"bufferSizePlaceholder": "خودکار",
"seconds": "ثانیه",
"rules": {
"first": "اولین",
"last": "آخرین",
"up": "بالا",
"down": "پایین",
"source": "مبدا",
"dest": "مقصد",
"inbound": "ورودی",
"outbound": "خروجی",
"balancer": "بالانسر",
"info": "اطلاعات",
"add": "افزودن قانون",
"edit": "ویرایش قانون",
"useComma": "موارد جدا شده با کاما"
},
"routing": {
@@ -1605,7 +1480,6 @@
"ruleN": "قانون {n}",
"action": "عمل",
"redirect": "بازهدایت",
"fragment": "Fragment",
"finalRules": "قوانین نهایی",
"overrideXrayPrivateIp": "override بلاک پیش‌فرض IP خصوصی Xray",
"blockDelay": "تأخیر بلاک (ms)",
@@ -1631,43 +1505,17 @@
"keepAliveInterval": "بازه Keep alive",
"markFwmark": "علامت (fwmark)",
"interface": "رابط",
"ipv6Only": "فقط IPv6",
"acceptProxyProtocol": "پذیرش Proxy Protocol",
"proxyProtocol": "Proxy Protocol",
"tcpUserTimeoutMs": "TCP user timeout (ms)",
"tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
},
"outbound": {
"addOutbound": "افزودن خروجی",
"addReverse": "افزودن معکوس",
"editOutbound": "ویرایش خروجی",
"editReverse": "ویرایش معکوس",
"reverseTag": "تگ معکوس",
"reverseTagDesc": "تگ خروجی پروکسی معکوس ساده VLESS. برای غیرفعال کردن خالی بگذارید. در صورت تنظیم، اتصالات این کلاینت می‌توانند به عنوان تونل پروکسی معکوس استفاده شوند.",
"reverseTagPlaceholder": "تگ خروجی (خالی = غیرفعال)",
"tag": "تگ",
"tagDesc": "برچسب یگانه",
"address": "آدرس",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"reverse": "معکوس",
"domain": "دامنه",
"type": "نوع",
"bridge": "پل",
"portal": "پورتال",
"link": "لینک",
"intercon": "اتصال میانی",
"settings": "تنظیمات",
"accountInfo": "اطلاعات حساب",
"outboundStatus": "وضعیت خروجی",
"sendThrough": "ارسال با",
"targetStrategy": "استراتژی مقصد",
"test": "تست",
"testResult": "نتیجه تست",
"testing": "در حال تست اتصال...",
"testSuccess": "تست موفقیت‌آمیز",
"testFailed": "تست ناموفق",
"testError": "خطا در تست خروجی",
"modeRealDelay": "تأخیر واقعی",
"testModeTooltip": "TCP: فقط dial سریع. HTTP: درخواست کامل از طریق xray. تأخیر واقعی: کل زمان همراه با برقراری اتصال.",
"testAll": "تست همه",
@@ -1675,14 +1523,10 @@
"breakdownConnect": "اتصال پروکسی",
"breakdownTls": "TLS از طریق خروجی",
"breakdownTtfb": "اولین بایت",
"nordvpn": "NordVPN",
"accessToken": "توکن دسترسی",
"country": "کشور",
"server": "سرور",
"city": "شهر",
"allCities": "همه شهرها",
"privateKey": "کلید خصوصی",
"load": "فشار سرور",
"moveToTop": "انتقال به بالا"
},
"outboundSub": {
@@ -1712,16 +1556,11 @@
"active": "سابسکریپشن‌های فعال",
"empty": "هنوز سابسکریپشنی وجود ندارد. از بالا یکی اضافه کنید.",
"colRemark": "نام",
"colPrefix": "پیشوند",
"colInterval": "بازه",
"colLastFetch": "آخرین دریافت",
"colEnabled": "فعال",
"auto": "خودکار",
"never": "هرگز",
"yes": "بله",
"no": "خیر",
"refreshNow": "تازه‌سازی اکنون",
"lastError": "آخرین خطا",
"deleteConfirm": "این سابسکریپشن حذف شود؟",
"restartHint": "پس از افزودن یا تازه‌سازی، برای فعال‌شدن خروجی‌ها Xray را راه‌اندازی مجدد کنید (یا منتظر بارگذاری مجدد خودکار بعدی بمانید).",
"fromSubsTitle": "از سابسکریپشن‌های خروجی (فقط‌خواندنی)",
@@ -1738,8 +1577,6 @@
"tabBalancerSettings": "تنظیمات بالانسر",
"tabObservatory": "رصدخانه",
"observatory": {
"title": "رصدخانه",
"burstTitle": "رصدخانه Burst",
"autoManaged": "رصدگرها به‌صورت خودکار از روی بالانسرهای شما مدیریت می‌شوند. در ادامه می‌توانید نحوهٔ پروب‌زدن را تنظیم کنید؛ خروجی‌های تحت نظر از سلکتورهای بالانسر پیروی می‌کنند.",
"emptyHint": "هیچ رصدگر اتصالی فعال نیست. وقتی یک بالانسر Least Ping یا Least Load بسازید — یا یک بالانسر Random / Round-robin همراه با fallback — به‌صورت خودکار یکی اضافه می‌شود تا بالانسرهای متکی به رصدگر بتوانند پیش از انتخاب مقصد، سلامت خروجی‌ها را بررسی کنند.",
"mixedLegacy": "این پیکربندی هم Observatory و هم Burst Observatory دارد. Xray فقط از یک رصدگر سراسری استفاده می‌کند، بنابراین این حالت قدیمیِ ترکیبی پشتیبانی نمی‌شود؛ ذخیرهٔ بالانسرها آن را به یک رصدگر عادی‌سازی می‌کند.",
@@ -1773,12 +1610,8 @@
"balancerRemoved": "بالانسر {tag} — حذف شد (هدفی باقی نماند)"
},
"balancer": {
"addBalancer": "افزودن بالانسر",
"editBalancer": "ویرایش بالانسر",
"balancerStrategy": "استراتژی",
"balancerSelectors": "انتخاب‌گرها",
"tag": "تگ",
"tagDesc": "برچسب یگانه",
"tagDuplicate": "این تگ توسط بالانسر دیگری استفاده شده است",
"tagPlaceholder": "تگ منحصربه‌فرد بالانسر",
"selector": "انتخابگر",
@@ -1790,7 +1623,6 @@
"tolerance": "تحمل",
"baselines": "خطوط پایه",
"costs": "هزینه‌ها",
"balancerDesc": "امکان استفاده همزمان balancerTag و outboundTag باهم وجود ندارد. درصورت استفاده همزمان فقط outboundTag عمل خواهد کرد.",
"costMatch": "الگوی برچسب",
"costValue": "وزن",
"costRegexp": "تطبیق با عبارت باقاعده",
@@ -1805,7 +1637,6 @@
"publicKey": "کلید عمومی",
"allowedIPs": "آی‌پی‌های مجاز",
"endpoint": "نقطه پایانی",
"psk": "کلید مشترک",
"domainStrategy": "استراتژی حل دامنه"
},
"amneziawg": {
@@ -1843,10 +1674,7 @@
"i1Hint": "فقط برای AmneziaWG 2.0. برای سازگاری با نسخه ۱.x خالی بگذارید."
},
"tun": {
"nameDesc": "نام رابط TUN. مقدار پیش‌فرض 'xray0' است",
"mtuDesc": "واحد انتقال حداکثر. بیشترین اندازه بسته‌های داده. مقدار پیش‌فرض 1500 است",
"userLevel": "سطح کاربر",
"userLevelDesc": "تمام اتصالات انجام‌شده از طریق این ورودی از این سطح کاربری استفاده خواهند کرد. مقدار پیش‌فرض 0 است"
"userLevel": "سطح کاربر"
},
"nord": {
"accessToken": "توکن دسترسی",
@@ -1931,7 +1759,6 @@
},
"fakedns": {
"add": "افزودن دی‌ان‌اس جعلی",
"edit": "ویرایش دی‌ان‌اس جعلی",
"ipPool": "زیرشبکه استخر آی‌پی",
"poolSize": "اندازه استخر"
},
@@ -2067,7 +1894,6 @@
"add": "افزودن",
"month": "ماه",
"months": "ماه",
"day": "روز",
"days": "روز",
"hours": "ساعت",
"minutes": "دقیقه",
@@ -2106,7 +1932,6 @@
"userSaved": "✅ کاربر تلگرام ذخیره شد.",
"loginSuccess": "✅ با موفقیت به پنل وارد شدید.\r\n",
"loginFailed": "❗️ ورود به پنل ناموفق‌بود \r\n",
"2faFailed": "خطای 2FA",
"report": "🕰 گزارشات‌زمان‌بندی‌شده: {{ .RunTime }}\r\n",
"datetime": "⏰ تاریخ‌وزمان: {{ .DateTime }}\r\n",
"hostname": "💻 میزبان: {{ .Hostname }}\r\n",
@@ -2139,7 +1964,6 @@
"download": "🔽 دانلود: ↓{{ .Download }}\r\n",
"total": "📊 کل: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
"TGUser": "👤 کاربر تلگرام: {{ .TelegramID }}\r\n",
"exhaustedMsg": "🚨 {{ .Type }} به‌اتمام‌رسیده‌است:\r\n",
"exhaustedCount": "🚨 تعداد {{ .Type }} به‌اتمام‌رسیده‌است:\r\n",
"onlinesCount": "🌐 کاربران‌آنلاین: {{ .Count }}\r\n",
"disabled": "🛑 غیرفعال: {{ .Disabled }}\r\n",
@@ -2148,16 +1972,10 @@
"refreshedOn": "\r\n📋🔄 تازه‌سازی شده در: {{ .Time }}\r\n\r\n",
"yes": "✅ بله",
"no": "❌ خیر",
"received_id": "🔑📥 شناسه به‌روزرسانی شد.",
"received_password": "🔑📥 رمز عبور به‌روزرسانی شد.",
"received_email": "📧📥 ایمیل به‌روزرسانی شد.",
"received_comment": "💬📥 نظر به‌روزرسانی شد.",
"id_prompt": "🔑 شناسه پیش‌فرض: {{ .ClientId }}\n\nشناسه خود را وارد کنید.",
"pass_prompt": "🔑 رمز عبور پیش‌فرض: {{ .ClientPassword }}\n\nرمز عبور خود را وارد کنید.",
"email_prompt": "📧 ایمیل پیش‌فرض: {{ .ClientEmail }}\n\nایمیل خود را وارد کنید.",
"comment_prompt": "💬 نظر پیش‌فرض: {{ .ClientComment }}\n\nنظر خود را وارد کنید.",
"inbound_client_data_id": "🔄 ورودی: {{ .InboundRemark }}\n\n🔑 شناسه: {{ .ClientId }}\n📧 ایمیل: {{ .ClientEmail }}\n📊 ترافیک: {{ .ClientTraffic }}\n📅 تاریخ انقضا: {{ .ClientExp }}\n🌐 محدودیت IP: {{ .IpLimit }}\n💬 توضیح: {{ .ClientComment }}\n\nاکنون می‌تونی مشتری را به ورودی اضافه کنی!",
"inbound_client_data_pass": "🔄 ورودی: {{ .InboundRemark }}\n\n🔑 رمز عبور: {{ .ClientPass }}\n📧 ایمیل: {{ .ClientEmail }}\n📊 ترافیک: {{ .ClientTraffic }}\n📅 تاریخ انقضا: {{ .ClientExp }}\n🌐 محدودیت IP: {{ .IpLimit }}\n💬 توضیح: {{ .ClientComment }}\n\nاکنون می‌تونی مشتری را به ورودی اضافه کنی!",
"cancel": "❌ فرآیند لغو شد! \n\nمی‌توانید هر زمان که خواستید /start را دوباره اجرا کنید. 🔄",
"error_add_client": "⚠️ خطا:\n\n {{ .error }}",
"using_default_value": "باشه، از مقدار پیش‌فرض استفاده می‌کنم. 😊",
@@ -2174,8 +1992,6 @@
"eventXrayCrashError": "خطا: {{ .Error }}",
"eventNodeDown": "نود {{ .Name }} قطع است",
"eventNodeUp": "نود {{ .Name }} وصل است",
"eventCPUHigh": "بالا بودن CPU",
"eventCPUHighDetail": "CPU: {{ .Detail }}",
"eventLoginFallback": "ورود ناموفق از {{ .Source }}",
"memoryThreshold": "مصرف حافظه {{ .Percent }}% از حد آستانه {{ .Threshold }}% فراتر رفته است"
},
@@ -2216,11 +2032,8 @@
"submitDisable": "ارسال به عنوان غیرفعال ☑️",
"submitEnable": "ارسال به عنوان فعال ✅",
"use_default": "🏷️ استفاده از پیش‌فرض",
"change_id": "⚙️🔑 ID",
"change_password": "⚙️🔑 گذرواژه",
"change_email": "⚙️📧 ایمیل",
"change_comment": "⚙️💬 نظر",
"change_flow": "⚙️🚦 Flow",
"ResetAllTraffics": "بازنشانی همه ترافیک‌ها",
"SortedTrafficUsageReport": "گزارش استفاده از ترافیک مرتب‌شده"
},
@@ -2249,31 +2062,16 @@
}
},
"email": {
"subjectOutboundDown": "خروجی {{ .Tag }} قطع است",
"subjectOutboundUp": "خروجی {{ .Tag }} وصل است",
"subjectXrayCrash": "Xray کرش کرد",
"subjectCPUHigh": "بالا بودن CPU",
"subjectLoginSuccess": "ورود موفق",
"subjectLoginFailed": "ورود ناموفق",
"titleOutboundDown": "خروجی قطع شد",
"titleOutboundUp": "خروجی وصل شد",
"titleXrayCrash": "Xray کرش کرد",
"titleCPUHigh": "بالا بودن CPU",
"titleLoginSuccess": "ورود موفق",
"titleLoginFailed": "ورود ناموفق",
"labelStatus": "وضعیت",
"labelOutbound": "خروجی",
"labelNode": "نود",
"labelError": "خطا",
"labelDelay": "تأخیر",
"labelDetail": "جزئیات",
"labelUsername": "نام‌کاربری",
"labelIP": "IP",
"labelReason": "دلیل",
"labelSource": "مبدأ",
"labelTime": "زمان",
"statusCrashed": "کرش کرد",
"statusRunning": "در حال اجرا",
"statusHigh": "بالا",
"statusSuccess": "موفق",
"statusFailed": "ناموفق",
+25 -227
View File
@@ -48,7 +48,6 @@
"copySuccess": "Berhasil Disalin",
"sure": "Yakin",
"encryption": "Enkripsi",
"useIPv4ForHost": "Gunakan IPv4 untuk host",
"transmission": "Transmisi",
"host": "Host",
"path": "Path",
@@ -74,18 +73,9 @@
"twoFactorCode": "Kode",
"remained": "Tersisa",
"security": "Keamanan",
"secAlertTitle": "Peringatan keamanan",
"secAlertSsl": "Koneksi ini tidak aman. Harap hindari memasukkan informasi sensitif sampai TLS diaktifkan untuk perlindungan data.",
"secAlertConf": "Beberapa pengaturan rentan terhadap serangan. Disarankan untuk memperkuat protokol keamanan guna mencegah pelanggaran potensial.",
"secAlertSSL": "Panel kekurangan koneksi yang aman. Harap instal sertifikat TLS untuk perlindungan data.",
"secAlertPanelPort": "Port default panel rentan. Harap konfigurasi port acak atau tertentu.",
"secAlertPanelURI": "Jalur URI default panel tidak aman. Harap konfigurasi jalur URI kompleks.",
"secAlertSubURI": "Jalur URI default langganan tidak aman. Harap konfigurasi jalur URI kompleks.",
"secAlertSubJsonURI": "Jalur URI default JSON langganan tidak aman. Harap konfigurasikan jalur URI kompleks.",
"emptyDnsDesc": "Tidak ada server DNS yang ditambahkan.",
"emptyFakeDnsDesc": "Tidak ada server Fake DNS yang ditambahkan.",
"emptyBalancersDesc": "Tidak ada penyeimbang yang ditambahkan.",
"emptyReverseDesc": "Tidak ada proxy terbalik yang ditambahkan.",
"somethingWentWrong": "Terjadi kesalahan",
"subscription": {
"title": "Info langganan",
@@ -106,8 +96,6 @@
},
"menu": {
"theme": "Tema",
"dark": "Gelap",
"ultraDark": "Sangat Gelap",
"dashboard": "Ikhtisar",
"inbounds": "Inbound",
"clients": "Klien",
@@ -118,12 +106,12 @@
"routing": "Pengalihan",
"outbounds": "Outbound",
"apiDocs": "Dokumentasi API",
"logout": "Keluar",
"link": "Kelola",
"donate": "Donasi",
"hosts": "Host",
"docs": "Dokumentasi",
"openMenu": "Buka menu"
"openMenu": "Buka menu",
"pinSidebar": "Sematkan bilah sisi",
"unpinSidebar": "Lepas sematan bilah sisi"
},
"pages": {
"login": {
@@ -139,20 +127,15 @@
}
},
"index": {
"title": "Ikhtisar",
"cpu": "CPU",
"logicalProcessors": "Prosesor logis",
"frequency": "Frekuensi",
"swap": "Swap",
"storage": "Penyimpanan",
"memory": "Memori",
"threads": "Thread",
"xrayStatus": "Xray",
"stopXray": "Hentikan",
"restartXray": "Mulai ulang",
"xraySwitch": "Versi",
"xrayUpdates": "Pembaruan Xray",
"xraySwitchClick": "Pilih versi yang ingin Anda pindah.",
"xraySwitchClickDesk": "Pilih dengan hati-hati, karena versi yang lebih lama mungkin tidak kompatibel dengan konfigurasi saat ini.",
"updatePanel": "Perbarui Panel",
"panelUpdateDesc": "Ini akan memperbarui 3X-UI ke rilis terbaru dan me-restart layanan panel.",
@@ -164,13 +147,10 @@
"currentCommit": "Commit saat ini",
"latestCommit": "Commit terbaru",
"updateChannelChanged": "Kanal pembaruan diubah",
"upToDate": "Terbaru",
"xrayStatusUnknown": "Tidak diketahui",
"xrayStatusRunning": "Berjalan",
"xrayStatusStop": "Berhenti",
"xrayStatusError": "Error",
"xrayErrorPopoverTitle": "Terjadi kesalahan saat menjalankan Xray",
"operationHours": "Waktu Aktif",
"systemHistoryTitle": "Riwayat Sistem",
"historyTitleCpu": "Penggunaan CPU",
"historyTitleMem": "Penggunaan Memori",
@@ -188,7 +168,6 @@
"historyTabLoad": "Beban",
"historyTabConnections": "Koneksi",
"historyTabDiskUsage": "Penggunaan Disk",
"charts": "Grafik",
"xrayMetricsTitle": "Metrik Xray",
"xrayTitleHeap": "Memori Heap Teralokasi",
"xrayTitleSys": "Memori Dicadangkan dari OS",
@@ -211,25 +190,19 @@
"xrayObservatoryDead": "Mati",
"xrayObservatoryLastSeen": "Terakhir terlihat",
"xrayObservatoryLastTry": "Percobaan terakhir",
"trendLast2Min": "2 menit terakhir",
"systemLoad": "Beban Sistem",
"systemLoadDesc": "Rata-rata beban sistem selama 1, 5, dan 15 menit terakhir",
"connectionCount": "Statistik Koneksi",
"ipAddresses": "Alamat IP",
"toggleIpVisibility": "Alihkan visibilitas IP",
"overallSpeed": "Kecepatan keseluruhan",
"upload": "Unggah",
"download": "Unduh",
"totalData": "Total data",
"sent": "Dikirim",
"received": "Diterima",
"documentation": "Dokumentasi",
"xraySwitchVersionDialog": "Apakah Anda yakin ingin mengubah versi Xray?",
"xraySwitchVersionDialogDesc": "Ini akan mengubah versi Xray ke #version#.",
"xraySwitchVersionPopover": "Xray berhasil diperbarui",
"panelUpdateDialog": "Apakah Anda benar-benar ingin memperbarui panel?",
"panelUpdateDialogDesc": "Ini akan memperbarui 3X-UI ke #version# dan me-restart layanan panel.",
"panelUpdateCheckPopover": "Pemeriksaan pembaruan panel gagal",
"panelUpdateStartedPopover": "Pembaruan panel dimulai",
"panelUpdateFailedTitle": "Pembaruan panel gagal",
"panelUpdateFailedDesc": "Pembaruan tidak selesai dengan sukses. Periksa log server, atau jalankan 'x-ui update' dari baris perintah.",
@@ -258,7 +231,6 @@
"accessLogs": "Log Akses",
"autoUpdate": "Pembaruan Otomatis",
"config": "Konfigurasi",
"backup": "Cadangan",
"backupTitle": "Cadangan & Pulihkan",
"exportDatabase": "Cadangkan",
"exportDatabaseDesc": "Klik untuk mengunduh file .db yang berisi cadangan dari database Anda saat ini ke perangkat Anda. Berkas yang sama juga dapat dipulihkan ke panel yang berjalan di PostgreSQL.",
@@ -273,10 +245,20 @@
"exportDatabasePgDesc": "Klik untuk mengunduh dump PostgreSQL (.dump) dari basis data Anda saat ini ke perangkat Anda.",
"importDatabasePgDesc": "Klik untuk memilih dan mengunggah cadangan PostgreSQL (.dump), basis data SQLite (.db), atau dump migrasi SQLite guna memulihkan basis data Anda. Ini menggantikan semua data saat ini.",
"migrationDownload": "Unduh migrasi",
"migrationDownloadPgDesc": "Klik untuk mengunduh basis data SQLite .db yang dibuat dari data PostgreSQL Anda, siap menjalankan panel ini di SQLite."
"migrationDownloadPgDesc": "Klik untuk mengunduh basis data SQLite .db yang dibuat dari data PostgreSQL Anda, siap menjalankan panel ini di SQLite.",
"avg": "rerata",
"peak": "puncak",
"free": "tersisa",
"openSockets": "soket terbuka",
"throughputSub": "Total antarmuka",
"avgWindow": "Rerata periode",
"healthWarm": "{list} — mulai tinggi",
"healthCritical": "{list} — kritis",
"panel": "Panel",
"threads": "Thread",
"uptime": "Waktu aktif"
},
"inbounds": {
"title": "Inbound",
"totalDownUp": "Total Terkirim/Diterima",
"totalUsage": "Penggunaan Total",
"inboundCount": "Total Masuk",
@@ -288,21 +270,11 @@
"localPanel": "Panel lokal",
"fallbacks": {
"title": "Fallback",
"help": "Saat koneksi pada inbound ini tidak cocok dengan client mana pun, arahkan ke tempat lain. Pilih inbound child di bawah untuk mengisi otomatis field routing (SNI / ALPN / Path / xver) dari transport-nya, atau biarkan pemilih kosong dan atur Dest langsung (mis. 8080 atau 127.0.0.1:8080) untuk mengarahkan ke server eksternal seperti Nginx. Setiap inbound child harus listen di 127.0.0.1 dengan security=none.",
"empty": "Belum ada fallback",
"add": "Tambah fallback",
"pickInbound": "Pilih inbound",
"matchAny": "apa pun",
"destPlaceholder": "otomatis (listen:port child)",
"rederive": "Isi ulang dari child",
"rederived": "Diisi ulang dari child",
"editAdvanced": "Edit field routing",
"hideAdvanced": "Sembunyikan lanjutan",
"quickAddAll": "Tambah cepat semua yang memenuhi syarat",
"quickAdded": "Menambahkan {n} fallback",
"quickAddedNone": "Tidak ada inbound baru yang memenuhi syarat",
"routesWhen": "Diarahkan ketika",
"defaultCatchAll": "Default — menangkap apa pun lainnya",
"needsTls": "Fallback tersedia setelah memilih TLS atau Reality di tab Keamanan (hanya VLESS/Trojan melalui RAW)."
},
"protocol": "Protokol",
@@ -310,8 +282,6 @@
"portMap": "Pemetaan port",
"traffic": "Trafik",
"speed": "Kecepatan",
"details": "Rincian",
"transportConfig": "Transport",
"expireDate": "Durasi",
"createdAt": "Dibuat",
"updatedAt": "Diperbarui",
@@ -319,8 +289,6 @@
"addInbound": "Tambahkan Masuk",
"generalActions": "Tindakan Umum",
"modifyInbound": "Ubah Masuk",
"deleteInbound": "Hapus Masuk",
"deleteInboundContent": "Apakah Anda yakin ingin menghapus masuk?",
"deleteConfirmTitle": "Hapus inbound \"{remark}\"?",
"deleteConfirmContent": "Tindakan ini menghapus inbound beserta semua kliennya. Tidak dapat dibatalkan.",
"resetConfirmTitle": "Reset trafik \"{remark}\"?",
@@ -364,8 +332,6 @@
"exportAllLinksFileName": "Semua-Inbound",
"exportAllSubsFileName": "Semua-Inbound-Subs",
"inboundJsonTitle": "JSON inbound",
"deleteClient": "Hapus Klien",
"deleteClientContent": "Apakah Anda yakin ingin menghapus klien?",
"resetTrafficContent": "Apakah Anda yakin ingin mereset traffic?",
"copyLink": "Salin URL",
"address": "Alamat",
@@ -376,36 +342,19 @@
"meansNoLimit": "= Tanpa batas. (satuan: GB)",
"totalFlow": "Total Aliran",
"leaveBlankToNeverExpire": "Biarkan kosong untuk tidak pernah kedaluwarsa",
"noRecommendKeepDefault": "Disarankan untuk tetap menggunakan pengaturan default",
"certificatePath": "Path Berkas",
"certificateContent": "Konten Berkas",
"publicKey": "Kunci Publik",
"privatekey": "Kunci Pribadi",
"clickOnQRcode": "Klik pada Kode QR untuk Menyalin",
"client": "Klien",
"export": "Ekspor Semua URL",
"clone": "Duplikat",
"cloneInbound": "Duplikat",
"cloneInboundContent": "Semua pengaturan masuk ini, kecuali Port, Listening IP, dan Klien, akan diterapkan pada duplikat.",
"cloneInboundOk": "Duplikat",
"resetAllTraffic": "Reset Semua Traffic Masuk",
"resetAllTrafficTitle": "Reset Semua Traffic Masuk",
"resetAllTrafficContent": "Apakah Anda yakin ingin mereset traffic semua masuk?",
"resetInboundClientTraffics": "Reset Traffic Klien Masuk",
"resetInboundClientTrafficTitle": "Reset Traffic Klien Masuk",
"resetInboundClientTrafficContent": "Apakah Anda yakin ingin mereset traffic klien masuk ini?",
"resetAllClientTraffics": "Reset Traffic Semua Klien",
"resetAllClientTrafficTitle": "Reset Traffic Semua Klien",
"resetAllClientTrafficContent": "Apakah Anda yakin ingin mereset traffic semua klien?",
"delDepletedClients": "Hapus Klien Habis",
"delDepletedClientsTitle": "Hapus Klien Habis",
"delDepletedClientsContent": "Apakah Anda yakin ingin menghapus semua klien yang habis?",
"email": "Email",
"emailDesc": "Harap berikan alamat email yang unik.",
"IPLimit": "Batas IP",
"IPLimitDesc": "Menonaktifkan masuk jika jumlah melebihi nilai yang ditetapkan. (0 = nonaktif)",
"IPLimitlog": "Log IP",
"IPLimitlogDesc": "Log histori IP. (untuk mengaktifkan masuk setelah menonaktifkan, hapus log)",
"IPLimitlogclear": "Hapus Log",
"setDefaultCert": "Atur Sertifikat dari Panel",
"setDefaultCertEmpty": "Tidak ada sertifikat yang dikonfigurasi untuk panel. Atur dulu di Pengaturan.",
@@ -438,20 +387,15 @@
"sniffing": "Sniffing",
"sniffingHelp": "Pembungkus blok sniffing Xray:",
"stream": "Stream",
"streamHelp": "Pembungkus blok stream Xray:",
"jsonErrorPrefix": "JSON lanjutan"
"streamHelp": "Pembungkus blok stream Xray:"
},
"telegramDesc": "Harap berikan ID Obrolan Telegram. (gunakan perintah '/id' di bot) atau ({'@'}userinfobot)",
"subscriptionDesc": "Untuk menemukan URL langganan Anda, buka 'Rincian'. Selain itu, Anda dapat menggunakan nama yang sama untuk beberapa klien.",
"subSortIndex": "Urutan sub",
"same": "Sama",
"inboundInfo": "Informasi Inbound",
"exportInbound": "Ekspor Masuk",
"import": "Impor",
"importInbound": "Impor Masuk",
"periodicTrafficResetTitle": "Reset Trafik Berkala",
"periodicTrafficResetDesc": "Reset otomatis penghitung trafik pada interval tertentu",
"lastReset": "Reset Terakhir",
"periodicTrafficResetDay": "Hari reset bulanan",
"periodicTrafficReset": {
"never": "Tidak Pernah",
"daily": "Harian",
@@ -463,7 +407,6 @@
"obtain": "Dapatkan",
"updateSuccess": "Pembaruan berhasil",
"logCleanSuccess": "Log telah dibersihkan",
"inboundsUpdateSuccess": "Inbound berhasil diperbarui",
"inboundUpdateSuccess": "Inbound berhasil diperbarui",
"inboundCreateSuccess": "Inbound berhasil dibuat",
"bulkDeleted": "{count} inbound dihapus",
@@ -473,7 +416,6 @@
"inboundClientDeleteSuccess": "Klien inbound telah dihapus",
"inboundClientUpdateSuccess": "Klien inbound telah diperbarui",
"savedNodeOfflineWillSync": "Disimpan secara lokal. Node pendukung sedang offline atau dinonaktifkan — perubahan akan disinkronkan setelah terhubung kembali.",
"delDepletedClientsSuccess": "Semua klien yang habis telah dihapus",
"resetAllClientTrafficSuccess": "Semua lalu lintas klien telah direset",
"resetAllTrafficSuccess": "Semua lalu lintas telah direset",
"resetInboundClientTrafficSuccess": "Lalu lintas telah direset",
@@ -636,6 +578,10 @@
"maxTimeDiff": "Maks. selisih waktu (ms)",
"minClientVer": "Min. versi klien",
"maxClientVer": "Maks. versi klien",
"minClientVerHint": "Kosong bukan berarti tanpa batas: Xray-core akan memakai minimum bawaan dari build core yang dijalankan (26.3.27 pada rilis saat ini) dan menolak klien yang melaporkan versi lebih lama — termasuk core pihak ketiga seperti Mihomo dan sing-box. Isi 1.0.0 untuk menerimanya, dengan risiko mengizinkan sidik jari TLS yang usang.",
"maxClientVerHint": "Kosong berarti tanpa batas atas. Jika diisi, tidak boleh lebih rendah dari minimum efektif — versi klien minimum, atau minimum bawaan Xray-core saat kolom itu kosong — atau semua klien akan ditolak.",
"clientVerInvalid": "Versi klien harus berupa maksimal tiga angka dipisah titik, masing-masing 0-255 (mis. 26.3.27)",
"maxClientVerBelowMin": "Versi klien maksimum tidak boleh lebih rendah dari versi klien minimum",
"shortIds": "Short IDs",
"realityTargetHint": "Wajib. Harus menyertakan port (mis. example.com:443). Tanpa port, Xray-core menolak untuk mulai.",
"realityTargetRequired": "Target REALITY wajib diisi",
@@ -712,23 +658,6 @@
"peerNumber": "Peer {n}",
"peerNumberConfig": "Konfig Peer {n}"
},
"stream": {
"general": {
"request": "Permintaan",
"response": "Respons",
"name": "Nama",
"value": "Nilai"
},
"tcp": {
"version": "Versi",
"method": "Metode",
"path": "Path",
"status": "Status",
"statusDescription": "Deskripsi Status",
"requestHeader": "Header Permintaan",
"responseHeader": "Header Respons"
}
},
"sniffingDestOverride": "Penggantian tujuan"
},
"clients": {
@@ -742,24 +671,9 @@
"addExternalSubscription": "Tambah Langganan Eksternal",
"noExternalLinks": "Belum ada tautan eksternal.",
"noExternalSubscriptions": "Belum ada langganan eksternal.",
"add": "Tambah klien",
"edit": "Ubah klien",
"submitAdd": "Tambah klien",
"submitEdit": "Simpan perubahan",
"clientCount": "Jumlah klien",
"bulk": "Tambah massal",
"copyFromInbound": "Salin klien dari inbound",
"copyToInbound": "Salin klien ke",
"copySelected": "Salin terpilih",
"copySource": "Sumber",
"copyEmailPreview": "Pratinjau email hasil",
"copySelectSourceFirst": "Pilih inbound sumber terlebih dahulu.",
"copyResult": "Hasil salinan",
"copyResultSuccess": "Berhasil disalin",
"copyResultNone": "Tidak ada yang disalin: tidak ada klien terpilih atau sumber kosong",
"copyResultErrors": "Kesalahan salin",
"copyFlowLabel": "Flow untuk klien baru (VLESS)",
"copyFlowHint": "Diterapkan ke semua klien yang disalin. Kosongkan untuk dilewati.",
"selectAll": "Pilih semua",
"clearAll": "Hapus semua",
"method": "Metode",
@@ -770,7 +684,6 @@
"postfix": "Akhiran",
"delayedStart": "Mulai setelah penggunaan pertama",
"expireDays": "Durasi (hari)",
"days": "Hari",
"renew": "Perpanjangan otomatis",
"renewDesc": "Perpanjangan otomatis setelah kedaluwarsa. (0 = nonaktif) (satuan: hari)",
"renewDays": "Perpanjangan otomatis (hari)",
@@ -793,7 +706,6 @@
"sortExpiringSoonest": "Segera kedaluwarsa",
"has": "Memiliki",
"hasNot": "Tidak memiliki",
"title": "Klien",
"actions": "Aksi",
"totalGB": "Batas Trafik (GB)",
"totalGBDesc": "Kuota data untuk klien ini. 0 = tidak terbatas.",
@@ -821,8 +733,6 @@
"addClient": "Tambah klien",
"qrCode": "Kode QR",
"clientInfo": "Informasi Klien",
"delete": "Hapus",
"reset": "Reset lalu lintas",
"editClient": "Ubah klien",
"client": "Klien",
"enabled": "Aktif",
@@ -836,13 +746,11 @@
"noLinks": "Tidak ada tautan yang bisa dibagikan — lampirkan klien ini ke inbound yang mendukung protokol terlebih dahulu.",
"link": "Tautan",
"resetNotPossible": "Lampirkan klien ini ke inbound terlebih dahulu.",
"general": "Umum",
"resetAllTraffics": "Reset lalu lintas semua klien",
"resetAllTrafficsTitle": "Reset lalu lintas semua klien?",
"resetAllTrafficsContent": "Penghitung kirim/terima setiap klien turun ke nol. Kuota dan kedaluwarsa tidak terpengaruh. Tidak dapat dibatalkan.",
"deleteConfirmTitle": "Hapus klien {email}?",
"deleteConfirmContent": "Tindakan ini menghapus klien dari setiap inbound terlampir dan menghapus catatan lalu lintasnya. Tidak dapat dibatalkan.",
"deleteSelected": "Hapus ({count})",
"adjustSelected": "Sesuaikan ({count})",
"subLinksSelected": "Tautan sub ({count})",
"addToGroupTitle": "Tambahkan {count} klien ke grup",
@@ -864,12 +772,10 @@
"bulkDisableConfirmTitle": "Nonaktifkan {count} klien?",
"bulkDisableConfirmContent": "Menonaktifkan setiap klien yang dipilih di semua inbound yang terlampir. Mereka langsung kehilangan akses, tetapi catatan dan trafiknya tetap disimpan.",
"selectedCount": "{count} dipilih",
"attachSelected": "Lampirkan ({count})",
"attachToInboundsTitle": "Lampirkan {count} klien ke inbound",
"attachToInboundsDesc": "Melampirkan {count} klien terpilih (UUID/kata sandi sama dan trafik bersama) ke inbound terpilih. Lampiran yang ada tetap dipertahankan.",
"attachToInboundsTargets": "Inbound tujuan",
"attachToInboundsNoTargets": "Tidak ada inbound multi-pengguna untuk dilampirkan.",
"detachSelected": "Lepas ({count})",
"detach": "Lepas",
"detachFromInboundsTitle": "Lepas {count} klien dari inbound",
"detachFromInboundsDesc": "Menghapus {count} klien terpilih dari inbound terpilih. Pasangan di mana klien tidak terlampir akan dilewati secara diam-diam. Catatan klien dipertahankan (gunakan Delete untuk menghapus sepenuhnya).",
@@ -931,8 +837,6 @@
"reverseTagPlaceholder": "Reverse tag opsional",
"telegramId": "ID pengguna Telegram",
"telegramIdPlaceholder": "ID numerik pengguna Telegram (0 = tidak ada)",
"created": "Dibuat",
"updated": "Diperbarui",
"ipLimit": "Batas IP",
"toasts": {
"deleted": "Klien dihapus",
@@ -955,7 +859,6 @@
}
},
"groups": {
"title": "Grup",
"name": "Nama",
"clientCount": "Klien",
"totalGroups": "Total grup",
@@ -997,7 +900,6 @@
"removeFromGroupResult": "{count} klien dihapus dari {name}."
},
"nodes": {
"title": "Node",
"addNode": "Tambah Node",
"editNode": "Edit node",
"totalNodes": "Total Node",
@@ -1016,8 +918,6 @@
"apiTokenPlaceholder": "Token dari halaman Pengaturan panel jarak jauh",
"apiTokenHint": "Panel jarak jauh menampilkan token API-nya di Otentikasi → Token API.",
"apiTokenKeepHint": "Biarkan kosong untuk mempertahankan token saat ini",
"regenerate": "Buat Ulang Token",
"regenerateConfirm": "Membuat ulang akan membatalkan token saat ini. Setiap panel pusat yang menggunakannya akan kehilangan akses sampai diperbarui. Lanjutkan?",
"allowPrivateAddress": "Izinkan alamat pribadi",
"allowPrivateAddressHint": "Aktifkan hanya untuk node di jaringan pribadi atau VPN.",
"outboundTag": "Outbound koneksi",
@@ -1049,7 +949,6 @@
"updatePanel": "Perbarui Panel",
"updateSelected": "Perbarui Terpilih ({count})",
"updateAvailable": "Pembaruan tersedia",
"upToDate": "Terbaru",
"updateConfirmTitle": "Perbarui {count} node ke versi terbaru?",
"updateConfirmContent": "Setiap node terpilih mengunduh rilis terbaru dan memulai ulang. Hanya node aktif dan online yang diperbarui.",
"updateDevChannel": "Perbarui ke kanal dev (commit terbaru)",
@@ -1113,6 +1012,7 @@
"pinFetchFailed": "Tidak dapat mengambil sertifikat"
},
"settings": {
"defaultTag": "Bawaan",
"title": "Pengaturan Panel",
"save": "Simpan",
"infoDesc": "Setiap perubahan yang dibuat di sini perlu disimpan. Harap restart panel untuk menerapkan perubahan.",
@@ -1441,6 +1341,8 @@
"eventMemoryHigh": "Penggunaan memori tinggi (%)",
"remarkTemplate": "Templat Catatan",
"remarkTemplateDesc": "Jika diatur, ini menggantikan model catatan untuk setiap tautan langganan — tulis format Anda sendiri dengan token variabel (gunakan tombol untuk menyisipkannya). Biarkan kosong untuk memakai model di atas.",
"subShowIdentityOnAllLinks": "Tampilkan identitas di setiap tautan",
"subShowIdentityOnAllLinksDesc": "Jika diaktifkan, {{EMAIL}} dan {{USERNAME}} tetap ada di catatan setiap tautan isi langganan. Token penggunaan tetap hanya di tautan pertama.",
"validation": {
"pathLeadingSlash": "Path harus diawali dengan /"
},
@@ -1448,13 +1350,8 @@
"secretClearUndo": "Batalkan hapus"
},
"xray": {
"title": "Konfigurasi Xray",
"save": "Simpan",
"restart": "Mulai ulang Xray",
"restartSuccess": "Xray berhasil diluncurkan ulang",
"restartOutputTitle": "Output mulai ulang Xray",
"restartConfirmTitle": "Mulai ulang xray?",
"restartConfirmContent": "Memuat ulang layanan xray dengan konfigurasi tersimpan.",
"stopSuccess": "Xray telah berhasil dihentikan",
"restartError": "Terjadi kesalahan saat memulai ulang Xray.",
"stopError": "Terjadi kesalahan saat menghentikan Xray.",
@@ -1464,7 +1361,6 @@
"generalConfigsDesc": "Opsi ini akan menentukan penyesuaian strategi umum.",
"logConfigs": "Log",
"logConfigsDesc": "Log dapat mempengaruhi efisiensi server Anda. Disarankan untuk mengaktifkannya dengan bijak hanya jika diperlukan",
"blockConfigsDesc": "Opsi ini akan memblokir lalu lintas berdasarkan protokol dan situs web yang diminta.",
"basicRouting": "Perutean Dasar",
"blockConnectionsConfigsDesc": "Opsi ini akan memblokir lalu lintas berdasarkan negara yang diminta.",
"directConnectionsConfigsDesc": "Koneksi langsung memastikan bahwa lalu lintas tertentu tidak dialihkan melalui server lain.",
@@ -1474,10 +1370,6 @@
"directdomains": "Domain Langsung",
"ipv4Routing": "Perutean IPv4",
"ipv4RoutingDesc": "Opsi ini akan mengalihkan lalu lintas berdasarkan tujuan tertentu melalui IPv4.",
"warpRouting": "Perutean WARP",
"warpRoutingDesc": "Opsi ini akan mengalihkan lalu lintas berdasarkan tujuan tertentu melalui WARP.",
"nordRouting": "Routing NordVPN",
"nordRoutingDesc": "Opsi ini akan mengalihkan lalu lintas berdasarkan tujuan tertentu melalui NordVPN.",
"Template": "Template Konfigurasi Xray Lanjutan",
"TemplateDesc": "File konfigurasi Xray akhir akan dibuat berdasarkan template ini.",
"FreedomStrategy": "Strategi Protokol Freedom",
@@ -1491,7 +1383,6 @@
"outboundTestUrlDesc": "URL yang digunakan saat menguji konektivitas outbound",
"Torrent": "Blokir Protokol BitTorrent",
"Inbounds": "Inbound",
"InboundsDesc": "Menerima klien tertentu.",
"Outbounds": "Outbound",
"importRules": "Impor aturan",
"exportRules": "Ekspor aturan",
@@ -1501,8 +1392,6 @@
"metricsListen": "Endpoint metrik",
"metricsListenDesc": "Tampilkan metrik gaya Prometheus dari Xray pada alamat:port ini (mis. 127.0.0.1:11111). Biarkan kosong untuk menonaktifkan. Ikat ke localhost dan reverse-proxy — endpoint ini tanpa autentikasi.",
"metricsTag": "Tag metrik",
"OutboundSubscriptions": "Langganan Outbound",
"OutboundSubscriptionsDesc": "Impor outbound dari URL langganan jarak jauh (vmess/vless/trojan/ss/...). Tag dijaga tetap stabil untuk digunakan pada penyeimbang dan aturan routing. Pembaruan berjalan otomatis.",
"Balancers": "Penyeimbang",
"balancerTagRequired": "Tag wajib diisi",
"balancerSelectorRequired": "Pilih setidaknya satu outbound",
@@ -1521,9 +1410,7 @@
"routeTesterMatchedOutbound": "Outbound yang cocok",
"routeTesterViaBalancer": "melalui penyeimbang",
"routeTesterDefaultOutbound": "Tidak ada aturan routing yang cocok — lalu lintas menuju outbound default (pertama).",
"OutboundsDesc": "Atur jalur lalu lintas keluar.",
"Routings": "Aturan Pengalihan",
"RoutingsDesc": "Prioritas setiap aturan penting!",
"completeTemplate": "Semua",
"logLevel": "Tingkat Log",
"logLevelDesc": "Tingkat log untuk log kesalahan, menunjukkan informasi yang perlu dicatat.",
@@ -1537,13 +1424,9 @@
"maskAddressDesc": "Masker alamat IP, ketika diaktifkan, akan secara otomatis mengganti alamat IP yang muncul di log.",
"statistics": "Statistik",
"statsInboundUplink": "Statistik Unggah Masuk",
"statsInboundUplinkDesc": "Mengaktifkan pengumpulan statistik untuk lalu lintas unggah dari semua proxy masuk.",
"statsInboundDownlink": "Statistik Unduh Masuk",
"statsInboundDownlinkDesc": "Mengaktifkan pengumpulan statistik untuk lalu lintas unduh dari semua proxy masuk.",
"statsOutboundUplink": "Statistik Unggah Keluar",
"statsOutboundUplinkDesc": "Mengaktifkan pengumpulan statistik untuk lalu lintas unggah dari semua proxy keluar.",
"statsOutboundDownlink": "Statistik Unduh Keluar",
"statsOutboundDownlinkDesc": "Mengaktifkan pengumpulan statistik untuk lalu lintas unduh dari semua proxy keluar.",
"connectionLimits": "Batas Koneksi",
"connectionLimitsDesc": "Kebijakan tingkat koneksi untuk level pengguna 0. Biarkan kolom kosong untuk menggunakan nilai bawaan Xray.",
"connIdle": "Batas Waktu Idle",
@@ -1553,18 +1436,10 @@
"bufferSizePlaceholder": "otomatis",
"seconds": "detik",
"rules": {
"first": "Pertama",
"last": "Terakhir",
"up": "Naik",
"down": "Turun",
"source": "Sumber",
"dest": "Tujuan",
"inbound": "Masuk",
"outbound": "Keluar",
"balancer": "Pengimbang",
"info": "Info",
"add": "Tambahkan Aturan",
"edit": "Edit Aturan",
"useComma": "Item yang dipisahkan koma"
},
"routing": {
@@ -1605,7 +1480,6 @@
"ruleN": "Aturan {n}",
"action": "Aksi",
"redirect": "Redirect",
"fragment": "Fragment",
"finalRules": "Aturan akhir",
"overrideXrayPrivateIp": "Timpa blok IP privat default Xray",
"blockDelay": "Penundaan blokir (ms)",
@@ -1631,43 +1505,17 @@
"keepAliveInterval": "Interval keep alive",
"markFwmark": "Mark (fwmark)",
"interface": "Interface",
"ipv6Only": "Hanya IPv6",
"acceptProxyProtocol": "Terima proxy protocol",
"proxyProtocol": "Proxy protocol",
"tcpUserTimeoutMs": "TCP user timeout (ms)",
"tcpKeepAliveIdleS": "TCP keep-alive idle (d)"
},
"outbound": {
"addOutbound": "Tambahkan Keluar",
"addReverse": "Tambahkan Revers",
"editOutbound": "Edit Keluar",
"editReverse": "Edit Revers",
"reverseTag": "Tag Revers",
"reverseTagDesc": "Tag outbound proxy revers sederhana VLESS. Kosongkan untuk menonaktifkan.",
"reverseTagPlaceholder": "tag outbound (kosong untuk menonaktifkan)",
"tag": "Tag",
"tagDesc": "Tag Unik",
"address": "Alamat",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"reverse": "Revers",
"domain": "Domain",
"type": "Tipe",
"bridge": "Bridge",
"portal": "Portal",
"link": "Tautan",
"intercon": "Interkoneksi",
"settings": "Pengaturan",
"accountInfo": "Informasi Akun",
"outboundStatus": "Status Keluar",
"sendThrough": "Kirim Melalui",
"targetStrategy": "Strategi Target",
"test": "Tes",
"testResult": "Hasil Tes",
"testing": "Menguji koneksi...",
"testSuccess": "Tes berhasil",
"testFailed": "Tes gagal",
"testError": "Gagal menguji outbound",
"modeRealDelay": "Delay nyata",
"testModeTooltip": "TCP: probe dial-only cepat. HTTP: permintaan penuh via xray. Delay nyata: total waktu termasuk pembentukan koneksi.",
"testAll": "Tes semua",
@@ -1675,14 +1523,10 @@
"breakdownConnect": "Koneksi proxy",
"breakdownTls": "TLS melalui outbound",
"breakdownTtfb": "Byte pertama",
"nordvpn": "NordVPN",
"accessToken": "Token Akses",
"country": "Negara",
"server": "Server",
"city": "Kota",
"allCities": "Semua Kota",
"privateKey": "Kunci Privat",
"load": "Beban",
"moveToTop": "Pindahkan ke atas"
},
"outboundSub": {
@@ -1712,16 +1556,11 @@
"active": "Langganan aktif",
"empty": "Belum ada langganan. Tambahkan satu di atas.",
"colRemark": "Catatan",
"colPrefix": "Awalan",
"colInterval": "Interval",
"colLastFetch": "Pengambilan terakhir",
"colEnabled": "Aktif",
"auto": "otomatis",
"never": "tidak pernah",
"yes": "Ya",
"no": "Tidak",
"refreshNow": "Segarkan sekarang",
"lastError": "Kesalahan terakhir",
"deleteConfirm": "Hapus langganan ini?",
"restartHint": "Setelah menambahkan atau menyegarkan, mulai ulang Xray (atau tunggu muat ulang otomatis berikutnya) agar outbound menjadi aktif.",
"fromSubsTitle": "Dari langganan outbound (hanya-baca)",
@@ -1738,8 +1577,6 @@
"tabBalancerSettings": "Pengaturan Balancer",
"tabObservatory": "Observatory",
"observatory": {
"title": "Observatory",
"burstTitle": "Burst Observatory",
"autoManaged": "Observer dikelola otomatis dari balancer Anda. Atur cara mereka melakukan probe di bawah; outbound yang dipantau mengikuti selector balancer.",
"emptyHint": "Tidak ada observer koneksi yang aktif. Satu akan ditambahkan otomatis saat Anda membuat balancer Least Ping atau Least Load — atau balancer Random / Round-robin dengan fallback — sehingga balancer yang memakai observer dapat memeriksa kesehatan outbound sebelum memilih target.",
"mixedLegacy": "Konfigurasi ini berisi Observatory dan Burst Observatory sekaligus. Xray memakai satu observer global, jadi status campuran lama ini tidak didukung; menyimpan balancer akan menormalkannya menjadi satu observer.",
@@ -1773,12 +1610,8 @@
"balancerRemoved": "Balancer {tag} — dihapus (tidak ada target tersisa)"
},
"balancer": {
"addBalancer": "Tambahkan Penyeimbang",
"editBalancer": "Sunting Penyeimbang",
"balancerStrategy": "Strategi",
"balancerSelectors": "Penyeleksi",
"tag": "Tag",
"tagDesc": "Label Unik",
"tagDuplicate": "Tag sudah digunakan oleh balancer lain",
"tagPlaceholder": "tag balancer unik",
"selector": "Selector",
@@ -1790,7 +1623,6 @@
"tolerance": "Toleransi",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "BalancerTag dan outboundTag tidak dapat digunakan secara bersamaan. Jika digunakan secara bersamaan, hanya outboundTag yang akan berfungsi.",
"costMatch": "Pola tag",
"costValue": "Bobot",
"costRegexp": "Pencocokan ekspresi reguler",
@@ -1805,7 +1637,6 @@
"publicKey": "Kunci Publik",
"allowedIPs": "IP yang Diizinkan",
"endpoint": "Titik Akhir",
"psk": "Kunci Pra-Bagi",
"domainStrategy": "Strategi Domain"
},
"amneziawg": {
@@ -1843,10 +1674,7 @@
"i1Hint": "Hanya untuk AmneziaWG 2.0. Biarkan kosong untuk kompatibilitas dengan 1.x."
},
"tun": {
"nameDesc": "Nama antarmuka TUN. Standar adalah 'xray0'",
"mtuDesc": "Unit Transmisi Maksimum. Ukuran maksimum paket data. Standar adalah 1500",
"userLevel": "Level Pengguna",
"userLevelDesc": "Semua koneksi yang dibuat melalui inbound ini akan menggunakan level pengguna ini. Standar adalah 0"
"userLevel": "Level Pengguna"
},
"nord": {
"accessToken": "Access token",
@@ -1931,7 +1759,6 @@
},
"fakedns": {
"add": "Tambahkan DNS Palsu",
"edit": "Edit DNS Palsu",
"ipPool": "Subnet Kumpulan IP",
"poolSize": "Ukuran Kolam"
},
@@ -2067,7 +1894,6 @@
"add": "Tambah",
"month": "Bulan",
"months": "Bulan",
"day": "Hari",
"days": "Hari",
"hours": "Jam",
"minutes": "Menit",
@@ -2106,7 +1932,6 @@
"userSaved": "✅ Pengguna Telegram tersimpan.",
"loginSuccess": "✅ Berhasil masuk ke panel.\r\n",
"loginFailed": "❗️ Gagal masuk ke panel.\r\n",
"2faFailed": "2FA Gagal",
"report": "🕰 Laporan Terjadwal: {{ .RunTime }}\r\n",
"datetime": "⏰ Tanggal & Waktu: {{ .DateTime }}\r\n",
"hostname": "💻 Host: {{ .Hostname }}\r\n",
@@ -2139,7 +1964,6 @@
"download": "🔽 Unduh: ↓{{ .Download }}\r\n",
"total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
"TGUser": "👤 Pengguna Telegram: {{ .TelegramID }}\r\n",
"exhaustedMsg": "🚨 Habis {{ .Type }}:\r\n",
"exhaustedCount": "🚨 Jumlah Habis {{ .Type }}:\r\n",
"onlinesCount": "🌐 Klien Online: {{ .Count }}\r\n",
"disabled": "🛑 Dinonaktifkan: {{ .Disabled }}\r\n",
@@ -2148,16 +1972,10 @@
"refreshedOn": "\r\n📋🔄 Diperbarui Pada: {{ .Time }}\r\n\r\n",
"yes": "✅ Ya",
"no": "❌ Tidak",
"received_id": "🔑📥 ID diperbarui.",
"received_password": "🔑📥 Kata sandi diperbarui.",
"received_email": "📧📥 Email diperbarui.",
"received_comment": "💬📥 Komentar diperbarui.",
"id_prompt": "🔑 ID Default: {{ .ClientId }}\n\nMasukkan ID Anda.",
"pass_prompt": "🔑 Kata Sandi Default: {{ .ClientPassword }}\n\nMasukkan kata sandi Anda.",
"email_prompt": "📧 Email Default: {{ .ClientEmail }}\n\nMasukkan email Anda.",
"comment_prompt": "💬 Komentar Default: {{ .ClientComment }}\n\nMasukkan komentar Anda.",
"inbound_client_data_id": "🔄 Masuk: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Email: {{ .ClientEmail }}\n📊 Lalu lintas: {{ .ClientTraffic }}\n📅 Tanggal Kedaluwarsa: {{ .ClientExp }}\n🌐 Batas IP: {{ .IpLimit }}\n💬 Komentar: {{ .ClientComment }}\n\nSekarang kamu bisa menambahkan klien ke inbound!",
"inbound_client_data_pass": "🔄 Masuk: {{ .InboundRemark }}\n\n🔑 Kata sandi: {{ .ClientPass }}\n📧 Email: {{ .ClientEmail }}\n📊 Lalu lintas: {{ .ClientTraffic }}\n📅 Tanggal Kedaluwarsa: {{ .ClientExp }}\n🌐 Batas IP: {{ .IpLimit }}\n💬 Komentar: {{ .ClientComment }}\n\nSekarang kamu bisa menambahkan klien ke inbound!",
"cancel": "❌ Proses Dibatalkan! \n\nAnda dapat /start lagi kapan saja. 🔄",
"error_add_client": "⚠️ Error:\n\n {{ .error }}",
"using_default_value": "Oke, saya akan tetap menggunakan nilai default. 😊",
@@ -2174,8 +1992,6 @@
"eventXrayCrashError": "Kesalahan: {{ .Error }}",
"eventNodeDown": "Node {{ .Name }} MATI",
"eventNodeUp": "Node {{ .Name }} AKTIF",
"eventCPUHigh": "CPU tinggi",
"eventCPUHighDetail": "CPU: {{ .Detail }}",
"eventLoginFallback": "Gagal masuk dari {{ .Source }}",
"memoryThreshold": "Penggunaan memori {{ .Percent }}% melebihi ambang batas {{ .Threshold }}%"
},
@@ -2216,11 +2032,8 @@
"submitDisable": "Kirim Sebagai Nonaktif ☑️",
"submitEnable": "Kirim Sebagai Aktif ✅",
"use_default": "🏷️ Gunakan Default",
"change_id": "⚙️🔑 ID",
"change_password": "⚙️🔑 Kata Sandi",
"change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Komentar",
"change_flow": "⚙️🚦 Flow",
"ResetAllTraffics": "Reset Semua Lalu Lintas",
"SortedTrafficUsageReport": "Laporan Penggunaan Lalu Lintas yang Terurut"
},
@@ -2249,31 +2062,16 @@
}
},
"email": {
"subjectOutboundDown": "Outbound {{ .Tag }} MATI",
"subjectOutboundUp": "Outbound {{ .Tag }} AKTIF",
"subjectXrayCrash": "Xray CRASH",
"subjectCPUHigh": "CPU tinggi",
"subjectLoginSuccess": "Berhasil masuk",
"subjectLoginFailed": "Gagal masuk",
"titleOutboundDown": "Outbound MATI",
"titleOutboundUp": "Outbound AKTIF",
"titleXrayCrash": "Xray CRASH",
"titleCPUHigh": "CPU tinggi",
"titleLoginSuccess": "Berhasil masuk",
"titleLoginFailed": "Gagal masuk",
"labelStatus": "Status",
"labelOutbound": "Outbound",
"labelNode": "Node",
"labelError": "Kesalahan",
"labelDelay": "Penundaan",
"labelDetail": "Detail",
"labelUsername": "Nama Pengguna",
"labelIP": "IP",
"labelReason": "Alasan",
"labelSource": "Sumber",
"labelTime": "Waktu",
"statusCrashed": "CRASH",
"statusRunning": "Berjalan",
"statusHigh": "TINGGI",
"statusSuccess": "BERHASIL",
"statusFailed": "GAGAL",
+25 -227
View File
@@ -48,7 +48,6 @@
"copySuccess": "コピー成功",
"sure": "確定",
"encryption": "暗号化",
"useIPv4ForHost": "ホストにIPv4を使用",
"transmission": "伝送",
"host": "ホスト",
"path": "パス",
@@ -74,18 +73,9 @@
"twoFactorCode": "コード",
"remained": "残り",
"security": "セキュリティ",
"secAlertTitle": "セキュリティアラート",
"secAlertSsl": "この接続は安全ではありません。TLSを有効にしてデータ保護を行うまで、機密情報を入力しないでください。",
"secAlertConf": "一部の設定は脆弱です。潜在的な脆弱性を防ぐために、セキュリティプロトコルを強化することをお勧めします。",
"secAlertSSL": "セキュアな接続がありません。データ保護のためにTLS証明書をインストールしてください。",
"secAlertPanelPort": "デフォルトのポートにはセキュリティリスクがあります。ランダムなポートまたは特定のポートを設定してください。",
"secAlertPanelURI": "デフォルトのURIパスは安全ではありません。複雑なURIパスを設定してください。",
"secAlertSubURI": "サブスクリプションのデフォルトURIパスは安全ではありません。複雑なURIパスを設定してください。",
"secAlertSubJsonURI": "JSONサブスクリプションのデフォルトURIパスは安全ではありません。複雑なURIパスを設定してください。",
"emptyDnsDesc": "追加されたDNSサーバーはありません。",
"emptyFakeDnsDesc": "追加されたFake DNSサーバーはありません。",
"emptyBalancersDesc": "追加されたバランサーはありません。",
"emptyReverseDesc": "追加されたリバースプロキシはありません。",
"somethingWentWrong": "エラーが発生しました",
"subscription": {
"title": "サブスクリプション情報",
@@ -106,8 +96,6 @@
},
"menu": {
"theme": "テーマ",
"dark": "ダーク",
"ultraDark": "ウルトラダーク",
"dashboard": "ダッシュボード",
"inbounds": "インバウンド",
"clients": "クライアント",
@@ -118,12 +106,12 @@
"routing": "ルーティング",
"outbounds": "アウトバウンド",
"apiDocs": "API ドキュメント",
"logout": "ログアウト",
"link": "リンク管理",
"donate": "寄付",
"hosts": "ホスト",
"docs": "ドキュメント",
"openMenu": "メニューを開く"
"openMenu": "メニューを開く",
"pinSidebar": "サイドバーを固定",
"unpinSidebar": "サイドバーの固定を解除"
},
"pages": {
"login": {
@@ -139,20 +127,15 @@
}
},
"index": {
"title": "システムステータス",
"cpu": "CPU",
"logicalProcessors": "論理プロセッサ",
"frequency": "周波数",
"swap": "スワップ",
"storage": "ストレージ",
"memory": "メモリ",
"threads": "スレッド",
"xrayStatus": "Xray",
"stopXray": "停止",
"restartXray": "再起動",
"xraySwitch": "バージョン",
"xrayUpdates": "Xrayの更新",
"xraySwitchClick": "切り替えるバージョンを選択してください",
"xraySwitchClickDesk": "慎重に選択してください。古いバージョンは現在の設定と互換性がない可能性があります。",
"updatePanel": "パネルを更新",
"panelUpdateDesc": "これにより3X-UIが最新リリースに更新され、パネルサービスが再起動されます。",
@@ -164,13 +147,10 @@
"currentCommit": "現在のコミット",
"latestCommit": "最新のコミット",
"updateChannelChanged": "更新チャンネルを変更しました",
"upToDate": "最新",
"xrayStatusUnknown": "不明",
"xrayStatusRunning": "実行中",
"xrayStatusStop": "停止",
"xrayStatusError": "エラー",
"xrayErrorPopoverTitle": "Xrayの実行中にエラーが発生しました",
"operationHours": "システム稼働時間",
"systemHistoryTitle": "システム履歴",
"historyTitleCpu": "CPU 使用率",
"historyTitleMem": "メモリ使用率",
@@ -188,7 +168,6 @@
"historyTabLoad": "負荷",
"historyTabConnections": "接続数",
"historyTabDiskUsage": "ディスク使用量",
"charts": "チャート",
"xrayMetricsTitle": "Xray メトリクス",
"xrayTitleHeap": "割り当て済みヒープメモリ",
"xrayTitleSys": "OS から確保したメモリ",
@@ -211,25 +190,19 @@
"xrayObservatoryDead": "停止",
"xrayObservatoryLastSeen": "最終確認",
"xrayObservatoryLastTry": "最終試行",
"trendLast2Min": "直近2分",
"systemLoad": "システム負荷",
"systemLoadDesc": "過去1、5、15分間のシステム平均負荷",
"connectionCount": "接続数",
"ipAddresses": "IPアドレス",
"toggleIpVisibility": "IPの表示を切り替える",
"overallSpeed": "全体の速度",
"upload": "アップロード",
"download": "ダウンロード",
"totalData": "総データ量",
"sent": "送信",
"received": "受信",
"documentation": "ドキュメント",
"xraySwitchVersionDialog": "Xrayのバージョンを本当に変更しますか?",
"xraySwitchVersionDialogDesc": "Xrayのバージョンが#version#に変更されます。",
"xraySwitchVersionPopover": "Xrayの更新が成功しました",
"panelUpdateDialog": "本当にパネルを更新しますか?",
"panelUpdateDialogDesc": "これにより3X-UIが#version#に更新され、パネルサービスが再起動されます。",
"panelUpdateCheckPopover": "パネルの更新確認に失敗しました",
"panelUpdateStartedPopover": "パネルの更新を開始しました",
"panelUpdateFailedTitle": "パネルの更新に失敗しました",
"panelUpdateFailedDesc": "更新が正常に完了しませんでした。サーバーのログを確認するか、コマンドラインで「x-ui update」を実行してください。",
@@ -258,7 +231,6 @@
"accessLogs": "アクセスログ",
"autoUpdate": "自動更新",
"config": "設定",
"backup": "バックアップ",
"backupTitle": "バックアップと復元",
"exportDatabase": "バックアップ",
"exportDatabaseDesc": "クリックして、現在のデータベースのバックアップを含む .db ファイルをデバイスにダウンロードします。同じファイルは PostgreSQL で動作するパネルにも復元できます。",
@@ -273,10 +245,20 @@
"exportDatabasePgDesc": "現在のデータベースの PostgreSQL ダンプ (.dump) を端末にダウンロードするにはクリックしてください。",
"importDatabasePgDesc": "データベースを復元するために PostgreSQL バックアップ (.dump)、SQLite データベース (.db)、または SQLite 移行ダンプを選択してアップロードするにはクリックしてください。現在のすべてのデータが置き換えられます。",
"migrationDownload": "移行ファイルをダウンロード",
"migrationDownloadPgDesc": "PostgreSQL のデータから作成した .db SQLite データベースをダウンロードします。このパネルを SQLite で実行する準備が整います。"
"migrationDownloadPgDesc": "PostgreSQL のデータから作成した .db SQLite データベースをダウンロードします。このパネルを SQLite で実行する準備が整います。",
"avg": "平均",
"peak": "ピーク",
"free": "空き",
"openSockets": "オープンソケット",
"throughputSub": "インターフェース合計",
"avgWindow": "期間平均",
"healthWarm": "{list} — 高めです",
"healthCritical": "{list} — 危険水準",
"panel": "パネル",
"threads": "スレッド",
"uptime": "稼働時間"
},
"inbounds": {
"title": "インバウンド",
"totalDownUp": "総アップロード / ダウンロード",
"totalUsage": "総使用量",
"inboundCount": "インバウンド数",
@@ -288,21 +270,11 @@
"localPanel": "ローカルパネル",
"fallbacks": {
"title": "Fallbacks",
"help": "このインバウンドへの接続がどのクライアントにも一致しない場合、別の宛先へルーティングします。下から子インバウンドを選ぶとルーティング項目(SNI / ALPN / Path / xver)がそのトランスポートから自動的に埋められます。あるいは選択を空のままにして Dest を直接指定すると(例: 8080 または 127.0.0.1:8080)、Nginx などの外部サーバーへルーティングできます。各子インバウンドは 127.0.0.1 で security=none をリッスンする必要があります。",
"empty": "フォールバックはまだありません",
"add": "フォールバックを追加",
"pickInbound": "インバウンドを選択",
"matchAny": "任意",
"destPlaceholder": "自動(子の listen:port)",
"rederive": "子から再取得",
"rederived": "子から再取得しました",
"editAdvanced": "ルーティング項目を編集",
"hideAdvanced": "詳細を隠す",
"quickAddAll": "対象のインバウンドをすべて一括追加",
"quickAdded": "{n} 件のフォールバックを追加しました",
"quickAddedNone": "追加可能な新規インバウンドはありません",
"routesWhen": "次の条件でルーティング",
"defaultCatchAll": "デフォルト — その他すべてを捕捉",
"needsTls": "フォールバックは、セキュリティタブで TLS または Reality を選択すると設定できます(RAW 上の VLESS/Trojan のみ)。"
},
"protocol": "プロトコル",
@@ -310,8 +282,6 @@
"portMap": "ポートマッピング",
"traffic": "トラフィック",
"speed": "速度",
"details": "詳細情報",
"transportConfig": "トランスポート",
"expireDate": "有効期限",
"createdAt": "作成",
"updatedAt": "更新",
@@ -319,8 +289,6 @@
"addInbound": "インバウンド追加",
"generalActions": "一般操作",
"modifyInbound": "インバウンド修正",
"deleteInbound": "インバウンド削除",
"deleteInboundContent": "インバウンドを削除してもよろしいですか?",
"deleteConfirmTitle": "インバウンド「{remark}」を削除しますか?",
"deleteConfirmContent": "インバウンドと関連付けされたすべてのクライアントを削除します。元に戻せません。",
"resetConfirmTitle": "「{remark}」のトラフィックをリセットしますか?",
@@ -364,8 +332,6 @@
"exportAllLinksFileName": "全インバウンド",
"exportAllSubsFileName": "全インバウンド-Subs",
"inboundJsonTitle": "インバウンド JSON",
"deleteClient": "クライアント削除",
"deleteClientContent": "クライアントを削除してもよろしいですか?",
"resetTrafficContent": "トラフィックをリセットしてもよろしいですか?",
"copyLink": "リンクをコピー",
"address": "アドレス",
@@ -376,36 +342,19 @@
"meansNoLimit": "= 無制限。(単位: GB)",
"totalFlow": "総トラフィック",
"leaveBlankToNeverExpire": "空白にすると期限なし",
"noRecommendKeepDefault": "デフォルト値を保持することをお勧めします",
"certificatePath": "ファイルパス",
"certificateContent": "ファイル内容",
"publicKey": "公開鍵",
"privatekey": "秘密鍵",
"clickOnQRcode": "QRコードをクリックしてコピー",
"client": "クライアント",
"export": "リンクエクスポート",
"clone": "複製",
"cloneInbound": "複製",
"cloneInboundContent": "このインバウンドルールは、ポート(Port)、リスニングIP(Listening IP)、クライアント(Clients)を除くすべての設定がクローンされます",
"cloneInboundOk": "クローン作成",
"resetAllTraffic": "すべてのインバウンドトラフィックをリセット",
"resetAllTrafficTitle": "すべてのインバウンドトラフィックをリセット",
"resetAllTrafficContent": "すべてのインバウンドトラフィックをリセットしてもよろしいですか?",
"resetInboundClientTraffics": "クライアントトラフィックをリセット",
"resetInboundClientTrafficTitle": "すべてのクライアントトラフィックをリセット",
"resetInboundClientTrafficContent": "このインバウンドクライアントのすべてのトラフィックをリセットしてもよろしいですか?",
"resetAllClientTraffics": "すべてのクライアントトラフィックをリセット",
"resetAllClientTrafficTitle": "すべてのクライアントトラフィックをリセット",
"resetAllClientTrafficContent": "すべてのクライアントのトラフィックをリセットしてもよろしいですか?",
"delDepletedClients": "トラフィックが尽きたクライアントを削除",
"delDepletedClientsTitle": "トラフィックが尽きたクライアントを削除",
"delDepletedClientsContent": "トラフィックが尽きたすべてのクライアントを削除してもよろしいですか?",
"email": "メール",
"emailDesc": "メールアドレスは一意でなければなりません",
"IPLimit": "IP制限",
"IPLimitDesc": "設定値を超えるとインバウンドトラフィックが無効になります。(0 = 無効)",
"IPLimitlog": "IPログ",
"IPLimitlogDesc": "IP履歴ログ(無効なインバウンドトラフィックを有効にするには、ログをクリアしてください)",
"IPLimitlogclear": "ログをクリア",
"setDefaultCert": "パネル設定から証明書を設定",
"setDefaultCertEmpty": "パネル用の証明書が設定されていません。先に設定から指定してください。",
@@ -438,20 +387,15 @@
"sniffing": "Sniffing",
"sniffingHelp": "Xray sniffing ブロックのラッパー:",
"stream": "Stream",
"streamHelp": "Xray stream ブロックのラッパー:",
"jsonErrorPrefix": "高度な JSON"
"streamHelp": "Xray stream ブロックのラッパー:"
},
"telegramDesc": "TelegramチャットIDを提供してください。(ボットで'/id'コマンドを使用)または({'@'}userinfobot)",
"subscriptionDesc": "サブスクリプションURLを見つけるには、“詳細情報”に移動してください。また、複数のクライアントに同じ名前を使用することができます。",
"subSortIndex": "サブ並び順",
"same": "同じ",
"inboundInfo": "インバウンド情報",
"exportInbound": "インバウンドルールをエクスポート",
"import": "インポート",
"importInbound": "インバウンドルールをインポート",
"periodicTrafficResetTitle": "トラフィックリセット",
"periodicTrafficResetDesc": "指定された間隔でトラフィックカウンタを自動的にリセット",
"lastReset": "最後のリセット",
"periodicTrafficResetDay": "毎月のリセット日",
"periodicTrafficReset": {
"never": "なし",
"daily": "毎日",
@@ -463,7 +407,6 @@
"obtain": "取得",
"updateSuccess": "更新が成功しました",
"logCleanSuccess": "ログがクリアされました",
"inboundsUpdateSuccess": "インバウンドが正常に更新されました",
"inboundUpdateSuccess": "インバウンドが正常に更新されました",
"inboundCreateSuccess": "インバウンドが正常に作成されました",
"bulkDeleted": "{count} 件のインバウンドを削除しました",
@@ -473,7 +416,6 @@
"inboundClientDeleteSuccess": "インバウンドクライアントが削除されました",
"inboundClientUpdateSuccess": "インバウンドクライアントが更新されました",
"savedNodeOfflineWillSync": "ローカルに保存しました。バックエンドのノードがオフラインまたは無効になっています — 再接続後に変更が同期されます。",
"delDepletedClientsSuccess": "すべての枯渇したクライアントが削除されました",
"resetAllClientTrafficSuccess": "クライアントのすべてのトラフィックがリセットされました",
"resetAllTrafficSuccess": "すべてのトラフィックがリセットされました",
"resetInboundClientTrafficSuccess": "トラフィックがリセットされました",
@@ -657,6 +599,10 @@
"maxTimeDiff": "最大時間差 (ms)",
"minClientVer": "最小クライアントバージョン",
"maxClientVer": "最大クライアントバージョン",
"minClientVerHint": "空欄は無制限ではありません。Xray-core は実行中のコアに組み込まれた最低バージョン(現行リリースでは 26.3.27)を適用し、それより古いバージョンを名乗るクライアント(Mihomo や sing-box などのサードパーティコアを含む)を拒否します。1.0.0 を設定すると許可されますが、古い TLS フィンガープリントも受け入れることになります。",
"maxClientVerHint": "空欄は上限なしを意味します。設定する場合は実効的な下限(最小クライアントバージョン。その欄が空欄の場合は Xray-core 組み込みの最低バージョン)を下回らないでください。下回るとすべてのクライアントが拒否されます。",
"clientVerInvalid": "クライアントバージョンはドット区切りの数値(最大 3 つ、各 0-255)で指定してください(例:26.3.27)",
"maxClientVerBelowMin": "最大クライアントバージョンは最小クライアントバージョンを下回れません",
"shortIds": "Short IDs",
"realityTargetHint": "必須です。ポートを含める必要があります(例: example.com:443)。ポートがないと Xray-core は起動しません。",
"realityTargetRequired": "REALITY ターゲットは必須です",
@@ -712,23 +658,6 @@
"peerNumber": "Peer {n}",
"peerNumberConfig": "Peer {n} 設定"
},
"stream": {
"general": {
"request": "リクエスト",
"response": "レスポンス",
"name": "名前",
"value": "値"
},
"tcp": {
"version": "バージョン",
"method": "方法",
"path": "パス",
"status": "ステータス",
"statusDescription": "ステータス説明",
"requestHeader": "リクエストヘッダー",
"responseHeader": "レスポンスヘッダー"
}
},
"sniffingDestOverride": "宛先のオーバーライド"
},
"clients": {
@@ -742,24 +671,9 @@
"addExternalSubscription": "外部サブスクリプションを追加",
"noExternalLinks": "外部リンクはまだありません。",
"noExternalSubscriptions": "外部サブスクリプションはまだありません。",
"add": "クライアントを追加",
"edit": "クライアントを編集",
"submitAdd": "クライアントを追加",
"submitEdit": "変更を保存",
"clientCount": "クライアント数",
"bulk": "一括追加",
"copyFromInbound": "インバウンドからクライアントをコピー",
"copyToInbound": "コピー先",
"copySelected": "選択をコピー",
"copySource": "コピー元",
"copyEmailPreview": "生成されるメールのプレビュー",
"copySelectSourceFirst": "まずコピー元のインバウンドを選択してください。",
"copyResult": "コピー結果",
"copyResultSuccess": "コピーに成功しました",
"copyResultNone": "コピーする対象がありません。クライアントが選択されていないか、コピー元が空です",
"copyResultErrors": "コピーエラー",
"copyFlowLabel": "新規クライアントの Flow (VLESS)",
"copyFlowHint": "コピーされる全クライアントに適用されます。空欄でスキップします。",
"selectAll": "すべて選択",
"clearAll": "すべてクリア",
"method": "メソッド",
@@ -770,7 +684,6 @@
"postfix": "サフィックス",
"delayedStart": "初回使用から開始",
"expireDays": "期間 (日)",
"days": "日",
"renew": "自動更新",
"renewDesc": "有効期限切れ後に自動更新します。(0 = 無効) (単位: 日)",
"renewDays": "自動更新 (日)",
@@ -793,7 +706,6 @@
"sortExpiringSoonest": "もうすぐ期限切れ",
"has": "あり",
"hasNot": "なし",
"title": "クライアント",
"actions": "操作",
"totalGB": "トラフィック上限 (GB)",
"totalGBDesc": "このクライアントのデータ割当量。0 = 無制限。",
@@ -821,8 +733,6 @@
"addClient": "クライアントを追加",
"qrCode": "QR コード",
"clientInfo": "クライアント情報",
"delete": "削除",
"reset": "トラフィックをリセット",
"editClient": "クライアントを編集",
"client": "クライアント",
"enabled": "有効",
@@ -836,13 +746,11 @@
"noLinks": "共有可能なリンクがありません — まずこのクライアントを対応するプロトコルのインバウンドに関連付けてください。",
"link": "リンク",
"resetNotPossible": "まずこのクライアントをインバウンドに関連付けてください。",
"general": "一般",
"resetAllTraffics": "すべてのクライアントのトラフィックをリセット",
"resetAllTrafficsTitle": "すべてのクライアントのトラフィックをリセットしますか?",
"resetAllTrafficsContent": "すべてのクライアントの送受信カウンターがゼロにリセットされます。クォータと有効期限には影響しません。元に戻せません。",
"deleteConfirmTitle": "クライアント {email} を削除しますか?",
"deleteConfirmContent": "クライアントを関連付けされたすべてのインバウンドから削除し、トラフィック記録も破棄します。元に戻せません。",
"deleteSelected": "削除 ({count})",
"adjustSelected": "調整 ({count})",
"subLinksSelected": "サブリンク ({count})",
"addToGroupTitle": "{count} クライアントをグループに追加",
@@ -864,12 +772,10 @@
"bulkDisableConfirmTitle": "{count} 件のクライアントを無効化しますか?",
"bulkDisableConfirmContent": "選択した各クライアントを、接続されているすべてのインバウンドで無効化します。アクセスはすぐに失われますが、記録とトラフィックは保持されます。",
"selectedCount": "{count} 選択中",
"attachSelected": "アタッチ ({count})",
"attachToInboundsTitle": "{count} クライアントをインバウンドにアタッチ",
"attachToInboundsDesc": "選択した {count} クライアント(同じ UUID/パスワードと共有トラフィック)を選択したインバウンドにアタッチします。既存のアタッチは維持されます。",
"attachToInboundsTargets": "ターゲットインバウンド",
"attachToInboundsNoTargets": "アタッチ可能なマルチユーザーインバウンドがありません。",
"detachSelected": "デタッチ ({count})",
"detach": "デタッチ",
"detachFromInboundsTitle": "{count} クライアントをインバウンドからデタッチ",
"detachFromInboundsDesc": "選択した {count} クライアントを選択したインバウンドから外します。アタッチされていなかったペアは黙ってスキップされます。クライアントレコードは保持されます (完全に削除するには Delete を使用)。",
@@ -931,8 +837,6 @@
"reverseTagPlaceholder": "任意の Reverse tag",
"telegramId": "Telegram ユーザー ID",
"telegramIdPlaceholder": "数値の Telegram ユーザー ID (0 = なし)",
"created": "作成日",
"updated": "更新日",
"ipLimit": "IP 制限",
"toasts": {
"deleted": "クライアントを削除しました",
@@ -955,7 +859,6 @@
}
},
"groups": {
"title": "グループ",
"name": "名前",
"clientCount": "クライアント",
"totalGroups": "グループ合計",
@@ -997,7 +900,6 @@
"removeFromGroupResult": "{count} クライアントを {name} から外しました。"
},
"nodes": {
"title": "ノード",
"addNode": "ノードを追加",
"editNode": "ノード編集",
"totalNodes": "ノード総数",
@@ -1016,8 +918,6 @@
"apiTokenPlaceholder": "リモートパネルの設定ページから取得したトークン",
"apiTokenHint": "リモートパネルでは、セキュリティ設定 → APIトークン でAPIトークンを確認できます。",
"apiTokenKeepHint": "現在のトークンを保持するには空欄のままにします",
"regenerate": "トークンを再生成",
"regenerateConfirm": "再生成すると現在のトークンは無効になります。これを使用しているすべての中央パネルは更新されるまでアクセスできなくなります。続行しますか?",
"allowPrivateAddress": "プライベートアドレスを許可",
"allowPrivateAddressHint": "プライベートネットワークまたはVPN上のノードにのみ有効にします。",
"outboundTag": "接続アウトバウンド",
@@ -1049,7 +949,6 @@
"updatePanel": "パネルを更新",
"updateSelected": "選択を更新 ({count})",
"updateAvailable": "更新あり",
"upToDate": "最新",
"updateConfirmTitle": "{count} 個のノードを最新バージョンに更新しますか?",
"updateConfirmContent": "選択した各ノードは最新リリースをダウンロードして再起動します。有効かつオンラインのノードのみが更新されます。",
"updateDevChannel": "開発チャンネルに更新(最新コミット)",
@@ -1113,6 +1012,7 @@
"pinFetchFailed": "証明書を取得できませんでした"
},
"settings": {
"defaultTag": "デフォルト",
"title": "パネル設定",
"save": "保存",
"infoDesc": "ここでのすべての変更は、保存してパネルを再起動する必要があります",
@@ -1441,6 +1341,8 @@
"eventMemoryHigh": "メモリ使用率が高い (%)",
"remarkTemplate": "備考テンプレート",
"remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。",
"subShowIdentityOnAllLinks": "すべてのリンクに識別情報を表示",
"subShowIdentityOnAllLinksDesc": "有効にすると、{{EMAIL}} と {{USERNAME}} がサブスクリプション本文のすべてのリンク備考に残ります。使用量トークンは引き続き最初のリンクのみです。",
"validation": {
"pathLeadingSlash": "パスは / で始まる必要があります"
},
@@ -1448,7 +1350,6 @@
"secretClearUndo": "クリアを取り消す"
},
"xray": {
"title": "Xray 設定",
"importRules": "ルールをインポート",
"exportRules": "ルールをエクスポート",
"importOutbounds": "アウトバウンドをインポート",
@@ -1458,11 +1359,7 @@
"metricsListenDesc": "この アドレス:ポート で Xray の Prometheus 形式メトリクスを公開します(例: 127.0.0.1:11111)。空欄にすると無効になります。認証されないため、localhost にバインドしてリバースプロキシ経由で公開してください。",
"metricsTag": "メトリクスタグ",
"save": "保存",
"restart": "Xray を再起動",
"restartSuccess": "Xrayの再起動に成功しました",
"restartOutputTitle": "Xray 再起動の出力",
"restartConfirmTitle": "xray を再起動?",
"restartConfirmContent": "保存された構成で xray サービスを再ロードします。",
"stopSuccess": "Xrayが正常に停止しました",
"restartError": "Xrayの再起動中にエラーが発生しました。",
"stopError": "Xrayの停止中にエラーが発生しました。",
@@ -1472,7 +1369,6 @@
"generalConfigsDesc": "これらのオプションは一般設定を決定します",
"logConfigs": "ログ",
"logConfigsDesc": "ログはサーバーのパフォーマンスに影響を与える可能性があるため、必要な場合にのみ有効にすることをお勧めします",
"blockConfigsDesc": "これらのオプションは、特定のプロトコルやウェブサイトへのユーザー接続をブロックします",
"basicRouting": "基本ルーティング",
"blockConnectionsConfigsDesc": "これらのオプションにより、特定のリクエスト元の国に基づいてトラフィックをブロックします。",
"directConnectionsConfigsDesc": "直接接続により、特定のトラフィックが他のサーバーを経由しないようにします。",
@@ -1482,10 +1378,6 @@
"directdomains": "直接ドメイン",
"ipv4Routing": "IPv4 ルーティング",
"ipv4RoutingDesc": "このオプションはIPv4のみを介してターゲットドメインへルーティングします",
"warpRouting": "WARP ルーティング",
"warpRoutingDesc": "注意:これらのオプションを使用する前に、パネルのGitHubの手順に従って、サーバーにsocks5プロキシモードでWARPをインストールしてください。WARPはCloudflareサーバー経由でトラフィックをウェブサイトにルーティングします。",
"nordRouting": "NordVPN ルーティング",
"nordRoutingDesc": "これらのオプションはNordVPN経由で特定の宛先にトラフィックをルーティングします。",
"Template": "高度なXray設定テンプレート",
"TemplateDesc": "最終的なXray設定ファイルはこのテンプレートに基づいて生成されます",
"FreedomStrategy": "Freedom プロトコル戦略",
@@ -1499,10 +1391,7 @@
"outboundTestUrlDesc": "アウトバウンド接続テストに使用する URL。既定値",
"Torrent": "BitTorrent プロトコルをブロック",
"Inbounds": "インバウンド",
"InboundsDesc": "特定のクライアントからのトラフィックを受け入れる",
"Outbounds": "アウトバウンド",
"OutboundSubscriptions": "アウトバウンドサブスクリプション",
"OutboundSubscriptionsDesc": "リモートのサブスクリプションURL(vmess/vless/trojan/ss/...)からアウトバウンドをインポートします。タグはバランサーやルーティングルールで使えるように安定して保持されます。更新は自動的に行われます。",
"Balancers": "負荷分散",
"balancerTagRequired": "タグは必須です",
"balancerSelectorRequired": "アウトバウンドを少なくとも1つ選んでください",
@@ -1521,9 +1410,7 @@
"routeTesterMatchedOutbound": "マッチしたアウトバウンド",
"routeTesterViaBalancer": "バランサー経由",
"routeTesterDefaultOutbound": "ルーティングルールに一致しませんでした — トラフィックはデフォルト(最初の)アウトバウンドに送られます。",
"OutboundsDesc": "アウトバウンドトラフィックの送信方法を設定する",
"Routings": "ルーティングルール",
"RoutingsDesc": "各ルールの優先順位が重要です",
"completeTemplate": "すべて",
"logLevel": "ログレベル",
"logLevelDesc": "エラーログのレベルを指定し、記録する情報を示します",
@@ -1537,13 +1424,9 @@
"maskAddressDesc": "IPアドレスをマスクし、有効にするとログに表示されるIPアドレスを自動的に置き換えます",
"statistics": "統計",
"statsInboundUplink": "インバウンドアップロード統計",
"statsInboundUplinkDesc": "すべてのインバウンドプロキシのアップストリームトラフィックの統計収集を有効にします。",
"statsInboundDownlink": "インバウンドダウンロード統計",
"statsInboundDownlinkDesc": "すべてのインバウンドプロキシのダウンストリームトラフィックの統計収集を有効にします。",
"statsOutboundUplink": "アウトバウンドアップロード統計",
"statsOutboundUplinkDesc": "すべてのアウトバウンドプロキシのアップストリームトラフィックの統計収集を有効にします。",
"statsOutboundDownlink": "アウトバウンドダウンロード統計",
"statsOutboundDownlinkDesc": "すべてのアウトバウンドプロキシのダウンストリームトラフィックの統計収集を有効にします。",
"connectionLimits": "接続制限",
"connectionLimitsDesc": "ユーザーレベル0の接続レベルのポリシーです。フィールドを空のままにすると Xray のデフォルト値が使用されます。",
"connIdle": "アイドルタイムアウト",
@@ -1553,18 +1436,10 @@
"bufferSizePlaceholder": "自動",
"seconds": "秒",
"rules": {
"first": "最初",
"last": "最後",
"up": "上へ",
"down": "下へ",
"source": "ソース",
"dest": "宛先アドレス",
"inbound": "インバウンド",
"outbound": "アウトバウンド",
"balancer": "負荷分散",
"info": "情報",
"add": "ルール追加",
"edit": "ルール編集",
"useComma": "カンマ区切りの項目"
},
"routing": {
@@ -1605,7 +1480,6 @@
"ruleN": "ルール {n}",
"action": "アクション",
"redirect": "Redirect",
"fragment": "Fragment",
"finalRules": "最終ルール",
"overrideXrayPrivateIp": "Xray のデフォルトプライベート IP ブロックを上書き",
"blockDelay": "ブロック遅延 (ms)",
@@ -1631,43 +1505,17 @@
"keepAliveInterval": "keep alive 間隔",
"markFwmark": "Mark (fwmark)",
"interface": "インターフェース",
"ipv6Only": "IPv6 のみ",
"acceptProxyProtocol": "proxy protocol を受け入れる",
"proxyProtocol": "Proxy protocol",
"tcpUserTimeoutMs": "TCP user timeout (ms)",
"tcpKeepAliveIdleS": "TCP keep-alive idle (秒)"
},
"outbound": {
"addOutbound": "アウトバウンド追加",
"addReverse": "リバース追加",
"editOutbound": "アウトバウンド編集",
"editReverse": "リバース編集",
"reverseTag": "リバースタグ",
"reverseTagDesc": "VLESSシンプルリバースプロキシのアウトバウンドタグ。無効にするには空欄にしてください。",
"reverseTagPlaceholder": "アウトバウンドタグ(空欄で無効)",
"tag": "タグ",
"tagDesc": "一意のタグ",
"address": "アドレス",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"reverse": "リバース",
"domain": "ドメイン",
"type": "タイプ",
"bridge": "Bridge",
"portal": "Portal",
"link": "リンク",
"intercon": "インターコネクション",
"settings": "設定",
"accountInfo": "アカウント情報",
"outboundStatus": "アウトバウンドステータス",
"sendThrough": "送信経路",
"targetStrategy": "ターゲット解決戦略",
"test": "テスト",
"testResult": "テスト結果",
"testing": "接続をテスト中...",
"testSuccess": "テスト成功",
"testFailed": "テスト失敗",
"testError": "アウトバウンドのテストに失敗しました",
"modeRealDelay": "実際の遅延",
"testModeTooltip": "TCP: 高速 dial-only プローブ。HTTP: xray を経由した完全リクエスト。実際の遅延: 接続確立を含む合計時間。",
"testAll": "すべてテスト",
@@ -1675,14 +1523,10 @@
"breakdownConnect": "プロキシ接続",
"breakdownTls": "アウトバウンド経由のTLS",
"breakdownTtfb": "最初のバイト",
"nordvpn": "NordVPN",
"accessToken": "アクセストークン",
"country": "国",
"server": "サーバー",
"city": "都市",
"allCities": "すべての都市",
"privateKey": "秘密鍵",
"load": "負荷",
"moveToTop": "先頭に移動"
},
"outboundSub": {
@@ -1712,16 +1556,11 @@
"active": "有効なサブスクリプション",
"empty": "サブスクリプションはまだありません。上から追加してください。",
"colRemark": "備考",
"colPrefix": "プレフィックス",
"colInterval": "間隔",
"colLastFetch": "最終取得",
"colEnabled": "有効",
"auto": "自動",
"never": "なし",
"yes": "はい",
"no": "いいえ",
"refreshNow": "今すぐ更新",
"lastError": "最後のエラー",
"deleteConfirm": "このサブスクリプションを削除しますか?",
"restartHint": "追加または更新した後、アウトバウンドを有効にするにはXrayを再起動してください(または次の自動リロードをお待ちください)。",
"fromSubsTitle": "アウトバウンドサブスクリプションから(読み取り専用)",
@@ -1738,8 +1577,6 @@
"tabBalancerSettings": "バランサー設定",
"tabObservatory": "オブザーバトリ",
"observatory": {
"title": "オブザーバトリ",
"burstTitle": "バースト オブザーバトリ",
"autoManaged": "オブザーバはバランサーから自動的に管理されます。プローブの方法は下で調整できます。監視対象のアウトバウンドはバランサーのセレクターに従います。",
"emptyHint": "有効な接続オブザーバはありません。Least Ping または Least Load のバランサー、あるいは fallback 付きの Random / Round-robin バランサーを作成すると自動的に追加され、オブザーバを使うバランサーがターゲットを選ぶ前にアウトバウンドの健全性を確認できるようになります。",
"mixedLegacy": "この設定には Observatory と Burst Observatory の両方が含まれています。Xray は単一のグローバルオブザーバを使用するため、この古い混在状態はサポートされません。バランサーを保存すると 1 つのオブザーバに正規化されます。",
@@ -1773,12 +1610,8 @@
"balancerRemoved": "バランサー {tag} — 削除(対象が残っていません)"
},
"balancer": {
"addBalancer": "負荷分散追加",
"editBalancer": "負荷分散編集",
"balancerStrategy": "戦略",
"balancerSelectors": "セレクター",
"tag": "タグ",
"tagDesc": "一意のタグ",
"tagDuplicate": "このタグは他のバランサーで使用されています",
"tagPlaceholder": "一意のバランサータグ",
"selector": "セレクター",
@@ -1790,7 +1623,6 @@
"tolerance": "許容範囲",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "balancerTagとoutboundTagは同時に使用できません。同時に使用された場合、outboundTagのみが有効になります。",
"costMatch": "タグパターン",
"costValue": "重み",
"costRegexp": "正規表現で一致",
@@ -1805,7 +1637,6 @@
"publicKey": "公開鍵",
"allowedIPs": "許可されたIP",
"endpoint": "エンドポイント",
"psk": "共有キー",
"domainStrategy": "ドメイン戦略"
},
"amneziawg": {
@@ -1843,10 +1674,7 @@
"i1Hint": "AmneziaWG 2.0専用。空欄で1.x互換の設定になります。"
},
"tun": {
"nameDesc": "TUN インターフェースの名前。デフォルトは 'xray0' です",
"mtuDesc": "最大伝送単位。データパケットの最大サイズ。デフォルトは 1500 です",
"userLevel": "ユーザーレベル",
"userLevelDesc": "このインバウンドを通じて確立されたすべての接続は、このユーザーレベルを使用します。デフォルトは 0 です"
"userLevel": "ユーザーレベル"
},
"nord": {
"accessToken": "Access token",
@@ -1931,7 +1759,6 @@
},
"fakedns": {
"add": "フェイクDNS追加",
"edit": "フェイクDNS編集",
"ipPool": "IPプールサブネット",
"poolSize": "プールサイズ"
},
@@ -2067,7 +1894,6 @@
"add": "追加",
"month": "月",
"months": "ヶ月",
"day": "日",
"days": "日間",
"hours": "時間",
"minutes": "分",
@@ -2106,7 +1932,6 @@
"userSaved": "✅ Telegramユーザーが保存されました。",
"loginSuccess": "✅ パネルに正常にログインしました。\r\n",
"loginFailed": "❗️ パネルのログインに失敗しました。\r\n",
"2faFailed": "2FAエラー",
"report": "🕰 定期報告:{{ .RunTime }}\r\n",
"datetime": "⏰ 日時:{{ .DateTime }}\r\n",
"hostname": "💻 ホスト: {{ .Hostname }}\r\n",
@@ -2139,7 +1964,6 @@
"download": "🔽 ダウンロード: ↓{{ .Download }}\r\n",
"total": "📊 合計: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
"TGUser": "👤 Telegramユーザー:{{ .TelegramID }}\r\n",
"exhaustedMsg": "🚨 消耗済みの {{ .Type }}:\r\n",
"exhaustedCount": "🚨 消耗済みの {{ .Type }} 数量:\r\n",
"onlinesCount": "🌐 オンラインクライアント:{{ .Count }}\r\n",
"disabled": "🛑 無効化:{{ .Disabled }}\r\n",
@@ -2148,16 +1972,10 @@
"refreshedOn": "\r\n📋🔄 更新時間:{{ .Time }}\r\n\r\n",
"yes": "✅ はい",
"no": "❌ いいえ",
"received_id": "🔑📥 IDが更新されました。",
"received_password": "🔑📥 パスワードが更新されました。",
"received_email": "📧📥 メールが更新されました。",
"received_comment": "💬📥 コメントが更新されました。",
"id_prompt": "🔑 デフォルトID: {{ .ClientId }}\n\nIDを入力してください。",
"pass_prompt": "🔑 デフォルトパスワード: {{ .ClientPassword }}\n\nパスワードを入力してください。",
"email_prompt": "📧 デフォルトメール: {{ .ClientEmail }}\n\nメールを入力してください。",
"comment_prompt": "💬 デフォルトコメント: {{ .ClientComment }}\n\nコメントを入力してください。",
"inbound_client_data_id": "🔄 インバウンド: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 メール: {{ .ClientEmail }}\n📊 トラフィック: {{ .ClientTraffic }}\n📅 有効期限: {{ .ClientExp }}\n🌐 IP制限: {{ .IpLimit }}\n💬 コメント: {{ .ClientComment }}\n\n今すぐこのクライアントをインバウンドに追加できます!",
"inbound_client_data_pass": "🔄 インバウンド: {{ .InboundRemark }}\n\n🔑 パスワード: {{ .ClientPass }}\n📧 メール: {{ .ClientEmail }}\n📊 トラフィック: {{ .ClientTraffic }}\n📅 有効期限: {{ .ClientExp }}\n🌐 IP制限: {{ .IpLimit }}\n💬 コメント: {{ .ClientComment }}\n\n今すぐこのクライアントをインバウンドに追加できます!",
"cancel": "❌ プロセスがキャンセルされました!\n\nいつでも /start で再開できます。 🔄",
"error_add_client": "⚠️ エラー:\n\n {{ .error }}",
"using_default_value": "わかりました、デフォルト値を使用します。 😊",
@@ -2174,8 +1992,6 @@
"eventXrayCrashError": "エラー: {{ .Error }}",
"eventNodeDown": "ノード {{ .Name }} がダウンしています",
"eventNodeUp": "ノード {{ .Name }} が復旧しました",
"eventCPUHigh": "CPU高負荷",
"eventCPUHighDetail": "CPU: {{ .Detail }}",
"eventLoginFallback": "{{ .Source }} からのログインに失敗しました",
"memoryThreshold": "メモリ使用率 {{ .Percent }}% がしきい値 {{ .Threshold }}% を超えました"
},
@@ -2216,11 +2032,8 @@
"submitDisable": "無効として送信 ☑️",
"submitEnable": "有効として送信 ✅",
"use_default": "🏷️ デフォルトを使用",
"change_id": "⚙️🔑 ID",
"change_password": "⚙️🔑 パスワード",
"change_email": "⚙️📧 メール",
"change_comment": "⚙️💬 コメント",
"change_flow": "⚙️🚦 Flow",
"ResetAllTraffics": "すべてのトラフィックをリセット",
"SortedTrafficUsageReport": "ソートされたトラフィック使用レポート"
},
@@ -2249,31 +2062,16 @@
}
},
"email": {
"subjectOutboundDown": "アウトバウンド {{ .Tag }} がダウンしています",
"subjectOutboundUp": "アウトバウンド {{ .Tag }} が復旧しました",
"subjectXrayCrash": "Xrayがクラッシュしました",
"subjectCPUHigh": "CPU高負荷",
"subjectLoginSuccess": "ログイン成功",
"subjectLoginFailed": "ログイン失敗",
"titleOutboundDown": "アウトバウンド ダウン",
"titleOutboundUp": "アウトバウンド 復旧",
"titleXrayCrash": "Xrayがクラッシュしました",
"titleCPUHigh": "CPU高負荷",
"titleLoginSuccess": "ログイン成功",
"titleLoginFailed": "ログイン失敗",
"labelStatus": "ステータス",
"labelOutbound": "アウトバウンド",
"labelNode": "ノード",
"labelError": "エラー",
"labelDelay": "遅延",
"labelDetail": "詳細",
"labelUsername": "ユーザー名",
"labelIP": "IP",
"labelReason": "理由",
"labelSource": "送信元",
"labelTime": "時刻",
"statusCrashed": "クラッシュ",
"statusRunning": "実行中",
"statusHigh": "高負荷",
"statusSuccess": "成功",
"statusFailed": "失敗",
+25 -227
View File
@@ -48,7 +48,6 @@
"copySuccess": "Copiado com Sucesso",
"sure": "Certo",
"encryption": "Criptografia",
"useIPv4ForHost": "Usar IPv4 para o host",
"transmission": "Transmissão",
"host": "Host",
"path": "Caminho",
@@ -74,18 +73,9 @@
"twoFactorCode": "Código",
"remained": "Restante",
"security": "Segurança",
"secAlertTitle": "Alerta de Segurança",
"secAlertSsl": "Esta conexão não é segura. Evite inserir informações confidenciais até que o TLS seja ativado para proteção de dados.",
"secAlertConf": "Algumas configurações estão vulneráveis a ataques. Recomenda-se reforçar os protocolos de segurança para evitar possíveis violações.",
"secAlertSSL": "O painel não possui uma conexão segura. Instale o certificado TLS para proteção de dados.",
"secAlertPanelPort": "A porta padrão do painel é vulnerável. Configure uma porta aleatória ou específica.",
"secAlertPanelURI": "O caminho URI padrão do painel não é seguro. Configure um caminho URI complexo.",
"secAlertSubURI": "O caminho URI padrão de inscrição não é seguro. Configure um caminho URI complexo.",
"secAlertSubJsonURI": "O caminho URI JSON de inscrição padrão não é seguro. Configure um caminho URI complexo.",
"emptyDnsDesc": "Nenhum servidor DNS adicionado.",
"emptyFakeDnsDesc": "Nenhum servidor Fake DNS adicionado.",
"emptyBalancersDesc": "Nenhum balanceador adicionado.",
"emptyReverseDesc": "Nenhum proxy reverso adicionado.",
"somethingWentWrong": "Algo deu errado",
"subscription": {
"title": "Informações da assinatura",
@@ -106,8 +96,6 @@
},
"menu": {
"theme": "Tema",
"dark": "Escuro",
"ultraDark": "Ultra Escuro",
"dashboard": "Visão Geral",
"inbounds": "Entradas",
"clients": "Clientes",
@@ -118,12 +106,12 @@
"routing": "Roteamento",
"outbounds": "Saídas",
"apiDocs": "Documentação da API",
"logout": "Sair",
"link": "Gerenciar",
"donate": "Doar",
"hosts": "Hosts",
"docs": "Documentação",
"openMenu": "Abrir menu"
"openMenu": "Abrir menu",
"pinSidebar": "Fixar barra lateral",
"unpinSidebar": "Desafixar barra lateral"
},
"pages": {
"login": {
@@ -139,20 +127,15 @@
}
},
"index": {
"title": "Visão Geral",
"cpu": "CPU",
"logicalProcessors": "Processadores lógicos",
"frequency": "Frequência",
"swap": "Swap",
"storage": "Armazenamento",
"memory": "Memória",
"threads": "Threads",
"xrayStatus": "Xray",
"stopXray": "Parar",
"restartXray": "Reiniciar",
"xraySwitch": "Versão",
"xrayUpdates": "Atualizações do Xray",
"xraySwitchClick": "Escolha a versão para a qual deseja alternar.",
"xraySwitchClickDesk": "Escolha com cuidado, pois versões mais antigas podem não ser compatíveis com as configurações atuais.",
"updatePanel": "Atualizar painel",
"panelUpdateDesc": "Isso atualizará o 3X-UI para a versão mais recente e reiniciará o serviço do painel.",
@@ -164,13 +147,10 @@
"currentCommit": "Commit atual",
"latestCommit": "Último commit",
"updateChannelChanged": "Canal de atualização alterado",
"upToDate": "Atualizado",
"xrayStatusUnknown": "Desconhecido",
"xrayStatusRunning": "Em execução",
"xrayStatusStop": "Parado",
"xrayStatusError": "Erro",
"xrayErrorPopoverTitle": "Ocorreu um erro ao executar o Xray",
"operationHours": "Tempo de Atividade",
"systemHistoryTitle": "Histórico do Sistema",
"historyTitleCpu": "Uso da CPU",
"historyTitleMem": "Uso de Memória",
@@ -188,7 +168,6 @@
"historyTabLoad": "Carga",
"historyTabConnections": "Conexões",
"historyTabDiskUsage": "Uso de Disco",
"charts": "Gráficos",
"xrayMetricsTitle": "Métricas do Xray",
"xrayTitleHeap": "Memória Heap Alocada",
"xrayTitleSys": "Memória Reservada do SO",
@@ -211,25 +190,19 @@
"xrayObservatoryDead": "Inativo",
"xrayObservatoryLastSeen": "Visto pela última vez",
"xrayObservatoryLastTry": "Última tentativa",
"trendLast2Min": "Últimos 2 minutos",
"systemLoad": "Carga do Sistema",
"systemLoadDesc": "Média de carga do sistema nos últimos 1, 5 e 15 minutos",
"connectionCount": "Estatísticas de Conexão",
"ipAddresses": "Endereços IP",
"toggleIpVisibility": "Alternar visibilidade do IP",
"overallSpeed": "Velocidade geral",
"upload": "Upload",
"download": "Download",
"totalData": "Dados totais",
"sent": "Enviado",
"received": "Recebido",
"documentation": "Documentação",
"xraySwitchVersionDialog": "Você realmente deseja alterar a versão do Xray?",
"xraySwitchVersionDialogDesc": "Isso mudará a versão do Xray para #version#.",
"xraySwitchVersionPopover": "Xray atualizado com sucesso",
"panelUpdateDialog": "Deseja realmente atualizar o painel?",
"panelUpdateDialogDesc": "Isso atualizará o 3X-UI para #version# e reiniciará o serviço do painel.",
"panelUpdateCheckPopover": "Falha na verificação de atualização do painel",
"panelUpdateStartedPopover": "Atualização do painel iniciada",
"panelUpdateFailedTitle": "Falha ao atualizar o painel",
"panelUpdateFailedDesc": "A atualização não foi concluída com sucesso. Verifique os logs do servidor ou execute 'x-ui update' na linha de comando.",
@@ -258,7 +231,6 @@
"accessLogs": "Logs de acesso",
"autoUpdate": "Atualização automática",
"config": "Configuração",
"backup": "Backup",
"backupTitle": "Backup & Restauração",
"exportDatabase": "Backup",
"exportDatabaseDesc": "Clique para baixar um arquivo .db contendo um backup do seu banco de dados atual para o seu dispositivo. O mesmo arquivo também pode ser restaurado em um painel executando PostgreSQL.",
@@ -273,10 +245,20 @@
"exportDatabasePgDesc": "Clique para baixar um dump do PostgreSQL (.dump) do seu banco de dados atual para o seu dispositivo.",
"importDatabasePgDesc": "Clique para selecionar e enviar um backup do PostgreSQL (.dump), um banco de dados SQLite (.db) ou um dump de migração do SQLite para restaurar seu banco de dados. Isso substitui todos os dados atuais.",
"migrationDownload": "Baixar migração",
"migrationDownloadPgDesc": "Clique para baixar um banco de dados SQLite .db criado a partir dos seus dados do PostgreSQL, pronto para executar este painel no SQLite."
"migrationDownloadPgDesc": "Clique para baixar um banco de dados SQLite .db criado a partir dos seus dados do PostgreSQL, pronto para executar este painel no SQLite.",
"avg": "média",
"peak": "pico",
"free": "livre",
"openSockets": "sockets abertos",
"throughputSub": "Total da interface",
"avgWindow": "Média do período",
"healthWarm": "{list} — um pouco alto",
"healthCritical": "{list} — crítico",
"panel": "Painel",
"threads": "Threads",
"uptime": "Tempo ativo"
},
"inbounds": {
"title": "Entradas",
"totalDownUp": "Total Enviado/Recebido",
"totalUsage": "Uso Total",
"inboundCount": "Total de Inbounds",
@@ -288,21 +270,11 @@
"localPanel": "Painel local",
"fallbacks": {
"title": "Fallbacks",
"help": "Quando uma conexão neste inbound não corresponde a nenhum cliente, redirecione-a para outro lugar. Escolha um inbound filho abaixo para preencher automaticamente os campos de roteamento (SNI / ALPN / Path / xver) a partir do transporte dele, ou deixe o seletor vazio e defina Dest diretamente (ex.: 8080 ou 127.0.0.1:8080) para rotear para um servidor externo como o Nginx. Cada inbound filho deve escutar em 127.0.0.1 com security=none.",
"empty": "Ainda sem fallbacks",
"add": "Adicionar fallback",
"pickInbound": "Escolha um inbound",
"matchAny": "qualquer",
"destPlaceholder": "automático (listen:porta do filho)",
"rederive": "Preencher a partir do filho",
"rederived": "Preenchido a partir do filho",
"editAdvanced": "Editar campos de roteamento",
"hideAdvanced": "Ocultar avançado",
"quickAddAll": "Adicionar todos os elegíveis",
"quickAdded": "{n} fallback(s) adicionado(s)",
"quickAddedNone": "Nenhum inbound novo elegível para adicionar",
"routesWhen": "Roteia quando",
"defaultCatchAll": "Padrão — captura qualquer outra coisa",
"needsTls": "Os fallbacks ficam disponíveis após selecionar TLS ou Reality na aba Segurança (apenas VLESS/Trojan sobre RAW)."
},
"protocol": "Protocolo",
@@ -310,8 +282,6 @@
"portMap": "Mapeamento de portas",
"traffic": "Tráfego",
"speed": "Velocidade",
"details": "Detalhes",
"transportConfig": "Transporte",
"expireDate": "Duração",
"createdAt": "Criado",
"updatedAt": "Atualizado",
@@ -319,8 +289,6 @@
"addInbound": "Adicionar Inbound",
"generalActions": "Ações Gerais",
"modifyInbound": "Modificar Inbound",
"deleteInbound": "Excluir Inbound",
"deleteInboundContent": "Tem certeza de que deseja excluir o inbound?",
"deleteConfirmTitle": "Excluir o inbound \"{remark}\"?",
"deleteConfirmContent": "Isto remove o inbound e todos os seus clientes. Não é possível desfazer.",
"resetConfirmTitle": "Redefinir o tráfego de \"{remark}\"?",
@@ -364,8 +332,6 @@
"exportAllLinksFileName": "Todas-as-entradas",
"exportAllSubsFileName": "Todas-as-entradas-Subs",
"inboundJsonTitle": "JSON da entrada",
"deleteClient": "Excluir Cliente",
"deleteClientContent": "Tem certeza de que deseja excluir o cliente?",
"resetTrafficContent": "Tem certeza de que deseja redefinir o tráfego?",
"copyLink": "Copiar URL",
"address": "Endereço",
@@ -376,36 +342,19 @@
"meansNoLimit": "= Ilimitado. (unidade: GB)",
"totalFlow": "Fluxo Total",
"leaveBlankToNeverExpire": "Deixe em branco para nunca expirar",
"noRecommendKeepDefault": "Recomenda-se manter o padrão",
"certificatePath": "Caminho",
"certificateContent": "Conteúdo",
"publicKey": "Chave Pública",
"privatekey": "Chave Privada",
"clickOnQRcode": "Clique no Código QR para Copiar",
"client": "Cliente",
"export": "Exportar Todos os URLs",
"clone": "Clonar",
"cloneInbound": "Clonar",
"cloneInboundContent": "Todas as configurações deste inbound, exceto Porta, IP de Escuta e Clientes, serão aplicadas ao clone.",
"cloneInboundOk": "Clonar",
"resetAllTraffic": "Redefinir Tráfego de Todos os Inbounds",
"resetAllTrafficTitle": "Redefinir Tráfego de Todos os Inbounds",
"resetAllTrafficContent": "Tem certeza de que deseja redefinir o tráfego de todos os inbounds?",
"resetInboundClientTraffics": "Redefinir Tráfego dos Clientes",
"resetInboundClientTrafficTitle": "Redefinir Tráfego dos Clientes",
"resetInboundClientTrafficContent": "Tem certeza de que deseja redefinir o tráfego dos clientes deste inbound?",
"resetAllClientTraffics": "Redefinir Tráfego de Todos os Clientes",
"resetAllClientTrafficTitle": "Redefinir Tráfego de Todos os Clientes",
"resetAllClientTrafficContent": "Tem certeza de que deseja redefinir o tráfego de todos os clientes?",
"delDepletedClients": "Excluir Clientes Esgotados",
"delDepletedClientsTitle": "Excluir Clientes Esgotados",
"delDepletedClientsContent": "Tem certeza de que deseja excluir todos os clientes esgotados?",
"email": "Email",
"emailDesc": "Por favor, forneça um endereço de e-mail único.",
"IPLimit": "Limite de IP",
"IPLimitDesc": "Desativa o inbound se o número ultrapassar o valor definido. (0 = desativar)",
"IPLimitlog": "Log de IP",
"IPLimitlogDesc": "O histórico de IPs. (para ativar o inbound após a desativação, limpe o log)",
"IPLimitlogclear": "Limpar o Log",
"setDefaultCert": "Definir Certificado pelo Painel",
"setDefaultCertEmpty": "Nenhum certificado configurado para o painel. Configure um em Configurações primeiro.",
@@ -438,20 +387,15 @@
"sniffing": "Sniffing",
"sniffingHelp": "Wrapper do bloco sniffing do Xray:",
"stream": "Stream",
"streamHelp": "Wrapper do bloco stream do Xray:",
"jsonErrorPrefix": "JSON avançado"
"streamHelp": "Wrapper do bloco stream do Xray:"
},
"telegramDesc": "Por favor, forneça o ID do Chat do Telegram. (use o comando '/id' no bot) ou ({'@'}userinfobot)",
"subscriptionDesc": "Para encontrar seu URL de assinatura, navegue até 'Detalhes'. Além disso, você pode usar o mesmo nome para vários clientes.",
"subSortIndex": "Ordem sub",
"same": "Igual",
"inboundInfo": "Informações do Inbound",
"exportInbound": "Exportar Inbound",
"import": "Importar",
"importInbound": "Importar um Inbound",
"periodicTrafficResetTitle": "Reset de Tráfego",
"periodicTrafficResetDesc": "Reinicia automaticamente o contador de tráfego em intervalos especificados",
"lastReset": "Último Reset",
"periodicTrafficResetDay": "Dia da redefinição mensal",
"periodicTrafficReset": {
"never": "Nunca",
"daily": "Diariamente",
@@ -463,7 +407,6 @@
"obtain": "Obter",
"updateSuccess": "A atualização foi bem-sucedida",
"logCleanSuccess": "O log foi limpo",
"inboundsUpdateSuccess": "Entradas atualizadas com sucesso",
"inboundUpdateSuccess": "Entrada atualizada com sucesso",
"inboundCreateSuccess": "Entrada criada com sucesso",
"bulkDeleted": "{count} inbounds excluídos",
@@ -473,7 +416,6 @@
"inboundClientDeleteSuccess": "Cliente de entrada excluído",
"inboundClientUpdateSuccess": "Cliente de entrada atualizado",
"savedNodeOfflineWillSync": "Salvo localmente. Um nó de apoio está offline ou desativado — a alteração será sincronizada assim que reconectar.",
"delDepletedClientsSuccess": "Todos os clientes esgotados foram excluídos",
"resetAllClientTrafficSuccess": "Todo o tráfego do cliente foi reiniciado",
"resetAllTrafficSuccess": "Todo o tráfego foi reiniciado",
"resetInboundClientTrafficSuccess": "O tráfego foi reiniciado",
@@ -657,6 +599,10 @@
"maxTimeDiff": "Máx. diferença de tempo (ms)",
"minClientVer": "Mín. versão cliente",
"maxClientVer": "Máx. versão cliente",
"minClientVerHint": "Vazio não significa sem restrição: o Xray-core aplica o mínimo embutido da build do núcleo em uso (26.3.27 nas versões atuais) e rejeita clientes que reportam uma versão mais antiga — incluindo núcleos de terceiros como Mihomo e sing-box. Definir 1.0.0 os aceita, ao custo de admitir impressões digitais TLS desatualizadas.",
"maxClientVerHint": "Vazio significa sem limite superior. Se definido, não deve ser menor que o mínimo efetivo — a versão mínima do cliente ou, se aquele campo estiver vazio, o mínimo embutido do Xray-core — ou todos os clientes serão rejeitados.",
"clientVerInvalid": "A versão do cliente deve ter até três números separados por pontos, cada um 0-255 (ex.: 26.3.27)",
"maxClientVerBelowMin": "A versão máxima do cliente não deve ser menor que a versão mínima",
"shortIds": "Short IDs",
"realityTargetHint": "Obrigatório. Deve incluir uma porta (ex.: example.com:443). Sem porta, o Xray-core não inicia.",
"realityTargetRequired": "O alvo REALITY é obrigatório",
@@ -712,23 +658,6 @@
"peerNumber": "Peer {n}",
"peerNumberConfig": "Config Peer {n}"
},
"stream": {
"general": {
"request": "Requisição",
"response": "Resposta",
"name": "Nome",
"value": "Valor"
},
"tcp": {
"version": "Versão",
"method": "Método",
"path": "Caminho",
"status": "Status",
"statusDescription": "Descrição do Status",
"requestHeader": "Cabeçalho da Requisição",
"responseHeader": "Cabeçalho da Resposta"
}
},
"sniffingDestOverride": "Substituição de destino"
},
"clients": {
@@ -742,24 +671,9 @@
"addExternalSubscription": "Adicionar assinatura externa",
"noExternalLinks": "Ainda não há links externos.",
"noExternalSubscriptions": "Ainda não há assinaturas externas.",
"add": "Adicionar cliente",
"edit": "Editar cliente",
"submitAdd": "Adicionar cliente",
"submitEdit": "Salvar alterações",
"clientCount": "Número de clientes",
"bulk": "Adicionar em lote",
"copyFromInbound": "Copiar clientes do inbound",
"copyToInbound": "Copiar clientes para",
"copySelected": "Copiar selecionados",
"copySource": "Origem",
"copyEmailPreview": "Prévia do e-mail resultante",
"copySelectSourceFirst": "Selecione primeiro um inbound de origem.",
"copyResult": "Resultado da cópia",
"copyResultSuccess": "Copiado com sucesso",
"copyResultNone": "Nada a copiar: nenhum cliente selecionado ou a origem está vazia",
"copyResultErrors": "Erros de cópia",
"copyFlowLabel": "Flow para os novos clientes (VLESS)",
"copyFlowHint": "Aplicado a todos os clientes copiados. Deixe em branco para ignorar.",
"selectAll": "Selecionar tudo",
"clearAll": "Limpar tudo",
"method": "Método",
@@ -770,7 +684,6 @@
"postfix": "Sufixo",
"delayedStart": "Iniciar após o primeiro uso",
"expireDays": "Duração (dias)",
"days": "Dia(s)",
"renew": "Renovação automática",
"renewDesc": "Renovação automática após a expiração. (0 = desativar) (unidade: dia)",
"renewDays": "Renovação automática (dias)",
@@ -793,7 +706,6 @@
"sortExpiringSoonest": "Expira em breve",
"has": "Tem",
"hasNot": "Não tem",
"title": "Clientes",
"actions": "Ações",
"totalGB": "Limite de tráfego (GB)",
"totalGBDesc": "Cota de dados para este cliente. 0 = ilimitado.",
@@ -821,8 +733,6 @@
"addClient": "Adicionar cliente",
"qrCode": "Código QR",
"clientInfo": "Informações do cliente",
"delete": "Excluir",
"reset": "Redefinir tráfego",
"editClient": "Editar cliente",
"client": "Cliente",
"enabled": "Habilitado",
@@ -836,13 +746,11 @@
"noLinks": "Sem links compartilháveis — associe primeiro este cliente a um inbound compatível com o protocolo.",
"link": "Link",
"resetNotPossible": "Associe primeiro este cliente a um inbound.",
"general": "Geral",
"resetAllTraffics": "Redefinir o tráfego de todos os clientes",
"resetAllTrafficsTitle": "Redefinir o tráfego de todos os clientes?",
"resetAllTrafficsContent": "Os contadores de envio/recebimento de cada cliente vão a zero. Cota e expiração não são afetadas. Não é possível desfazer.",
"deleteConfirmTitle": "Excluir o cliente {email}?",
"deleteConfirmContent": "Isto remove o cliente de cada inbound associado e descarta o registro de tráfego. Não é possível desfazer.",
"deleteSelected": "Excluir ({count})",
"adjustSelected": "Ajustar ({count})",
"subLinksSelected": "Links sub ({count})",
"addToGroupTitle": "Adicionar {count} cliente(s) a um grupo",
@@ -864,12 +772,10 @@
"bulkDisableConfirmTitle": "Desativar {count} clientes?",
"bulkDisableConfirmContent": "Desativa cada cliente selecionado em todos os inbounds associados. Eles perdem o acesso imediatamente, mas seus registros e tráfego são mantidos.",
"selectedCount": "{count} selecionado(s)",
"attachSelected": "Associar ({count})",
"attachToInboundsTitle": "Associar {count} cliente(s) a entrada(s)",
"attachToInboundsDesc": "Associa os {count} cliente(s) selecionados (mesmo UUID/senha e tráfego compartilhado) às entradas escolhidas. Mantêm suas associações existentes.",
"attachToInboundsTargets": "Entradas de destino",
"attachToInboundsNoTargets": "Não há entradas multiusuário disponíveis para associação.",
"detachSelected": "Desassociar ({count})",
"detach": "Desassociar",
"detachFromInboundsTitle": "Desassociar {count} cliente(s) de entrada(s)",
"detachFromInboundsDesc": "Remove os {count} cliente(s) selecionados das entradas escolhidas. Pares onde o cliente não estava associado são ignorados silenciosamente. Os registros dos clientes são mantidos (use Delete para remover completamente).",
@@ -931,8 +837,6 @@
"reverseTagPlaceholder": "Reverse tag opcional",
"telegramId": "ID de usuário do Telegram",
"telegramIdPlaceholder": "ID numérico de usuário do Telegram (0 = nenhum)",
"created": "Criado",
"updated": "Atualizado",
"ipLimit": "Limite de IP",
"toasts": {
"deleted": "Cliente excluído",
@@ -955,7 +859,6 @@
}
},
"groups": {
"title": "Grupos",
"name": "Nome",
"clientCount": "Clientes",
"totalGroups": "Total de grupos",
@@ -997,7 +900,6 @@
"removeFromGroupResult": "Removidos {count} cliente(s) de {name}."
},
"nodes": {
"title": "Nós",
"addNode": "Adicionar nó",
"editNode": "Editar nó",
"totalNodes": "Total de nós",
@@ -1016,8 +918,6 @@
"apiTokenPlaceholder": "Token da página de Configurações do painel remoto",
"apiTokenHint": "O painel remoto exibe o token da API em Autenticação → Token da API.",
"apiTokenKeepHint": "Deixe em branco para manter o token atual",
"regenerate": "Regenerar token",
"regenerateConfirm": "Regenerar invalida o token atual. Qualquer painel central que o utilize perderá acesso até ser atualizado. Continuar?",
"allowPrivateAddress": "Permitir endereço privado",
"allowPrivateAddressHint": "Ativar apenas para nós em uma rede privada ou VPN.",
"outboundTag": "Outbound de conexão",
@@ -1049,7 +949,6 @@
"updatePanel": "Atualizar painel",
"updateSelected": "Atualizar selecionados ({count})",
"updateAvailable": "Atualização disponível",
"upToDate": "Atualizado",
"updateConfirmTitle": "Atualizar {count} nó(s) para a versão mais recente?",
"updateConfirmContent": "Cada nó selecionado baixa a versão mais recente e reinicia nela. Apenas nós ativos e online são atualizados.",
"updateDevChannel": "Atualizar para o canal de desenvolvimento (último commit)",
@@ -1113,6 +1012,7 @@
"pinFetchFailed": "Não foi possível obter o certificado"
},
"settings": {
"defaultTag": "Padrão",
"title": "Configurações do Painel",
"save": "Salvar",
"infoDesc": "Toda alteração feita aqui precisa ser salva. Reinicie o painel para aplicar as alterações.",
@@ -1441,6 +1341,8 @@
"eventMemoryHigh": "Uso de memória alto (%)",
"remarkTemplate": "Modelo de Observação",
"remarkTemplateDesc": "Quando definido, isto substitui o modelo de observação de cada link de assinatura — escreva seu próprio formato com os tokens de variáveis (use o botão para inseri-los). Deixe vazio para usar o modelo acima.",
"subShowIdentityOnAllLinks": "Mostrar identidade em todos os links",
"subShowIdentityOnAllLinksDesc": "Quando ativado, {{EMAIL}} e {{USERNAME}} permanecem na observação de cada link do corpo da assinatura. Tokens de uso continuam só no primeiro link.",
"validation": {
"pathLeadingSlash": "O caminho deve começar com /"
},
@@ -1448,7 +1350,6 @@
"secretClearUndo": "Desfazer limpeza"
},
"xray": {
"title": "Configurações Xray",
"importRules": "Importar regras",
"exportRules": "Exportar regras",
"importOutbounds": "Importar saídas",
@@ -1458,11 +1359,7 @@
"metricsListenDesc": "Expõe as métricas no estilo Prometheus do Xray neste endereço:porta (por exemplo, 127.0.0.1:11111). Deixe vazio para desativar. Vincule ao localhost e use um proxy reverso — ele não é autenticado.",
"metricsTag": "Tag de métricas",
"save": "Salvar",
"restart": "Reiniciar Xray",
"restartSuccess": "Xray foi reiniciado com sucesso",
"restartOutputTitle": "Saída do reinício do Xray",
"restartConfirmTitle": "Reiniciar xray?",
"restartConfirmContent": "Recarrega o serviço xray com a configuração salva.",
"stopSuccess": "Xray foi interrompido com sucesso",
"restartError": "Ocorreu um erro ao reiniciar o Xray.",
"stopError": "Ocorreu um erro ao parar o Xray.",
@@ -1472,7 +1369,6 @@
"generalConfigsDesc": "Essas opções determinam ajustes gerais.",
"logConfigs": "Log",
"logConfigsDesc": "Os logs podem afetar a eficiência do servidor. É recomendável habilitá-los com sabedoria apenas se necessário.",
"blockConfigsDesc": "Essas opções bloqueiam tráfego com base em protocolos e sites específicos solicitados.",
"basicRouting": "Roteamento Básico",
"blockConnectionsConfigsDesc": "Essas opções bloquearão o tráfego com base no país solicitado.",
"directConnectionsConfigsDesc": "Uma conexão direta garante que o tráfego específico não seja roteado por outro servidor.",
@@ -1482,10 +1378,6 @@
"directdomains": "Domínios Diretos",
"ipv4Routing": "Roteamento IPv4",
"ipv4RoutingDesc": "Essas opções roteam o tráfego para um destino específico via IPv4.",
"warpRouting": "Roteamento WARP",
"warpRoutingDesc": "Essas opções roteam o tráfego para um destino específico via WARP.",
"nordRouting": "Roteamento NordVPN",
"nordRoutingDesc": "Essas opções roteiam o tráfego para um destino específico via NordVPN.",
"Template": "Modelo de Configuração Avançada do Xray",
"TemplateDesc": "O arquivo final de configuração do Xray será gerado com base neste modelo.",
"FreedomStrategy": "Estratégia do Protocolo Freedom",
@@ -1499,10 +1391,7 @@
"outboundTestUrlDesc": "URL usada ao testar conectividade do outbound",
"Torrent": "Bloquear Protocolo BitTorrent",
"Inbounds": "Entradas",
"InboundsDesc": "Aceitar clientes específicos.",
"Outbounds": "Saídas",
"OutboundSubscriptions": "Assinaturas de Saída",
"OutboundSubscriptionsDesc": "Importe saídas a partir de URLs de assinatura remotas (vmess/vless/trojan/ss/...). As tags são mantidas estáveis para uso em balanceadores e regras de roteamento. As atualizações são automáticas.",
"Balancers": "Balanceadores",
"balancerTagRequired": "A tag é obrigatória",
"balancerSelectorRequired": "Selecione pelo menos uma saída",
@@ -1521,9 +1410,7 @@
"routeTesterMatchedOutbound": "Saída correspondente",
"routeTesterViaBalancer": "via balanceador",
"routeTesterDefaultOutbound": "Nenhuma regra de roteamento correspondeu — o tráfego vai para a saída padrão (primeira).",
"OutboundsDesc": "Definir o caminho de saída do tráfego.",
"Routings": "Regras de Roteamento",
"RoutingsDesc": "A prioridade de cada regra é importante!",
"completeTemplate": "Tudo",
"logLevel": "Nível de Log",
"logLevelDesc": "O nível de log para erros, indicando a informação que precisa ser registrada.",
@@ -1537,13 +1424,9 @@
"maskAddressDesc": "Máscara de endereço IP, quando ativado, substitui automaticamente o endereço IP que aparece no log.",
"statistics": "Estatísticas",
"statsInboundUplink": "Estatísticas de Upload de Entrada",
"statsInboundUplinkDesc": "Habilita a coleta de estatísticas para o tráfego de upload de todos os proxies de entrada.",
"statsInboundDownlink": "Estatísticas de Download de Entrada",
"statsInboundDownlinkDesc": "Habilita a coleta de estatísticas para o tráfego de download de todos os proxies de entrada.",
"statsOutboundUplink": "Estatísticas de Upload de Saída",
"statsOutboundUplinkDesc": "Habilita a coleta de estatísticas para o tráfego de upload de todos os proxies de saída.",
"statsOutboundDownlink": "Estatísticas de Download de Saída",
"statsOutboundDownlinkDesc": "Habilita a coleta de estatísticas para o tráfego de download de todos os proxies de saída.",
"connectionLimits": "Limites de conexão",
"connectionLimitsDesc": "Políticas em nível de conexão para o nível de usuário 0. Deixe um campo vazio para usar o padrão do Xray.",
"connIdle": "Tempo limite de inatividade",
@@ -1553,18 +1436,10 @@
"bufferSizePlaceholder": "automático",
"seconds": "segundos",
"rules": {
"first": "Primeiro",
"last": "Último",
"up": "Cima",
"down": "Baixo",
"source": "Fonte",
"dest": "Destino",
"inbound": "Entrada",
"outbound": "Saída",
"balancer": "Balanceador",
"info": "Info",
"add": "Adicionar Regra",
"edit": "Editar Regra",
"useComma": "Itens separados por vírgula"
},
"routing": {
@@ -1605,7 +1480,6 @@
"ruleN": "Regra {n}",
"action": "Ação",
"redirect": "Redirect",
"fragment": "Fragment",
"finalRules": "Regras finais",
"overrideXrayPrivateIp": "Sobrescrever o bloqueio de IP privado padrão do Xray",
"blockDelay": "Atraso do bloqueio (ms)",
@@ -1631,43 +1505,17 @@
"keepAliveInterval": "Intervalo keep alive",
"markFwmark": "Mark (fwmark)",
"interface": "Interface",
"ipv6Only": "Apenas IPv6",
"acceptProxyProtocol": "Aceitar proxy protocol",
"proxyProtocol": "Proxy protocol",
"tcpUserTimeoutMs": "TCP user timeout (ms)",
"tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
},
"outbound": {
"addOutbound": "Adicionar Saída",
"addReverse": "Adicionar Reverso",
"editOutbound": "Editar Saída",
"editReverse": "Editar Reverso",
"reverseTag": "Tag de Reverso",
"reverseTagDesc": "Tag de saída do proxy reverso simples VLESS. Deixe vazio para desabilitar.",
"reverseTagPlaceholder": "tag de saída (vazio para desabilitar)",
"tag": "Tag",
"tagDesc": "Tag Única",
"address": "Endereço",
"egress": "Egress",
"egressHint": "Run an HTTP test to show egress IP and country.",
"reverse": "Reverso",
"domain": "Domínio",
"type": "Tipo",
"bridge": "Bridge",
"portal": "Portal",
"link": "Link",
"intercon": "Interconexão",
"settings": "Configurações",
"accountInfo": "Informações da Conta",
"outboundStatus": "Status de Saída",
"sendThrough": "Enviar Através de",
"targetStrategy": "Estratégia de destino",
"test": "Testar",
"testResult": "Resultado do teste",
"testing": "Testando conexão...",
"testSuccess": "Teste bem-sucedido",
"testFailed": "Teste falhou",
"testError": "Falha ao testar saída",
"modeRealDelay": "Latência real",
"testModeTooltip": "TCP: sondagem rápida apenas de dial. HTTP: requisição completa pelo xray. Latência real: tempo total incluindo o estabelecimento da conexão.",
"testAll": "Testar todos",
@@ -1675,14 +1523,10 @@
"breakdownConnect": "Conexão do proxy",
"breakdownTls": "TLS via saída",
"breakdownTtfb": "Primeiro byte",
"nordvpn": "NordVPN",
"accessToken": "Token de Acesso",
"country": "País",
"server": "Servidor",
"city": "Cidade",
"allCities": "Todas as Cidades",
"privateKey": "Chave Privada",
"load": "Carga",
"moveToTop": "Mover para o topo"
},
"outboundSub": {
@@ -1712,16 +1556,11 @@
"active": "Assinaturas ativas",
"empty": "Nenhuma assinatura ainda. Adicione uma acima.",
"colRemark": "Observação",
"colPrefix": "Prefixo",
"colInterval": "Intervalo",
"colLastFetch": "Última busca",
"colEnabled": "Ativado",
"auto": "auto",
"never": "nunca",
"yes": "Sim",
"no": "Não",
"refreshNow": "Atualizar agora",
"lastError": "Último erro",
"deleteConfirm": "Excluir esta assinatura?",
"restartHint": "Após adicionar ou atualizar, reinicie o Xray (ou aguarde o próximo recarregamento automático) para ativar as saídas.",
"fromSubsTitle": "De assinaturas de saída (somente leitura)",
@@ -1738,8 +1577,6 @@
"tabBalancerSettings": "Configurações do balanceador",
"tabObservatory": "Observatório",
"observatory": {
"title": "Observatório",
"burstTitle": "Observatório Burst",
"autoManaged": "Os observadores são gerenciados automaticamente a partir dos seus balanceadores. Ajuste abaixo como eles sondam; as saídas monitoradas seguem os seletores do balanceador.",
"emptyHint": "Nenhum observador de conexão ativo. Um é adicionado automaticamente ao criar um balanceador Least Ping ou Least Load — ou um balanceador Random / Round-robin com fallback — para que balanceadores com observador possam verificar a saúde das saídas antes de escolher um destino.",
"mixedLegacy": "Esta configuração contém Observatory e Burst Observatory ao mesmo tempo. O Xray usa um único observador global, então esse estado misto legado não é suportado; ao salvar os balanceadores ele será normalizado para um único observador.",
@@ -1773,12 +1610,8 @@
"balancerRemoved": "Balanceador {tag} — removido (sem destinos restantes)"
},
"balancer": {
"addBalancer": "Adicionar Balanceador",
"editBalancer": "Editar Balanceador",
"balancerStrategy": "Estratégia",
"balancerSelectors": "Seletores",
"tag": "Tag",
"tagDesc": "Tag Única",
"tagDuplicate": "Tag já usada por outro balanceador",
"tagPlaceholder": "tag única do balanceador",
"selector": "Seletor",
@@ -1790,7 +1623,6 @@
"tolerance": "Tolerância",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "Não é possível usar balancerTag e outboundTag ao mesmo tempo. Se usados simultaneamente, apenas outboundTag funcionará.",
"costMatch": "Padrão de tag",
"costValue": "Peso",
"costRegexp": "Correspondência por expressão regular",
@@ -1805,7 +1637,6 @@
"publicKey": "Chave Pública",
"allowedIPs": "IPs Permitidos",
"endpoint": "Ponto Final",
"psk": "Chave Pré-Compartilhada",
"domainStrategy": "Estratégia de Domínio"
},
"amneziawg": {
@@ -1843,10 +1674,7 @@
"i1Hint": "Somente para AmneziaWG 2.0. Deixe vazio para compatibilidade com 1.x."
},
"tun": {
"nameDesc": "O nome da interface TUN. O padrão é 'xray0'",
"mtuDesc": "Unidade Máxima de Transmissão. O tamanho máximo dos pacotes de dados. O padrão é 1500",
"userLevel": "Nível do Usuário",
"userLevelDesc": "Todas as conexões feitas através deste inbound usarão este nível de usuário. O padrão é 0"
"userLevel": "Nível do Usuário"
},
"nord": {
"accessToken": "Access token",
@@ -1931,7 +1759,6 @@
},
"fakedns": {
"add": "Adicionar Fake DNS",
"edit": "Editar Fake DNS",
"ipPool": "Sub-rede do Pool de IP",
"poolSize": "Tamanho do Pool"
},
@@ -2067,7 +1894,6 @@
"add": "Adicionar",
"month": "Mês",
"months": "Meses",
"day": "Dia",
"days": "Dias",
"hours": "Horas",
"minutes": "Minutos",
@@ -2106,7 +1932,6 @@
"userSaved": "✅ Usuário do Telegram salvo.",
"loginSuccess": "✅ Conectado ao painel com sucesso.\r\n",
"loginFailed": "❗️Tentativa de login no painel falhou.\r\n",
"2faFailed": "Falha no 2FA",
"report": "🕰 Relatórios agendados: {{ .RunTime }}\r\n",
"datetime": "⏰ Data&Hora: {{ .DateTime }}\r\n",
"hostname": "💻 Host: {{ .Hostname }}\r\n",
@@ -2139,7 +1964,6 @@
"download": "🔽 Download: ↓{{ .Download }}\r\n",
"total": "📊 Total: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
"TGUser": "👤 Usuário do Telegram: {{ .TelegramID }}\r\n",
"exhaustedMsg": "🚨 {{ .Type }} esgotado:\r\n",
"exhaustedCount": "🚨 Contagem de {{ .Type }} esgotado:\r\n",
"onlinesCount": "🌐 Clientes online: {{ .Count }}\r\n",
"disabled": "🛑 Desativado: {{ .Disabled }}\r\n",
@@ -2148,16 +1972,10 @@
"refreshedOn": "\r\n📋🔄 Atualizado em: {{ .Time }}\r\n\r\n",
"yes": "✅ Sim",
"no": "❌ Não",
"received_id": "🔑📥 ID atualizado.",
"received_password": "🔑📥 Senha atualizada.",
"received_email": "📧📥 E-mail atualizado.",
"received_comment": "💬📥 Comentário atualizado.",
"id_prompt": "🔑 ID Padrão: {{ .ClientId }}\n\nDigite seu ID.",
"pass_prompt": "🔑 Senha Padrão: {{ .ClientPassword }}\n\nDigite sua senha.",
"email_prompt": "📧 E-mail Padrão: {{ .ClientEmail }}\n\nDigite seu e-mail.",
"comment_prompt": "💬 Comentário Padrão: {{ .ClientComment }}\n\nDigite seu comentário.",
"inbound_client_data_id": "🔄 Entrada: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Email: {{ .ClientEmail }}\n📊 Tráfego: {{ .ClientTraffic }}\n📅 Data de expiração: {{ .ClientExp }}\n🌐 Limite de IP: {{ .IpLimit }}\n💬 Comentário: {{ .ClientComment }}\n\nAgora você pode adicionar o cliente à entrada!",
"inbound_client_data_pass": "🔄 Entrada: {{ .InboundRemark }}\n\n🔑 Senha: {{ .ClientPass }}\n📧 Email: {{ .ClientEmail }}\n📊 Tráfego: {{ .ClientTraffic }}\n📅 Data de expiração: {{ .ClientExp }}\n🌐 Limite de IP: {{ .IpLimit }}\n💬 Comentário: {{ .ClientComment }}\n\nAgora você pode adicionar o cliente à entrada!",
"cancel": "❌ Processo Cancelado! \n\nVocê pode iniciar novamente a qualquer momento com /start. 🔄",
"error_add_client": "⚠️ Erro:\n\n {{ .error }}",
"using_default_value": "Tudo bem, vou manter o valor padrão. 😊",
@@ -2174,8 +1992,6 @@
"eventXrayCrashError": "Erro: {{ .Error }}",
"eventNodeDown": "O nó {{ .Name }} está INATIVO",
"eventNodeUp": "O nó {{ .Name }} está ATIVO",
"eventCPUHigh": "CPU alta",
"eventCPUHighDetail": "CPU: {{ .Detail }}",
"eventLoginFallback": "Falha de login a partir de {{ .Source }}",
"memoryThreshold": "Uso de memória {{ .Percent }}% excede o limite de {{ .Threshold }}%"
},
@@ -2216,11 +2032,8 @@
"submitDisable": "Enviar como Desativado ☑️",
"submitEnable": "Enviar como Ativado ✅",
"use_default": "🏷️ Usar padrão",
"change_id": "⚙️🔑 ID",
"change_password": "⚙️🔑 Senha",
"change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Comentário",
"change_flow": "⚙️🚦 Flow",
"ResetAllTraffics": "Redefinir Todo o Tráfego",
"SortedTrafficUsageReport": "Relatório de Uso de Tráfego Ordenado"
},
@@ -2249,31 +2062,16 @@
}
},
"email": {
"subjectOutboundDown": "O outbound {{ .Tag }} está INATIVO",
"subjectOutboundUp": "O outbound {{ .Tag }} está ATIVO",
"subjectXrayCrash": "O Xray FALHOU",
"subjectCPUHigh": "CPU alta",
"subjectLoginSuccess": "Login bem-sucedido",
"subjectLoginFailed": "Falha de login",
"titleOutboundDown": "Outbound INATIVO",
"titleOutboundUp": "Outbound ATIVO",
"titleXrayCrash": "O Xray FALHOU",
"titleCPUHigh": "CPU alta",
"titleLoginSuccess": "Login bem-sucedido",
"titleLoginFailed": "Falha de login",
"labelStatus": "Status",
"labelOutbound": "Outbound",
"labelNode": "Nó",
"labelError": "Erro",
"labelDelay": "Latência",
"labelDetail": "Detalhe",
"labelUsername": "Nome de usuário",
"labelIP": "IP",
"labelReason": "Motivo",
"labelSource": "Origem",
"labelTime": "Horário",
"statusCrashed": "FALHOU",
"statusRunning": "Em execução",
"statusHigh": "ALTA",
"statusSuccess": "SUCESSO",
"statusFailed": "FALHOU",

Some files were not shown because too many files have changed in this diff Show More