mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-06 22:22:08 +03:00
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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user