Merge branch 'main' into main

This commit is contained in:
Sanaei 2026-06-04 23:42:59 +02:00 committed by GitHub
commit d8e9d9bb64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 2517 additions and 341 deletions

View file

@ -6,3 +6,4 @@ db
cert cert
pgdata pgdata
*.db *.db
*.dump

1
.gitignore vendored
View file

@ -38,6 +38,7 @@ Thumbs.db
x-ui.db x-ui.db
x-ui.db-shm x-ui.db-shm
x-ui.db-wal x-ui.db-wal
*.dump
# Ignore Docker specific files # Ignore Docker specific files
docker-compose.override.yml docker-compose.override.yml

View file

@ -27,6 +27,16 @@ failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnect
ignoreregex = ignoreregex =
EOF EOF
# Ports to exempt from the ban so an over-limit proxy client can never lock
# the administrator out of SSH or the panel. The ban still covers every other
# TCP port (including all Xray inbounds), so IP-limit keeps working for inbounds
# added later without regenerating these files.
SSH_PORTS=$(grep -oE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config 2>/dev/null | grep -oE '[0-9]+' | paste -sd, -)
[ -z "$SSH_PORTS" ] && SSH_PORTS="22"
PANEL_PORT=$(/app/x-ui setting -show true 2>/dev/null | grep -Eo 'port: .+' | awk '{print $2}')
EXEMPT_PORTS="$SSH_PORTS"
[ -n "$PANEL_PORT" ] && EXEMPT_PORTS="$EXEMPT_PORTS,$PANEL_PORT"
cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF
[INCLUDES] [INCLUDES]
before = iptables-allports.conf before = iptables-allports.conf
@ -42,16 +52,17 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]' actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype> actionban = <iptables> -I f2b-<name> 1 -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype> actionunban = <iptables> -D f2b-<name> -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
[Init] [Init]
name = default name = default
protocol = tcp protocol = tcp
chain = INPUT chain = INPUT
exemptports = $EXEMPT_PORTS
EOF EOF
fail2ban-client -x start fail2ban-client -x start

218
database/dump_sqlite.go Normal file
View file

@ -0,0 +1,218 @@
package database
import (
"database/sql"
"fmt"
"os"
"strconv"
"strings"
"unicode/utf8"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// DumpSQLite writes a portable SQL text dump of the SQLite database at srcPath
// to outPath. The output mirrors the `sqlite3 .dump` format (schema + data +
// indexes wrapped in a transaction), so it can be rebuilt with RestoreSQLite or
// loaded by the sqlite3 CLI. The source database is opened read-only in effect
// and left untouched.
func DumpSQLite(srcPath, outPath string) error {
data, err := DumpSQLiteToBytes(srcPath)
if err != nil {
return err
}
return os.WriteFile(outPath, data, 0o644)
}
// DumpSQLiteToBytes builds the same `sqlite3 .dump`-style SQL text as DumpSQLite
// but returns it in memory, which the panel uses to stream a migration download.
func DumpSQLiteToBytes(srcPath string) ([]byte, error) {
if _, err := os.Stat(srcPath); err != nil {
return nil, fmt.Errorf("source sqlite not found at %s: %w", srcPath, err)
}
gdb, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
if err != nil {
return nil, err
}
sqlDB, err := gdb.DB()
if err != nil {
return nil, err
}
defer sqlDB.Close()
var b strings.Builder
b.WriteString("PRAGMA foreign_keys=OFF;\n")
b.WriteString("BEGIN TRANSACTION;\n")
// Tables in creation order, each followed by its data.
type object struct{ name, ddl string }
var tables []object
rows, err := sqlDB.Query(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY rowid`)
if err != nil {
return nil, err
}
for rows.Next() {
var o object
if err := rows.Scan(&o.name, &o.ddl); err != nil {
rows.Close()
return nil, err
}
tables = append(tables, o)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
for _, t := range tables {
b.WriteString(t.ddl)
b.WriteString(";\n")
if err := dumpTableData(sqlDB, t.name, &b); err != nil {
return nil, err
}
}
// AUTOINCREMENT bookkeeping, restored verbatim like the sqlite3 CLI does.
if sqliteTableExists(sqlDB, "sqlite_sequence") {
b.WriteString("DELETE FROM sqlite_sequence;\n")
if err := dumpTableData(sqlDB, "sqlite_sequence", &b); err != nil {
return nil, err
}
}
// Indexes, triggers and views after the data is in place.
rows2, err := sqlDB.Query(`SELECT sql FROM sqlite_master WHERE type IN ('index','trigger','view') AND sql IS NOT NULL ORDER BY rowid`)
if err != nil {
return nil, err
}
for rows2.Next() {
var ddl string
if err := rows2.Scan(&ddl); err != nil {
rows2.Close()
return nil, err
}
b.WriteString(ddl)
b.WriteString(";\n")
}
if err := rows2.Err(); err != nil {
rows2.Close()
return nil, err
}
rows2.Close()
b.WriteString("COMMIT;\n")
return []byte(b.String()), nil
}
// RestoreSQLite rebuilds a SQLite database at dstPath from a SQL text dump
// produced by DumpSQLite (or `sqlite3 .dump`). dstPath must not already exist so
// an existing database is never clobbered silently.
func RestoreSQLite(dumpPath, dstPath string) error {
script, err := os.ReadFile(dumpPath)
if err != nil {
return err
}
if _, err := os.Stat(dstPath); err == nil {
return fmt.Errorf("destination already exists: %s", dstPath)
}
gdb, err := gorm.Open(sqlite.Open(dstPath), &gorm.Config{Logger: logger.Discard})
if err != nil {
return err
}
sqlDB, err := gdb.DB()
if err != nil {
return err
}
// mattn/go-sqlite3 executes every statement in a multi-statement string.
if _, err := sqlDB.Exec(string(script)); err != nil {
sqlDB.Close()
os.Remove(dstPath)
return fmt.Errorf("restore failed: %w", err)
}
return sqlDB.Close()
}
// dumpTableData appends one INSERT statement per row of table to b.
func dumpTableData(db *sql.DB, table string, b *strings.Builder) error {
rows, err := db.Query(`SELECT * FROM "` + table + `"`)
if err != nil {
return err
}
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
return err
}
n := len(cols)
prefix := `INSERT INTO "` + table + `" VALUES(`
for rows.Next() {
vals := make([]any, n)
ptrs := make([]any, n)
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
return err
}
b.WriteString(prefix)
for i, v := range vals {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(sqliteLiteral(v))
}
b.WriteString(");\n")
}
return rows.Err()
}
// sqliteLiteral renders a scanned column value as a SQLite SQL literal.
func sqliteLiteral(v any) string {
switch x := v.(type) {
case nil:
return "NULL"
case int64:
return strconv.FormatInt(x, 10)
case float64:
return strconv.FormatFloat(x, 'g', -1, 64)
case bool:
if x {
return "1"
}
return "0"
case string:
return quoteSQLiteText(x)
case []byte:
if utf8.Valid(x) {
return quoteSQLiteText(string(x))
}
var sb strings.Builder
sb.WriteString("X'")
for _, c := range x {
fmt.Fprintf(&sb, "%02x", c)
}
sb.WriteByte('\'')
return sb.String()
default:
return quoteSQLiteText(fmt.Sprintf("%v", x))
}
}
func quoteSQLiteText(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
func sqliteTableExists(db *sql.DB, name string) bool {
var found string
err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&found)
return err == nil
}

View file

@ -0,0 +1,137 @@
package database
import (
"os"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/xray"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// TestCopyAllModelsIntoSQLite exercises the same AutoMigrate + copyTable
// machinery that ExportPostgresToSQLite relies on, but with a SQLite source so
// it needs no external database. The Postgres source path uses identical gorm
// reads (see MigrateData), so this validates the destination-side copy.
func TestCopyAllModelsIntoSQLite(t *testing.T) {
dir := t.TempDir()
srcPath := filepath.Join(dir, "src.db")
dstPath := filepath.Join(dir, "dst.db")
src, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatalf("open src: %v", err)
}
defer closeGorm(src)
for _, m := range migrationModels() {
if err := src.AutoMigrate(m); err != nil {
t.Fatalf("automigrate src %T: %v", m, err)
}
}
// Seed a few rows across parent/child tables and a composite-PK table.
if err := src.Create(&model.User{Username: "admin", Password: "x"}).Error; err != nil {
t.Fatalf("seed user: %v", err)
}
if err := src.Create(&model.Inbound{UserId: 1, Remark: "in", Port: 443, Protocol: "vless", Tag: "inbound-443"}).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
if err := src.Create(&xray.ClientTraffic{InboundId: 1, Email: "a@b.c", Enable: true, Up: 10, Down: 20}).Error; err != nil {
t.Fatalf("seed traffic: %v", err)
}
dst, err := gorm.Open(sqlite.Open(dstPath), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatalf("open dst: %v", err)
}
defer closeGorm(dst)
if err := copyAllModels(src, dst); err != nil {
t.Fatalf("copyAllModels: %v", err)
}
for _, tc := range []struct {
model any
want int64
}{
{&model.User{}, 1},
{&model.Inbound{}, 1},
{&xray.ClientTraffic{}, 1},
} {
var got int64
if err := dst.Model(tc.model).Count(&got).Error; err != nil {
t.Fatalf("count %T: %v", tc.model, err)
}
if got != tc.want {
t.Errorf("%T: got %d rows, want %d", tc.model, got, tc.want)
}
}
// Spot-check a copied value survived the round-trip.
var ct xray.ClientTraffic
if err := dst.Where("email = ?", "a@b.c").First(&ct).Error; err != nil {
t.Fatalf("read back traffic: %v", err)
}
if ct.Up != 10 || ct.Down != 20 || !ct.Enable {
t.Errorf("traffic mismatch: %+v", ct)
}
}
// TestDumpAndRestoreSQLiteRoundTrip dumps a seeded SQLite db to .dump text and
// rebuilds it, asserting the row survives.
func TestDumpAndRestoreSQLiteRoundTrip(t *testing.T) {
dir := t.TempDir()
srcPath := filepath.Join(dir, "src.db")
dumpPath := filepath.Join(dir, "out.dump")
dstPath := filepath.Join(dir, "rebuilt.db")
src, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatalf("open src: %v", err)
}
if err := src.AutoMigrate(&model.Setting{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
if err := src.Create(&model.Setting{Key: "secret", Value: "o'brien \"quote\""}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
if sqlDB, _ := src.DB(); sqlDB != nil {
sqlDB.Close()
}
if err := DumpSQLite(srcPath, dumpPath); err != nil {
t.Fatalf("DumpSQLite: %v", err)
}
if fi, err := os.Stat(dumpPath); err != nil || fi.Size() == 0 {
t.Fatalf("dump missing/empty: %v", err)
}
if err := RestoreSQLite(dumpPath, dstPath); err != nil {
t.Fatalf("RestoreSQLite: %v", err)
}
dst, err := gorm.Open(sqlite.Open(dstPath), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatalf("open dst: %v", err)
}
defer closeGorm(dst)
var s model.Setting
if err := dst.Where("key = ?", "secret").First(&s).Error; err != nil {
t.Fatalf("read back: %v", err)
}
if s.Value != "o'brien \"quote\"" {
t.Errorf("value mismatch after round-trip: %q", s.Value)
}
}
// closeGorm closes the underlying *sql.DB so Windows can delete the temp file.
func closeGorm(db *gorm.DB) {
if db == nil {
return
}
if s, err := db.DB(); err == nil {
s.Close()
}
}

View file

@ -86,6 +86,23 @@ func MigrateData(srcPath, dstDSN string) error {
} }
} }
// AutoMigrate re-creates the legacy client_traffics -> inbounds foreign key,
// but the running panel drops it (see dropLegacyForeignKeys) and tolerates
// client_traffics rows whose inbound was deleted. Drop it here too so copying
// such orphaned rows can't fail with an fk_inbounds_client_stats violation.
if err := dst.Exec("ALTER TABLE client_traffics DROP CONSTRAINT IF EXISTS fk_inbounds_client_stats").Error; err != nil {
return fmt.Errorf("drop legacy foreign key: %w", err)
}
// Empty the destination tables so the migration is idempotent: a fresh
// PostgreSQL DB already holds an auto-seeded admin (id=1) from any prior
// panel start, and a partially-failed earlier run leaves rows behind. Either
// way a plain INSERT with explicit ids would collide on users_pkey, so clear
// our tables (only) before copying.
if err := truncatePostgresTables(dst, migrationModels()); err != nil {
return fmt.Errorf("clear destination tables: %w", err)
}
totalRows := 0 totalRows := 0
for _, m := range migrationModels() { for _, m := range migrationModels() {
n, err := copyTable(src, dst, m) n, err := copyTable(src, dst, m)
@ -105,6 +122,62 @@ func MigrateData(srcPath, dstDSN string) error {
return nil return nil
} }
// ExportPostgresToSQLite copies every row from the PostgreSQL database described
// by srcDSN into a fresh SQLite file at dstPath. It is the reverse of
// MigrateData and is used to hand a PostgreSQL-backed panel a portable .db file.
// dstPath is created/overwritten; the PostgreSQL source is left untouched.
func ExportPostgresToSQLite(srcDSN, dstPath string) error {
if srcDSN == "" {
return errors.New("source DSN is required")
}
if err := os.MkdirAll(path.Dir(dstPath), 0755); err != nil {
return err
}
// Start from an empty file so AutoMigrate creates the canonical schema.
if err := os.Remove(dstPath); err != nil && !os.IsNotExist(err) {
return err
}
src, err := gorm.Open(postgres.Open(srcDSN), &gorm.Config{Logger: logger.Discard})
if err != nil {
return fmt.Errorf("open postgres source: %w", err)
}
srcSQL, err := src.DB()
if err != nil {
return err
}
defer srcSQL.Close()
// No WAL: keep all data in the main file so it is complete once closed.
dst, err := gorm.Open(sqlite.Open(dstPath+"?_busy_timeout=10000"), &gorm.Config{Logger: logger.Discard})
if err != nil {
return fmt.Errorf("open sqlite destination: %w", err)
}
dstSQL, err := dst.DB()
if err != nil {
return err
}
defer dstSQL.Close()
return copyAllModels(src, dst)
}
// copyAllModels (re)creates the schema on dst and copies every migrated table
// from src to dst in FK-safe order. src/dst may be any gorm backend.
func copyAllModels(src, dst *gorm.DB) error {
for _, m := range migrationModels() {
if err := dst.AutoMigrate(m); err != nil {
return fmt.Errorf("AutoMigrate %T: %w", m, err)
}
}
for _, m := range migrationModels() {
if _, err := copyTable(src, dst, m); err != nil {
return fmt.Errorf("copy %T: %w", m, err)
}
}
return nil
}
func copyTable(src, dst *gorm.DB, mdl any) (int, error) { func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
const batchSize = 500 const batchSize = 500
@ -157,6 +230,26 @@ func copyTable(src, dst *gorm.DB, mdl any) (int, error) {
return total, nil return total, nil
} }
// truncatePostgresTables empties every migrated table on dst in a single
// statement, resetting identity sequences. CASCADE covers the inbound/client
// foreign keys regardless of insertion order. Only the panel's own tables are
// touched, never the rest of the schema.
func truncatePostgresTables(dst *gorm.DB, models []any) error {
tables := make([]string, 0, len(models))
for _, m := range models {
stmt := &gorm.Statement{DB: dst}
if err := stmt.Parse(m); err != nil {
return err
}
tables = append(tables, `"`+stmt.Schema.Table+`"`)
}
if len(tables) == 0 {
return nil
}
log.Println("Clearing destination tables...")
return dst.Exec("TRUNCATE TABLE " + strings.Join(tables, ", ") + " RESTART IDENTITY CASCADE").Error
}
// resetPostgresSequences advances each migrated table's id sequence past MAX(id), // resetPostgresSequences advances each migrated table's id sequence past MAX(id),
// otherwise the next INSERT-without-id would clash with copied rows. // otherwise the next INSERT-without-id would clash with copied rows.
func resetPostgresSequences(dst *gorm.DB) error { func resetPostgresSequences(dst *gorm.DB) error {

View file

@ -17,7 +17,7 @@
"axios": "^1.17.0", "axios": "^1.17.0",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
"i18next": "^26.3.0", "i18next": "^26.3.1",
"otpauth": "^9.5.1", "otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.5", "persian-calendar-suite": "^1.5.5",
"qs": "^6.15.2", "qs": "^6.15.2",
@ -1934,9 +1934,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1954,9 +1951,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1974,9 +1968,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1994,9 +1985,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2014,9 +2002,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2034,9 +2019,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5087,9 +5069,9 @@
} }
}, },
"node_modules/i18next": { "node_modules/i18next": {
"version": "26.3.0", "version": "26.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
"integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==", "integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@ -5615,9 +5597,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -5639,9 +5618,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -5663,9 +5639,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -5687,9 +5660,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [

View file

@ -29,7 +29,7 @@
"axios": "^1.17.0", "axios": "^1.17.0",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
"i18next": "^26.3.0", "i18next": "^26.3.1",
"otpauth": "^9.5.1", "otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.5", "persian-calendar-suite": "^1.5.5",
"qs": "^6.15.2", "qs": "^6.15.2",

View file

@ -1495,6 +1495,36 @@
} }
} }
}, },
"/panel/api/server/getMigration": {
"get": {
"tags": [
"Server"
],
"summary": "Stream a cross-engine migration file as an attachment: a .dump (SQL text) on SQLite, or a .db SQLite database built from the live data on PostgreSQL.",
"operationId": "get_panel_api_server_getMigration",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/server/getNewUUID": { "/panel/api/server/getNewUUID": {
"get": { "get": {
"tags": [ "tags": [

View file

@ -34,7 +34,9 @@ export interface AllSetting {
subAnnounce: string; subAnnounce: string;
subCertFile: string; subCertFile: string;
subClashEnable: boolean; subClashEnable: boolean;
subClashEnableRouting: boolean;
subClashPath: string; subClashPath: string;
subClashRules: string;
subClashURI: string; subClashURI: string;
subDomain: string; subDomain: string;
subEmailInRemark: boolean; subEmailInRemark: boolean;
@ -120,7 +122,9 @@ export interface AllSettingView {
subAnnounce: string; subAnnounce: string;
subCertFile: string; subCertFile: string;
subClashEnable: boolean; subClashEnable: boolean;
subClashEnableRouting: boolean;
subClashPath: string; subClashPath: string;
subClashRules: string;
subClashURI: string; subClashURI: string;
subDomain: string; subDomain: string;
subEmailInRemark: boolean; subEmailInRemark: boolean;

View file

@ -36,7 +36,9 @@ export const AllSettingSchema = z.object({
subAnnounce: z.string(), subAnnounce: z.string(),
subCertFile: z.string(), subCertFile: z.string(),
subClashEnable: z.boolean(), subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(),
subClashPath: z.string(), subClashPath: z.string(),
subClashRules: z.string(),
subClashURI: z.string(), subClashURI: z.string(),
subDomain: z.string(), subDomain: z.string(),
subEmailInRemark: z.boolean(), subEmailInRemark: z.boolean(),
@ -123,7 +125,9 @@ export const AllSettingViewSchema = z.object({
subAnnounce: z.string(), subAnnounce: z.string(),
subCertFile: z.string(), subCertFile: z.string(),
subClashEnable: z.boolean(), subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(),
subClashPath: z.string(), subClashPath: z.string(),
subClashRules: z.string(),
subClashURI: z.string(), subClashURI: z.string(),
subDomain: z.string(), subDomain: z.string(),
subEmailInRemark: z.boolean(), subEmailInRemark: z.boolean(),

View file

@ -55,6 +55,8 @@ export class AllSetting {
subURI = ''; subURI = '';
subJsonURI = ''; subJsonURI = '';
subClashURI = ''; subClashURI = '';
subClashEnableRouting = false;
subClashRules = '';
subJsonMux = ''; subJsonMux = '';
subJsonRules = ''; subJsonRules = '';
subJsonFinalMask = ''; subJsonFinalMask = '';

View file

@ -307,6 +307,11 @@ export const sections: readonly Section[] = [
path: '/panel/api/server/getDb', path: '/panel/api/server/getDb',
summary: 'Stream the SQLite database file as an attachment. Use as a manual backup.', summary: 'Stream the SQLite database file as an attachment. Use as a manual backup.',
}, },
{
method: 'GET',
path: '/panel/api/server/getMigration',
summary: 'Stream a cross-engine migration file as an attachment: a .dump (SQL text) on SQLite, or a .db SQLite database built from the live data on PostgreSQL.',
},
{ {
method: 'GET', method: 'GET',
path: '/panel/api/server/getNewUUID', path: '/panel/api/server/getNewUUID',
@ -1109,7 +1114,7 @@ export const sections: readonly Section[] = [
{ {
method: 'GET', method: 'GET',
path: '/{clashPath}:subid', path: '/{clashPath}:subid',
summary: 'Return subscription as a Clash/Mihomo-compatible YAML config. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.', summary: 'Return subscription as a Clash/Mihomo-compatible YAML config, including configured global Clash routing rules. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.',
params: [ params: [
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }, { name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
], ],

View file

@ -249,7 +249,7 @@ export default function ClientBulkAddModal({
)} )}
{form.emailMethod < 2 && ( {form.emailMethod < 2 && (
<Form.Item label={t('pages.clients.clientCount')}> <Form.Item label={t('pages.clients.clientCount')}>
<InputNumber value={form.quantity} min={1} max={100} onChange={(v) => update('quantity', Number(v) || 1)} /> <InputNumber value={form.quantity} min={1} max={1000} onChange={(v) => update('quantity', Number(v) || 1)} />
</Form.Item> </Form.Item>
)} )}

View file

@ -71,6 +71,7 @@ import type { ClientFilters } from './filters';
import './ClientsPage.css'; import './ClientsPage.css';
const FILTER_STATE_KEY = 'clientsFilterState'; const FILTER_STATE_KEY = 'clientsFilterState';
const DISABLED_PAGE_SIZE = 200;
function UngroupIcon() { function UngroupIcon() {
return ( return (
@ -276,10 +277,7 @@ export default function ClientsPage() {
const activeCount = activeFilterCount(filters); const activeCount = activeFilterCount(filters);
useEffect(() => { useEffect(() => {
if (pageSize > 0) { setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE);
setTablePageSize(pageSize);
}
}, [pageSize]); }, [pageSize]);
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]); const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);

View file

@ -25,6 +25,10 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
window.location.href = (window.X_UI_BASE_PATH || '') + 'panel/api/server/getDb'; window.location.href = (window.X_UI_BASE_PATH || '') + 'panel/api/server/getDb';
} }
function exportMigration() {
window.location.href = (window.X_UI_BASE_PATH || '') + 'panel/api/server/getMigration';
}
function importDb() { function importDb() {
const fileInput = document.createElement('input'); const fileInput = document.createElement('input');
fileInput.type = 'file'; fileInput.type = 'file';
@ -82,6 +86,16 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
<Button type="primary" onClick={exportDb} icon={<DownloadOutlined />} /> <Button type="primary" onClick={exportDb} icon={<DownloadOutlined />} />
</div> </div>
<div className="backup-item">
<div className="backup-meta">
<div className="backup-title">{t('pages.index.migrationDownload')}</div>
<div className="backup-description">
{isPostgres ? t('pages.index.migrationDownloadPgDesc') : t('pages.index.migrationDownloadDesc')}
</div>
</div>
<Button type="primary" onClick={exportMigration} icon={<DownloadOutlined />} />
</div>
<div className="backup-item"> <div className="backup-item">
<div className="backup-meta"> <div className="backup-meta">
<div className="backup-title">{t('pages.index.importDatabase')}</div> <div className="backup-title">{t('pages.index.importDatabase')}</div>

View file

@ -166,6 +166,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
<Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..." <Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..."
onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} /> onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} />
</SettingListItem> </SettingListItem>
<Divider>Clash / Mihomo</Divider>
<SettingListItem paddings="small" title={t('pages.settings.subClashEnableRouting')} description={t('pages.settings.subClashEnableRoutingDesc')}>
<Switch checked={allSetting.subClashEnableRouting} onChange={(v) => updateSetting({ subClashEnableRouting: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subClashRoutingRules')} description={t('pages.settings.subClashRoutingRulesDesc')}>
<Input.TextArea
value={allSetting.subClashRules}
rows={8}
placeholder={'GEOSITE,category-ir,DIRECT\nGEOIP,private,DIRECT'}
onChange={(e) => updateSetting({ subClashRules: e.target.value })}
/>
</SettingListItem>
</> </>
), ),
}, },

View file

@ -182,7 +182,7 @@ export const ClientBulkAddFormSchema = z.object({
lastNum: z.number().int().min(1), lastNum: z.number().int().min(1),
emailPrefix: z.string(), emailPrefix: z.string(),
emailPostfix: z.string(), emailPostfix: z.string(),
quantity: z.number().int().min(1).max(100), quantity: z.number().int().min(1).max(1000),
subId: z.string(), subId: z.string(),
group: z.string(), group: z.string(),
comment: z.string(), comment: z.string(),

View file

@ -59,6 +59,8 @@ export const AllSettingSchema = z.object({
subURI: z.string().optional(), subURI: z.string().optional(),
subJsonURI: z.string().optional(), subJsonURI: z.string().optional(),
subClashURI: z.string().optional(), subClashURI: z.string().optional(),
subClashEnableRouting: z.boolean().optional(),
subClashRules: z.string().optional(),
subJsonMux: z.string().optional(), subJsonMux: z.string().optional(),
subJsonRules: z.string().optional(), subJsonRules: z.string().optional(),
subJsonFinalMask: z.string().optional(), subJsonFinalMask: z.string().optional(),

View file

@ -858,13 +858,13 @@ export class LanguageManager {
}); });
if (LanguageManager.isSupportLanguage(lang)) { if (LanguageManager.isSupportLanguage(lang)) {
CookieManager.setCookie('lang', lang); CookieManager.setCookie('lang', lang, 365);
} else { } else {
CookieManager.setCookie('lang', 'en-US'); CookieManager.setCookie('lang', 'en-US', 365);
window.location.reload(); window.location.reload();
} }
} else { } else {
CookieManager.setCookie('lang', 'en-US'); CookieManager.setCookie('lang', 'en-US', 365);
window.location.reload(); window.location.reload();
} }
@ -875,7 +875,7 @@ export class LanguageManager {
if (!LanguageManager.isSupportLanguage(language)) { if (!LanguageManager.isSupportLanguage(language)) {
language = 'en-US'; language = 'en-US';
} }
CookieManager.setCookie('lang', language); CookieManager.setCookie('lang', language, 365);
window.location.reload(); window.location.reload();
} }

View file

@ -297,7 +297,7 @@ setup_ssl_certificate() {
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo -e "${yellow}Failed to issue certificate for ${domain}${plain}" echo -e "${yellow}Failed to issue certificate for ${domain}${plain}"
echo -e "${yellow}Please ensure port 80 is open and try again later with: x-ui${plain}" echo -e "${yellow}Please ensure port 80 is open and try again later with: x-ui${plain}"
rm -rf ~/.acme.sh/${domain} 2> /dev/null rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc 2> /dev/null
rm -rf "$certPath" 2> /dev/null rm -rf "$certPath" 2> /dev/null
return 1 return 1
fi fi
@ -431,8 +431,8 @@ setup_ip_certificate() {
echo -e "${red}Failed to issue IP certificate${plain}" echo -e "${red}Failed to issue IP certificate${plain}"
echo -e "${yellow}Please ensure port ${WebPort} is reachable (or forwarded from external port 80)${plain}" echo -e "${yellow}Please ensure port ${WebPort} is reachable (or forwarded from external port 80)${plain}"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified # Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
rm -rf ${certDir} 2> /dev/null rm -rf ${certDir} 2> /dev/null
return 1 return 1
fi fi
@ -451,8 +451,8 @@ setup_ip_certificate() {
if [[ ! -f "${certDir}/fullchain.pem" || ! -f "${certDir}/privkey.pem" ]]; then if [[ ! -f "${certDir}/fullchain.pem" || ! -f "${certDir}/privkey.pem" ]]; then
echo -e "${red}Certificate files not found after installation${plain}" echo -e "${red}Certificate files not found after installation${plain}"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified # Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
rm -rf ${certDir} 2> /dev/null rm -rf ${certDir} 2> /dev/null
return 1 return 1
fi fi
@ -524,14 +524,30 @@ ssl_cert_issue() {
echo -e "${green}Your domain is: ${domain}, checking it...${plain}" echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
SSL_ISSUED_DOMAIN="${domain}" SSL_ISSUED_DOMAIN="${domain}"
# detect existing certificate and reuse it if present # detect existing certificate and reuse it only if its files are actually
# present and non-empty. acme.sh stores ECC certs under ${domain}_ecc and RSA
# certs under ${domain}; a failed issuance can leave a domain entry in --list
# with no usable cert files, which must not be reused (it produces a 0-byte
# fullchain.pem). Broken partial state is cleaned up so issuance can proceed.
local cert_exists=0 local cert_exists=0
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
cert_exists=1 local acmeCertDir=""
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}") if [[ -s ~/.acme.sh/${domain}_ecc/fullchain.cer && -s ~/.acme.sh/${domain}_ecc/${domain}.key ]]; then
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}" acmeCertDir=~/.acme.sh/${domain}_ecc
[[ -n "${certInfo}" ]] && echo "$certInfo" elif [[ -s ~/.acme.sh/${domain}/fullchain.cer && -s ~/.acme.sh/${domain}/${domain}.key ]]; then
else acmeCertDir=~/.acme.sh/${domain}
fi
if [[ -n "${acmeCertDir}" ]]; then
cert_exists=1
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
[[ -n "${certInfo}" ]] && echo "$certInfo"
else
echo -e "${yellow}Found incomplete acme.sh state for ${domain} (no valid certificate files); cleaning it up and re-issuing.${plain}"
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
fi
fi
if [[ ${cert_exists} -eq 0 ]]; then
echo -e "${green}Your domain is ready for issuing certificates now...${plain}" echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
fi fi
@ -563,7 +579,7 @@ ssl_cert_issue() {
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo -e "${red}Issuing certificate failed, please check logs.${plain}" echo -e "${red}Issuing certificate failed, please check logs.${plain}"
rm -rf ~/.acme.sh/${domain} rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
return 1 return 1
else else
@ -617,7 +633,7 @@ ssl_cert_issue() {
else else
echo -e "${red}Installing certificate failed, exiting.${plain}" echo -e "${red}Installing certificate failed, exiting.${plain}"
if [[ ${cert_exists} -eq 0 ]]; then if [[ ${cert_exists} -eq 0 ]]; then
rm -rf ~/.acme.sh/${domain} rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
fi fi
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
return 1 return 1

39
main.go
View file

@ -466,8 +466,14 @@ func main() {
migrateDbCmd := flag.NewFlagSet("migrate-db", flag.ExitOnError) migrateDbCmd := flag.NewFlagSet("migrate-db", flag.ExitOnError)
var migrateDsn string var migrateDsn string
var migrateSrc string var migrateSrc string
var migrateDump string
var migrateRestore string
var migrateOut string
migrateDbCmd.StringVar(&migrateDsn, "dsn", "", "Destination PostgreSQL DSN (postgres://user:pass@host:port/db?sslmode=disable)") migrateDbCmd.StringVar(&migrateDsn, "dsn", "", "Destination PostgreSQL DSN (postgres://user:pass@host:port/db?sslmode=disable)")
migrateDbCmd.StringVar(&migrateSrc, "src", "", "Source SQLite file (defaults to the configured x-ui.db)") migrateDbCmd.StringVar(&migrateSrc, "src", "", "Source SQLite file (defaults to the configured x-ui.db)")
migrateDbCmd.StringVar(&migrateDump, "dump", "", "Write a portable SQL text dump of --src to this file (.db -> .dump)")
migrateDbCmd.StringVar(&migrateRestore, "restore", "", "Rebuild a SQLite database from this SQL text dump (.dump -> .db); requires --out")
migrateDbCmd.StringVar(&migrateOut, "out", "", "Destination SQLite file for --restore (must not already exist)")
settingCmd := flag.NewFlagSet("setting", flag.ExitOnError) settingCmd := flag.NewFlagSet("setting", flag.ExitOnError)
var port int var port int
@ -512,7 +518,7 @@ func main() {
fmt.Println("Commands:") fmt.Println("Commands:")
fmt.Println(" run run web panel") fmt.Println(" run run web panel")
fmt.Println(" migrate migrate form other/old x-ui") fmt.Println(" migrate migrate form other/old x-ui")
fmt.Println(" migrate-db copy data from the SQLite file into a PostgreSQL database") fmt.Println(" migrate-db SQLite <-> .dump (--dump/--restore) or copy into PostgreSQL (--dsn)")
fmt.Println(" setting set settings") fmt.Println(" setting set settings")
} }
@ -541,13 +547,30 @@ func main() {
if src == "" { if src == "" {
src = config.GetDBPath() src = config.GetDBPath()
} }
if migrateDsn == "" { switch {
fmt.Println("--dsn is required: postgres://user:pass@host:port/dbname?sslmode=disable") case migrateDump != "":
return if err := database.DumpSQLite(src, migrateDump); err != nil {
} fmt.Println("dump failed:", err)
if err := database.MigrateData(src, migrateDsn); err != nil { os.Exit(1)
fmt.Println("migration failed:", err) }
os.Exit(1) fmt.Printf("Dumped %s -> %s\n", src, migrateDump)
case migrateRestore != "":
if migrateOut == "" {
fmt.Println("--out is required when using --restore: the destination .db path (must not exist)")
return
}
if err := database.RestoreSQLite(migrateRestore, migrateOut); err != nil {
fmt.Println("restore failed:", err)
os.Exit(1)
}
fmt.Printf("Restored %s -> %s\n", migrateRestore, migrateOut)
case migrateDsn != "":
if err := database.MigrateData(src, migrateDsn); err != nil {
fmt.Println("migration failed:", err)
os.Exit(1)
}
default:
fmt.Println("nothing to do: pass --dump <file>, --restore <file> --out <db>, or --dsn <postgres-dsn>")
} }
case "setting": case "setting":
err := settingCmd.Parse(os.Args[2:]) err := settingCmd.Parse(os.Args[2:])

View file

@ -133,6 +133,14 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask() SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
if err != nil { if err != nil {
SubJsonFinalMask = "" SubJsonFinalMask = ""
SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
if err != nil {
SubClashEnableRouting = false
}
SubClashRules, err := s.settingService.GetSubClashRules()
if err != nil {
SubClashRules = ""
} }
SubTitle, err := s.settingService.GetSubTitle() SubTitle, err := s.settingService.GetSubTitle()
@ -221,7 +229,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
s.sub = NewSUBController( s.sub = NewSUBController(
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates, g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
SubJsonMux, SubJsonRules, SubJsonFinalMask, SubTitle, SubSupportUrl, SubJsonMux, SubJsonRules, SubJsonFinalMask,SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules) SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
return engine, nil return engine, nil

View file

@ -15,17 +15,13 @@ import (
type SubClashService struct { type SubClashService struct {
inboundService service.InboundService inboundService service.InboundService
enableRouting bool
clashRules string
SubService *SubService SubService *SubService
} }
type ClashConfig struct { func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
Proxies []map[string]any `yaml:"proxies"` return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService}
ProxyGroups []map[string]any `yaml:"proxy-groups"`
Rules []string `yaml:"rules"`
}
func NewSubClashService(subService *SubService) *SubClashService {
return &SubClashService{SubService: subService}
} }
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) { func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
@ -76,14 +72,20 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
} }
proxyNames = append(proxyNames, "DIRECT") proxyNames = append(proxyNames, "DIRECT")
config := ClashConfig{ config := map[string]any{
Proxies: proxies, "proxies": proxies,
ProxyGroups: []map[string]any{{ "proxy-groups": []map[string]any{{
"name": "PROXY", "name": "PROXY",
"type": "select", "type": "select",
"proxies": proxyNames, "proxies": proxyNames,
}}, }},
Rules: []string{"MATCH,PROXY"}, "rules": []string{"MATCH,PROXY"},
}
if s.enableRouting {
if err := mergeClashRulesYAML(config, s.clashRules); err != nil {
return "", "", err
}
} }
finalYAML, err := yaml.Marshal(config) finalYAML, err := yaml.Marshal(config)
@ -554,3 +556,96 @@ func cloneMap(src map[string]any) map[string]any {
maps.Copy(dst, src) maps.Copy(dst, src)
return dst return dst
} }
func mergeClashRulesYAML(base map[string]any, raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var custom any
if err := yaml.Unmarshal([]byte(raw), &custom); err != nil {
mergeClashRules(base, linesToClashRules(raw))
return nil
}
switch typed := custom.(type) {
case []any:
mergeClashRules(base, typed)
case map[string]any:
if rules, ok := typed["rules"]; ok {
if ruleList, ok := asAnySlice(rules); ok {
mergeClashRules(base, ruleList)
}
}
default:
mergeClashRules(base, linesToClashRules(raw))
}
return nil
}
func mergeClashRules(base map[string]any, customRules []any) {
if len(customRules) == 0 {
return
}
baseRules, _ := asAnySlice(base["rules"])
if hasClashMatchRule(customRules) {
base["rules"] = customRules
return
}
merged := make([]any, 0, len(customRules)+len(baseRules))
merged = append(merged, customRules...)
merged = append(merged, baseRules...)
base["rules"] = merged
}
func asAnySlice(value any) ([]any, bool) {
switch typed := value.(type) {
case []any:
return typed, true
case []string:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, item)
}
return out, true
case []map[string]any:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, item)
}
return out, true
default:
return nil, false
}
}
func hasClashMatchRule(rules []any) bool {
for _, rule := range rules {
ruleText, ok := rule.(string)
if !ok {
continue
}
parts := strings.SplitN(ruleText, ",", 2)
if strings.EqualFold(strings.TrimSpace(parts[0]), "MATCH") {
return true
}
}
return false
}
func linesToClashRules(raw string) []any {
lines := strings.Split(raw, "\n")
rules := make([]any, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
rules = append(rules, line)
}
return rules
}

View file

@ -65,6 +65,8 @@ func NewSUBController(
jsonMux string, jsonMux string,
jsonRules string, jsonRules string,
jsonFinalMask string, jsonFinalMask string,
clashEnableRouting bool,
clashRules string,
subTitle string, subTitle string,
subSupportUrl string, subSupportUrl string,
subProfileUrl string, subProfileUrl string,
@ -90,7 +92,7 @@ func NewSUBController(
subService: sub, subService: sub,
subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub), subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
subClashService: NewSubClashService(sub), subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
} }
a.initRouter(g) a.initRouter(g)
return a return a

View file

@ -3,6 +3,8 @@ package controller
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv"
"strings"
"time" "time"
"github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/database/model"
@ -16,6 +18,21 @@ func notifyClientsChanged() {
websocket.BroadcastInvalidate(websocket.MessageTypeClients) websocket.BroadcastInvalidate(websocket.MessageTypeClients)
} }
func parseInboundIdsQuery(raw string) []int {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
ids := make([]int, 0, len(parts))
for _, p := range parts {
if id, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
ids = append(ids, id)
}
}
return ids
}
type ClientController struct { type ClientController struct {
clientService service.ClientService clientService service.ClientService
inboundService service.InboundService inboundService service.InboundService
@ -129,7 +146,8 @@ func (a *ClientController) update(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return
} }
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated) inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated, inboundFilter...)
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return

View file

@ -53,6 +53,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo) g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
g.GET("/getConfigJson", a.getConfigJson) g.GET("/getConfigJson", a.getConfigJson)
g.GET("/getDb", a.getDb) g.GET("/getDb", a.getDb)
g.GET("/getMigration", a.getMigration)
g.GET("/getNewUUID", a.getNewUUID) g.GET("/getNewUUID", a.getNewUUID)
g.GET("/getWebCertFiles", a.getWebCertFiles) g.GET("/getWebCertFiles", a.getWebCertFiles)
g.GET("/getNewX25519Cert", a.getNewX25519Cert) g.GET("/getNewX25519Cert", a.getNewX25519Cert)
@ -300,6 +301,24 @@ func (a *ServerController) getDb(c *gin.Context) {
c.Writer.Write(db) c.Writer.Write(db)
} }
// getMigration downloads a cross-engine migration file: a .dump on SQLite or a
// .db SQLite database on PostgreSQL, so the data can seed the other backend.
func (a *ServerController) getMigration(c *gin.Context) {
data, filename, err := a.serverService.GetMigration()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.getDatabaseError"), err)
return
}
if !filenameRegex.MatchString(filename) {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
return
}
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Disposition", "attachment; filename="+filename)
c.Writer.Write(data)
}
// importDB imports a database file and restarts the Xray service. // importDB imports a database file and restarts the Xray service.
func (a *ServerController) importDB(c *gin.Context) { func (a *ServerController) importDB(c *gin.Context) {
file, _, err := c.Request.FormFile("db") file, _, err := c.Request.FormFile("db")

View file

@ -83,6 +83,8 @@ type AllSetting struct {
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint
SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint
SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI
SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"` // Enable global routing rules for Clash/Mihomo
SubClashRules string `json:"subClashRules" form:"subClashRules"` // Clash/Mihomo global routing rules
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"` SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams) SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams)

View file

@ -286,15 +286,17 @@ func (r *Remote) AddClient(ctx context.Context, ib *model.Inbound, client model.
return nil return nil
} }
// DeleteUser is idempotent: master's per-inbound Delete loop may call it func (r *Remote) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
// multiple times for the same node, and "not found" on the follow-ups is
// the expected success path.
func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string) error {
if email == "" { if email == "" {
return nil return nil
} }
_, err := r.do(ctx, http.MethodPost, id, err := r.resolveRemoteID(ctx, ib.Tag)
"panel/api/clients/del/"+url.PathEscape(email), nil) if err != nil {
return nil
}
body := map[string]any{"inboundIds": []int{id}}
_, err = r.do(ctx, http.MethodPost,
"panel/api/clients/"+url.PathEscape(email)+"/detach", body)
if err == nil { if err == nil {
return nil return nil
} }
@ -304,12 +306,17 @@ func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string)
return err return err
} }
func (r *Remote) UpdateUser(ctx context.Context, _ *model.Inbound, oldEmail string, payload model.Client) error { func (r *Remote) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
if oldEmail == "" { if oldEmail == "" {
oldEmail = payload.Email oldEmail = payload.Email
} }
if _, err := r.do(ctx, http.MethodPost, id, err := r.resolveRemoteID(ctx, ib.Tag)
"panel/api/clients/update/"+url.PathEscape(oldEmail), payload); err != nil { if err != nil {
return err
}
path := "panel/api/clients/update/" + url.PathEscape(oldEmail) +
"?inboundIds=" + strconv.Itoa(id)
if _, err := r.do(ctx, http.MethodPost, path, payload); err != nil {
return err return err
} }
return nil return nil

View file

@ -0,0 +1,216 @@
package service
import (
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
xuilogger "github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/xray"
"github.com/op/go-logging"
)
func seedClientTraffics(t *testing.T, inboundId int, clients []model.Client) {
t.Helper()
db := database.GetDB()
rows := make([]xray.ClientTraffic, len(clients))
for i := range clients {
rows[i] = xray.ClientTraffic{
InboundId: inboundId,
Email: clients[i].Email,
Enable: true,
Total: clients[i].TotalGB,
ExpiryTime: clients[i].ExpiryTime,
}
}
if err := db.CreateInBatches(rows, 1000).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
}
// TestAllAPIsPostgresScale exercises every client/inbound/group service method
// reachable from the REST API at 100k/200k clients, asserting none crash on the
// PostgreSQL bind-parameter ceiling and logging the wall-clock cost of each.
func TestAllAPIsPostgresScale(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
}
xuilogger.InitLogger(logging.ERROR)
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := &ClientService{}
inboundSvc := &InboundService{}
settingSvc := &SettingService{}
const userId = 1
const m = 2000
sizes := []int{50000, 100000, 200000}
for _, n := range sizes {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
db := database.GetDB()
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics, client_groups RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
clients := makeScaleClients(n)
exp := time.Now().AddDate(1, 0, 0).UnixMilli()
for i := range clients {
clients[i].ExpiryTime = exp
clients[i].TotalGB = 100 << 30
}
ib := &model.Inbound{UserId: userId, Tag: fmt.Sprintf("all-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
ib2 := &model.Inbound{UserId: userId, Tag: fmt.Sprintf("all2-%d", n), Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
if err := db.Create(ib2).Error; err != nil {
t.Fatalf("create inbound2: %v", err)
}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
run := func(name string, fn func() error) {
start := time.Now()
if err := fn(); err != nil {
t.Fatalf("%s: %v", name, err)
}
t.Logf("N=%-7d %-26s %v", n, name, time.Since(start).Round(time.Millisecond))
}
run("GetInboundDetail(noTraffic)", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
seedClientTraffics(t, ib.Id, clients)
db.Exec("ANALYZE")
emails := make([]string, n)
for i := 0; i < n; i++ {
emails[i] = clients[i].Email
}
emailsM := emails[:m]
run("GetInbounds", func() error { _, err := inboundSvc.GetInbounds(userId); return err })
run("GetInboundsSlim", func() error { _, err := inboundSvc.GetInboundsSlim(userId); return err })
run("GetInboundDetail", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
run("GetInboundOptions", func() error { _, err := inboundSvc.GetInboundOptions(userId); return err })
run("ListPaged", func() error { _, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25}); return err })
run("ListPaged+search", func() error {
_, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25, Search: "user-0012345"})
return err
})
run("GetClientsLastOnline", func() error { _, err := inboundSvc.GetClientsLastOnline(); return err })
run("GetClientTrafficByEmail", func() error { _, err := inboundSvc.GetClientTrafficByEmail(emails[n/2]); return err })
run("GetRecordByEmail", func() error { _, err := svc.GetRecordByEmail(nil, emails[n/2]); return err })
run("ListGroups", func() error { _, err := svc.ListGroups(); return err })
run("AddToGroup(M)", func() error { _, err := svc.AddToGroup(emailsM, "g1"); return err })
run("EmailsByGroup", func() error { _, err := svc.EmailsByGroup("g1"); return err })
run("RenameGroup", func() error { _, err := svc.RenameGroup("g1", "g2"); return err })
run("DeleteGroup", func() error { _, err := svc.DeleteGroup("g2"); return err })
run("ResetInboundTraffic", func() error { return inboundSvc.ResetInboundTraffic(ib.Id) })
run("Inbound.ResetAllTraffics", func() error { return inboundSvc.ResetAllTraffics() })
run("Client.ResetAllTraffics", func() error { _, err := svc.ResetAllTraffics(); return err })
run("BulkResetTraffic(M)", func() error { _, err := svc.BulkResetTraffic(inboundSvc, emailsM); return err })
run("UpdateByEmail", func() error {
upd := clients[n/3]
upd.Comment = "touched"
_, err := svc.UpdateByEmail(inboundSvc, upd.Email, upd)
return err
})
run("AttachByEmail", func() error { _, err := svc.AttachByEmail(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
run("DetachByEmailMany", func() error { _, err := svc.DetachByEmailMany(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
depEmails := emails[:1000]
for _, batch := range chunkStrings(depEmails, sqlInChunk) {
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Update("down", int64(200)<<30).Error; err != nil {
t.Fatalf("mark depleted: %v", err)
}
}
run("DelDepleted(1k)", func() error { _, _, err := svc.DelDepleted(inboundSvc); return err })
run("DelInbound(full)", func() error { _, err := inboundSvc.DelInbound(ib.Id); return err })
})
}
}
// TestGetClientTrafficByEmailABScale measures the GetClientTrafficByEmail change:
// old path (GetClientByEmail, which parses the inbound's entire settings JSON to
// find one client) vs new path (UUID/subId read from the indexed clients table).
func TestGetClientTrafficByEmailABScale(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
}
xuilogger.InitLogger(logging.ERROR)
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := &ClientService{}
inboundSvc := &InboundService{}
const reps = 10
sizes := []int{50000, 100000, 200000}
oldImpl := func(email string) error {
tr, client, err := inboundSvc.GetClientByEmail(email)
if err != nil {
return err
}
if tr != nil && client != nil {
tr.UUID = client.ID
tr.SubId = client.SubID
}
return nil
}
for _, n := range sizes {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
db := database.GetDB()
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
clients := makeScaleClients(n)
ib := &model.Inbound{UserId: 1, Tag: fmt.Sprintf("ctbe-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
seedClientTraffics(t, ib.Id, clients)
db.Exec("ANALYZE")
targets := []string{clients[0].Email, clients[n/2].Email, clients[n-1].Email}
start := time.Now()
for i := 0; i < reps; i++ {
if _, err := inboundSvc.GetClientTrafficByEmail(targets[i%len(targets)]); err != nil {
t.Fatalf("new GetClientTrafficByEmail: %v", err)
}
}
newDur := time.Since(start) / reps
start = time.Now()
for i := 0; i < reps; i++ {
if err := oldImpl(targets[i%len(targets)]); err != nil {
t.Fatalf("old GetClientTrafficByEmail: %v", err)
}
}
oldDur := time.Since(start) / reps
t.Logf("N=%-7d new=%-9v old=%-9v speedup=%.0fx", n,
newDur.Round(time.Microsecond), oldDur.Round(time.Millisecond),
float64(oldDur)/float64(maxDur(newDur, time.Microsecond)))
})
}
}

View file

@ -0,0 +1,149 @@
package service
import (
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/xray"
)
func mkTraffic(t *testing.T, inboundId int, email string, up, down, total, expiry int64, enable bool) {
t.Helper()
row := xray.ClientTraffic{
InboundId: inboundId,
Email: email,
Up: up,
Down: down,
Total: total,
ExpiryTime: expiry,
Enable: enable,
}
if err := database.GetDB().Create(&row).Error; err != nil {
t.Fatalf("create traffic %s: %v", email, err)
}
}
func trafficOf(t *testing.T, email string) xray.ClientTraffic {
t.Helper()
var row xray.ClientTraffic
if err := database.GetDB().Where("email = ?", email).First(&row).Error; err != nil {
t.Fatalf("load traffic %s: %v", email, err)
}
return row
}
func TestBulkResetTrafficZeroesUsageAndReenables(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{
{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
{Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
{Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
}
ib := mkInbound(t, 21001, model.VLESS, clientsSettings(t, source))
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
t.Fatalf("seed linkage: %v", err)
}
mkTraffic(t, ib.Id, "alice@x", 10, 20, 0, 0, false)
mkTraffic(t, ib.Id, "bob@x", 5, 5, 0, 0, true)
mkTraffic(t, ib.Id, "carol@x", 7, 0, 0, 0, true)
affected, err := svc.BulkResetTraffic(inboundSvc, []string{"alice@x", "bob@x"})
if err != nil {
t.Fatalf("BulkResetTraffic: %v", err)
}
if affected != 2 {
t.Fatalf("expected 2 affected, got %d", affected)
}
for _, e := range []string{"alice@x", "bob@x"} {
tr := trafficOf(t, e)
if tr.Up != 0 || tr.Down != 0 {
t.Fatalf("%s: expected up/down 0, got up=%d down=%d", e, tr.Up, tr.Down)
}
if !tr.Enable {
t.Fatalf("%s: expected re-enabled", e)
}
}
carol := trafficOf(t, "carol@x")
if carol.Up != 7 {
t.Fatalf("carol not in list should be untouched, got up=%d", carol.Up)
}
}
func TestDelDepletedRemovesOnlyDepleted(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{
{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
{Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
{Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
}
ib := mkInbound(t, 21002, model.VLESS, clientsSettings(t, source))
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
t.Fatalf("seed linkage: %v", err)
}
past := time.Now().Add(-time.Hour).UnixMilli()
mkTraffic(t, ib.Id, "alice@x", 60, 60, 100, 0, true)
mkTraffic(t, ib.Id, "bob@x", 10, 10, 100, 0, true)
mkTraffic(t, ib.Id, "carol@x", 0, 0, 0, past, true)
deleted, _, err := svc.DelDepleted(inboundSvc)
if err != nil {
t.Fatalf("DelDepleted: %v", err)
}
if deleted != 2 {
t.Fatalf("expected 2 deleted (alice traffic-depleted, carol expired), got %d", deleted)
}
if _, err := svc.GetRecordByEmail(nil, "bob@x"); err != nil {
t.Fatalf("bob should survive: %v", err)
}
for _, e := range []string{"alice@x", "carol@x"} {
if _, err := svc.GetRecordByEmail(nil, e); err == nil {
t.Fatalf("%s should be deleted", e)
}
}
reloaded, _ := inboundSvc.GetInbound(ib.Id)
jsonClients, _ := inboundSvc.GetClients(reloaded)
if len(jsonClients) != 1 || jsonClients[0].Email != "bob@x" {
t.Fatalf("settings JSON should contain only bob, got %d clients", len(jsonClients))
}
}
func TestGetClientTrafficByEmailReadsClientsTable(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{
{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
}
ib := mkInbound(t, 21003, model.VLESS, clientsSettings(t, source))
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
t.Fatalf("seed linkage: %v", err)
}
mkTraffic(t, ib.Id, "alice@x", 1, 2, 0, 0, true)
tr, err := inboundSvc.GetClientTrafficByEmail("alice@x")
if err != nil {
t.Fatalf("GetClientTrafficByEmail: %v", err)
}
if tr == nil {
t.Fatalf("expected traffic, got nil")
}
if tr.UUID != "11111111-1111-1111-1111-111111111111" {
t.Fatalf("UUID not enriched from clients table, got %q", tr.UUID)
}
if tr.SubId != "sa" {
t.Fatalf("SubId not enriched from clients table, got %q", tr.SubId)
}
}

File diff suppressed because it is too large Load diff

View file

@ -83,8 +83,17 @@ func (s *InboundService) enrichClientStats(db *gorm.DB, inbounds []*model.Inboun
emails = append(emails, e) emails = append(emails, e)
} }
var extra []xray.ClientTraffic var extra []xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", emails).Find(&extra).Error; err != nil { var loadErr error
logger.Warning("enrichClientStats:", err) for _, batch := range chunkStrings(emails, sqlInChunk) {
var page []xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
loadErr = err
break
}
extra = append(extra, page...)
}
if loadErr != nil {
logger.Warning("enrichClientStats:", loadErr)
} else { } else {
byEmail := make(map[string]xray.ClientTraffic, len(extra)) byEmail := make(map[string]xray.ClientTraffic, len(extra))
for _, st := range extra { for _, st := range extra {
@ -438,6 +447,37 @@ func (s *InboundService) emailUsedByOtherInbounds(email string, exceptInboundId
return count > 0, nil return count > 0, nil
} }
func (s *InboundService) emailsUsedByOtherInbounds(emails []string, exceptInboundId int) (map[string]bool, error) {
shared := make(map[string]bool, len(emails))
want := make(map[string]struct{}, len(emails))
for _, e := range emails {
e = strings.ToLower(strings.TrimSpace(e))
if e != "" {
want[e] = struct{}{}
}
}
if len(want) == 0 {
return shared, nil
}
db := database.GetDB()
var rows []string
query := fmt.Sprintf(
"SELECT DISTINCT LOWER(%s) %s WHERE inbounds.id != ?",
database.JSONFieldText("client.value", "email"),
database.JSONClientsFromInbound(),
)
if err := db.Raw(query, exceptInboundId).Scan(&rows).Error; err != nil {
return nil, err
}
for _, e := range rows {
e = strings.ToLower(strings.TrimSpace(e))
if _, ok := want[e]; ok {
shared[e] = true
}
}
return shared, nil
}
// normalizeStreamSettings clears StreamSettings for protocols that don't use it. // normalizeStreamSettings clears StreamSettings for protocols that don't use it.
// Only vmess, vless, trojan, shadowsocks, and hysteria protocols use streamSettings. // Only vmess, vless, trojan, shadowsocks, and hysteria protocols use streamSettings.
func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) { func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
@ -2438,6 +2478,32 @@ func (s *InboundService) DelClientIPs(tx *gorm.DB, email string) error {
return tx.Where("client_email = ?", email).Delete(model.InboundClientIps{}).Error return tx.Where("client_email = ?", email).Delete(model.InboundClientIps{}).Error
} }
func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) error {
const chunk = 400
for start := 0; start < len(emails); start += chunk {
end := min(start+chunk, len(emails))
batch := emails[start:end]
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 nil
}
func (s *InboundService) delClientIPsByEmails(tx *gorm.DB, emails []string) error {
const chunk = 400
for start := 0; start < len(emails); start += chunk {
end := min(start+chunk, len(emails))
if err := tx.Where("client_email IN ?", emails[start:end]).Delete(model.InboundClientIps{}).Error; err != nil {
return err
}
}
return nil
}
func (s *InboundService) GetClientInboundByTrafficID(trafficId int) (traffic *xray.ClientTraffic, inbound *model.Inbound, err error) { func (s *InboundService) GetClientInboundByTrafficID(trafficId int) (traffic *xray.ClientTraffic, inbound *model.Inbound, err error) {
db := database.GetDB() db := database.GetDB()
var traffics []*xray.ClientTraffic var traffics []*xray.ClientTraffic
@ -2991,16 +3057,33 @@ func (s *InboundService) GetInboundsTrafficSummary() ([]InboundTrafficSummary, e
} }
func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) { func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) {
// Prefer retrieving along with client to reflect actual enabled state from inbound settings db := database.GetDB()
t, client, err := s.GetClientByEmail(email) var traffics []*xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Find(&traffics).Error; err != nil {
logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
return nil, err
}
if len(traffics) == 0 {
return nil, nil
}
t := traffics[0]
if rec, rErr := s.clientService.GetRecordByEmail(db, email); rErr == nil && rec != nil {
c := rec.ToClient()
t.UUID = c.ID
t.SubId = c.SubID
return t, nil
}
t2, client, err := s.GetClientByEmail(email)
if err != nil { if err != nil {
logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err) logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
return nil, err return nil, err
} }
if t != nil && client != nil { if t2 != nil && client != nil {
t.UUID = client.ID t2.UUID = client.ID
t.SubId = client.SubID t2.SubId = client.SubID
return t, nil return t2, nil
} }
return nil, nil return nil, nil
} }
@ -3329,6 +3412,9 @@ func (s *InboundService) MigrateDB() {
} }
func (s *InboundService) GetOnlineClients() []string { func (s *InboundService) GetOnlineClients() []string {
if p == nil {
return []string{}
}
return p.GetOnlineClients() return p.GetOnlineClients()
} }

View file

@ -1156,6 +1156,41 @@ func (s *ServerService) GetDb() ([]byte, error) {
return fileContents, nil return fileContents, nil
} }
// GetMigration produces a cross-engine migration file plus its filename: on a
// SQLite panel it returns a portable .dump (SQL text), and on a PostgreSQL panel
// it returns a .db SQLite database built from the live data. Either output can
// then seed a panel running on the other backend.
func (s *ServerService) GetMigration() ([]byte, string, error) {
if database.IsPostgres() {
tmp, err := os.CreateTemp("", "x-ui-migration-*.db")
if err != nil {
return nil, "", err
}
tmpPath := tmp.Name()
tmp.Close()
defer os.Remove(tmpPath)
if err := database.ExportPostgresToSQLite(config.GetDBDSN(), tmpPath); err != nil {
return nil, "", err
}
data, err := os.ReadFile(tmpPath)
if err != nil {
return nil, "", err
}
return data, "x-ui.db", nil
}
// SQLite panel: checkpoint so the .db reflects the latest writes, then dump.
if err := database.Checkpoint(); err != nil {
return nil, "", err
}
data, err := database.DumpSQLiteToBytes(config.GetDBPath())
if err != nil {
return nil, "", err
}
return data, "x-ui.dump", nil
}
func (s *ServerService) ImportDB(file multipart.File) error { func (s *ServerService) ImportDB(file multipart.File) error {
if database.IsPostgres() { if database.IsPostgres() {
return s.importPostgresDB(file) return s.importPostgresDB(file)

View file

@ -79,6 +79,8 @@ var defaultValueMap = map[string]string{
"subClashEnable": "false", "subClashEnable": "false",
"subClashPath": "/clash/", "subClashPath": "/clash/",
"subClashURI": "", "subClashURI": "",
"subClashEnableRouting": "false",
"subClashRules": "",
"subJsonMux": "", "subJsonMux": "",
"subJsonRules": "", "subJsonRules": "",
"subJsonFinalMask": "", "subJsonFinalMask": "",
@ -657,6 +659,14 @@ func (s *SettingService) GetSubClashURI() (string, error) {
return s.getString("subClashURI") return s.getString("subClashURI")
} }
func (s *SettingService) GetSubClashEnableRouting() (bool, error) {
return s.getBool("subClashEnableRouting")
}
func (s *SettingService) GetSubClashRules() (string, error) {
return s.getString("subClashRules")
}
func (s *SettingService) GetSubJsonMux() (string, error) { func (s *SettingService) GetSubJsonMux() (string, error) {
return s.getString("subJsonMux") return s.getString("subJsonMux")
} }

View file

@ -0,0 +1,431 @@
package service
import (
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"gorm.io/gorm"
)
func syncInboundOld(tx *gorm.DB, inboundId int, clients []model.Client) error {
if tx == nil {
tx = database.GetDB()
}
if err := tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error; err != nil {
return err
}
for i := range clients {
c := clients[i]
email := strings.TrimSpace(c.Email)
if email == "" {
continue
}
incoming := c.ToRecord()
row := &model.ClientRecord{}
err := tx.Where("email = ?", email).First(row).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if errors.Is(err, gorm.ErrRecordNotFound) {
if err := tx.Create(incoming).Error; err != nil {
return err
}
row = incoming
} else {
row.Flow = incoming.Flow
row.SubID = incoming.SubID
row.LimitIP = incoming.LimitIP
row.TotalGB = incoming.TotalGB
row.ExpiryTime = incoming.ExpiryTime
row.Enable = incoming.Enable
row.TgID = incoming.TgID
row.Comment = incoming.Comment
row.Reset = incoming.Reset
preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
row.UpdatedAt = preservedUpdatedAt
if err := tx.Save(row).Error; err != nil {
return err
}
if err := tx.Model(&model.ClientRecord{}).
Where("id = ?", row.Id).
UpdateColumn("updated_at", preservedUpdatedAt).Error; err != nil {
return err
}
}
link := model.ClientInbound{ClientId: row.Id, InboundId: inboundId, FlowOverride: c.Flow}
if err := tx.Create(&link).Error; err != nil {
return err
}
}
return nil
}
func makeScaleClients(n int) []model.Client {
out := make([]model.Client, n)
for i := 0; i < n; i++ {
out[i] = model.Client{
ID: uuid.NewString(),
Email: fmt.Sprintf("user-%07d@scale", i),
SubID: fmt.Sprintf("sub-%07d", i),
Enable: true,
}
}
return out
}
func TestSyncInboundPostgresScale(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
}
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := &ClientService{}
sizes := []int{5000, 10000, 20000, 50000, 100000, 200000}
for _, n := range sizes {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
db := database.GetDB()
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
clients := makeScaleClients(n)
ib := &model.Inbound{
Tag: fmt.Sprintf("scale-%d", n),
Enable: true,
Port: 40000,
Protocol: model.VLESS,
Settings: clientsSettings(t, clients),
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
start := time.Now()
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
seed := time.Since(start)
clients[n/2].Enable = !clients[n/2].Enable
start = time.Now()
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("toggle SyncInbound (new): %v", err)
}
toggleNew := time.Since(start)
start = time.Now()
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("noop SyncInbound (new): %v", err)
}
noopNew := time.Since(start)
toggleOld := time.Duration(0)
if n <= 10000 {
clients[n/2].Enable = !clients[n/2].Enable
start = time.Now()
if err := syncInboundOld(db, ib.Id, clients); err != nil {
t.Fatalf("toggle SyncInbound (old): %v", err)
}
toggleOld = time.Since(start)
}
var linkCount, recCount int64
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
db.Model(&model.ClientRecord{}).Count(&recCount)
if int(linkCount) != n || int(recCount) != n {
t.Fatalf("row mismatch: links=%d records=%d want %d", linkCount, recCount, n)
}
oldStr, speedup := "skipped", ""
if toggleOld > 0 {
oldStr = toggleOld.Round(time.Millisecond).String()
speedup = fmt.Sprintf(" speedup=%.0fx", float64(toggleOld)/float64(maxDur(toggleNew, time.Millisecond)))
}
t.Logf("N=%-7d seed=%-10v toggle_new=%-10v noop_new=%-10v toggle_old=%-10s%s",
n, seed.Round(time.Millisecond), toggleNew.Round(time.Millisecond),
noopNew.Round(time.Millisecond), oldStr, speedup)
})
}
}
func maxDur(d, floor time.Duration) time.Duration {
if d < floor {
return floor
}
return d
}
func TestAddDelClientPostgresScale(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
}
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := &ClientService{}
inboundSvc := &InboundService{}
sizes := []int{5000, 20000, 50000, 100000, 200000}
for _, n := range sizes {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
db := database.GetDB()
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
clients := makeScaleClients(n)
ib := &model.Inbound{
Tag: fmt.Sprintf("adddel-%d", n),
Enable: true,
Port: 40000,
Protocol: model.VLESS,
Settings: clientsSettings(t, clients),
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
newC := model.Client{
ID: uuid.NewString(),
Email: "added-client@scale",
SubID: "added-sub",
Enable: true,
}
addData := &model.Inbound{Id: ib.Id, Protocol: model.VLESS, Settings: clientsSettings(t, []model.Client{newC})}
start := time.Now()
if _, err := svc.AddInboundClient(inboundSvc, addData); err != nil {
t.Fatalf("AddInboundClient: %v", err)
}
addDur := time.Since(start)
delId := clients[n/2].ID
start = time.Now()
if _, err := svc.DelInboundClient(inboundSvc, ib.Id, delId, false); err != nil {
t.Fatalf("DelInboundClient: %v", err)
}
delDur := time.Since(start)
var recCount, linkCount int64
db.Model(&model.ClientRecord{}).Count(&recCount)
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
t.Logf("N=%-7d add=%-10v del=%-10v records=%d links=%d", n,
addDur.Round(time.Millisecond), delDur.Round(time.Millisecond), recCount, linkCount)
})
}
}
func TestGroupAndListPostgresScale(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
}
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := &ClientService{}
sizes := []int{5000, 100000}
for _, n := range sizes {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
db := database.GetDB()
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
clients := makeScaleClients(n)
ib := &model.Inbound{Tag: fmt.Sprintf("grp-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
db.Exec("ANALYZE")
emails := make([]string, n)
for i := 0; i < n; i++ {
emails[i] = clients[i].Email
}
start := time.Now()
if _, err := svc.AddToGroup(emails, "benchgroup"); err != nil {
t.Fatalf("AddToGroup: %v", err)
}
addDur := time.Since(start)
start = time.Now()
if _, err := svc.RemoveFromGroup(emails); err != nil {
t.Fatalf("RemoveFromGroup: %v", err)
}
rmDur := time.Since(start)
start = time.Now()
list, err := svc.List()
if err != nil {
t.Fatalf("List: %v", err)
}
listDur := time.Since(start)
if len(list) != n {
t.Fatalf("List returned %d, want %d", len(list), n)
}
t.Logf("N=%-7d bulkAdd=%-9v bulkRemove=%-9v list=%-9v", n,
addDur.Round(time.Millisecond), rmDur.Round(time.Millisecond), listDur.Round(time.Millisecond))
})
}
}
func TestDelAllClientsPostgresScale(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
}
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := &ClientService{}
inboundSvc := &InboundService{}
sizes := []int{5000, 50000, 100000}
for _, n := range sizes {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
db := database.GetDB()
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
clients := makeScaleClients(n)
ib := &model.Inbound{Tag: fmt.Sprintf("delall-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
emails, err := inboundSvc.EmailsByInbound(ib.Id)
if err != nil {
t.Fatalf("EmailsByInbound: %v", err)
}
start := time.Now()
res, _, err := svc.BulkDelete(inboundSvc, emails, false)
if err != nil {
t.Fatalf("BulkDelete: %v", err)
}
dur := time.Since(start)
var recCount, linkCount int64
db.Model(&model.ClientRecord{}).Count(&recCount)
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
if recCount != 0 || linkCount != 0 {
t.Fatalf("after delAll: records=%d links=%d want 0/0", recCount, linkCount)
}
t.Logf("N=%-7d delAllClients=%-10v deleted=%d", n, dur.Round(time.Millisecond), res.Deleted)
})
}
}
func TestBulkOpsPostgresScale(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
}
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := &ClientService{}
inboundSvc := &InboundService{}
sizes := []int{5000, 20000, 50000, 100000}
const m = 2000
for _, n := range sizes {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
db := database.GetDB()
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
clients := makeScaleClients(n)
exp := time.Now().AddDate(1, 0, 0).UnixMilli()
for i := range clients {
clients[i].ExpiryTime = exp
clients[i].TotalGB = 100 << 30
}
ib := &model.Inbound{Tag: fmt.Sprintf("bulk-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
ib2 := &model.Inbound{Tag: fmt.Sprintf("bulk2-%d", n), Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
if err := db.Create(ib2).Error; err != nil {
t.Fatalf("create inbound2: %v", err)
}
emailsM := make([]string, m)
for i := 0; i < m; i++ {
emailsM[i] = clients[i].Email
}
t0 := time.Now()
if _, _, err := svc.BulkAdjust(inboundSvc, emailsM, 7, 1<<30); err != nil {
t.Fatalf("BulkAdjust: %v", err)
}
adjustDur := time.Since(t0)
t0 = time.Now()
if _, _, err := svc.BulkAttach(inboundSvc, emailsM, []int{ib2.Id}); err != nil {
t.Fatalf("BulkAttach: %v", err)
}
attachDur := time.Since(t0)
t0 = time.Now()
if _, _, err := svc.BulkDetach(inboundSvc, emailsM, []int{ib2.Id}); err != nil {
t.Fatalf("BulkDetach: %v", err)
}
detachDur := time.Since(t0)
payloads := make([]ClientCreatePayload, m)
for i := 0; i < m; i++ {
payloads[i] = ClientCreatePayload{
Client: model.Client{ID: uuid.NewString(), Email: fmt.Sprintf("bulknew-%07d@scale", i), SubID: fmt.Sprintf("bnsub-%07d", i), Enable: true},
InboundIds: []int{ib.Id},
}
}
t0 = time.Now()
if _, _, err := svc.BulkCreate(inboundSvc, payloads); err != nil {
t.Fatalf("BulkCreate: %v", err)
}
createDur := time.Since(t0)
t0 = time.Now()
if _, _, err := svc.BulkDelete(inboundSvc, emailsM, false); err != nil {
t.Fatalf("BulkDelete: %v", err)
}
deleteDur := time.Since(t0)
t.Logf("N=%-6d M=%d adjust=%-9v attach=%-9v detach=%-9v create=%-9v delete=%-9v", n, m,
adjustDur.Round(time.Millisecond), attachDur.Round(time.Millisecond), detachDur.Round(time.Millisecond),
createDur.Round(time.Millisecond), deleteDur.Round(time.Millisecond))
})
}
}

View file

@ -495,6 +495,10 @@ func (t *Tgbot) OnReceive() {
}, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard"))) }, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard")))
h.HandleMessage(func(ctx *th.Context, message telego.Message) error { h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
if !t.isCommandForCurrentBot(&message) {
return nil
}
// Use goroutine with worker pool for concurrent command processing // Use goroutine with worker pool for concurrent command processing
go func() { go func() {
messageWorkerPool <- struct{}{} // Acquire worker messageWorkerPool <- struct{}{} // Acquire worker
@ -684,6 +688,22 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo
} }
} }
func (t *Tgbot) isCommandForCurrentBot(message *telego.Message) bool {
return isCommandForBot(message.Text, botUsername())
}
func botUsername() string {
if bot == nil {
return ""
}
return bot.Username()
}
func isCommandForBot(text string, username string) bool {
_, commandUsername, _ := tu.ParseCommand(text)
return commandUsername == "" || username == "" || strings.EqualFold(commandUsername, username)
}
// sendResponse sends the response message based on the onlyMessage flag. // sendResponse sends the response message based on the onlyMessage flag.
func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) { func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) {
if onlyMessage { if onlyMessage {

View file

@ -99,3 +99,27 @@ func TestTgbotProxyDialerNoneWhenEmpty(t *testing.T) {
t.Fatal("Dial must be nil when no proxy is configured") t.Fatal("Dial must be nil when no proxy is configured")
} }
} }
func TestIsCommandForBotAllowsUntargetedCommand(t *testing.T) {
if !isCommandForBot("/status", "panel_bot") {
t.Fatal("untargeted commands must remain accepted")
}
}
func TestIsCommandForBotAllowsMatchingUsername(t *testing.T) {
if !isCommandForBot("/status@panel_bot", "Panel_Bot") {
t.Fatal("commands targeted to this bot must be accepted")
}
}
func TestIsCommandForBotRejectsOtherUsername(t *testing.T) {
if isCommandForBot("/status@other_bot", "panel_bot") {
t.Fatal("commands targeted to another bot must be ignored")
}
}
func TestIsCommandForBotKeepsLegacyBehaviorWhenUsernameUnavailable(t *testing.T) {
if !isCommandForBot("/status@panel_bot", "") {
t.Fatal("commands must remain accepted when the current bot username is unavailable")
}
}

View file

@ -277,7 +277,10 @@
"getConfigError": "حدث خطأ أثناء استرجاع ملف الإعدادات", "getConfigError": "حدث خطأ أثناء استرجاع ملف الإعدادات",
"backupPostgresNote": "تعمل هذه اللوحة على PostgreSQL. يقوم «النسخ الاحتياطي» بتنزيل أرشيف pg_dump (.dump)، و«الاستعادة» تعيد تحميله عبر pg_restore. يجب أن تكون أدوات عميل PostgreSQL (pg_dump و pg_restore) مثبَّتة على الخادم.", "backupPostgresNote": "تعمل هذه اللوحة على PostgreSQL. يقوم «النسخ الاحتياطي» بتنزيل أرشيف pg_dump (.dump)، و«الاستعادة» تعيد تحميله عبر pg_restore. يجب أن تكون أدوات عميل PostgreSQL (pg_dump و pg_restore) مثبَّتة على الخادم.",
"exportDatabasePgDesc": "انقر لتنزيل نسخة PostgreSQL (.dump) من قاعدة بياناتك الحالية إلى جهازك.", "exportDatabasePgDesc": "انقر لتنزيل نسخة PostgreSQL (.dump) من قاعدة بياناتك الحالية إلى جهازك.",
"importDatabasePgDesc": "انقر لاختيار ورفع ملف .dump لاستعادة قاعدة بيانات PostgreSQL. سيؤدي هذا إلى استبدال جميع البيانات الحالية." "importDatabasePgDesc": "انقر لاختيار ورفع ملف .dump لاستعادة قاعدة بيانات PostgreSQL. سيؤدي هذا إلى استبدال جميع البيانات الحالية.",
"migrationDownload": "تنزيل ملف الترحيل",
"migrationDownloadDesc": "انقر لتنزيل تصدير .dump محمول (نص SQL) لقاعدة بيانات SQLite الخاصة بك.",
"migrationDownloadPgDesc": "انقر لتنزيل قاعدة بيانات SQLite بامتداد .db مبنية من بيانات PostgreSQL الخاصة بك، جاهزة لتشغيل هذه اللوحة على SQLite."
}, },
"inbounds": { "inbounds": {
"title": "الواردات", "title": "الواردات",
@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)", "subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)",
"subRoutingRules": "قواعد التوجيه", "subRoutingRules": "قواعد التوجيه",
"subRoutingRulesDesc": "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)", "subRoutingRulesDesc": "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)",
"subClashEnableRouting": "تفعيل التوجيه",
"subClashEnableRoutingDesc": "تضمين قواعد توجيه Clash/Mihomo العامة في اشتراكات YAML المُنشأة.",
"subClashRoutingRules": "قواعد التوجيه العامة",
"subClashRoutingRulesDesc": "قواعد Clash/Mihomo التي تُضاف في بداية كل اشتراك YAML قبل MATCH,PROXY.",
"subListen": "IP الاستماع", "subListen": "IP الاستماع",
"subListenDesc": "عنوان IP لخدمة الاشتراك. (سيبه فاضي عشان يستمع على كل الـ IPs)", "subListenDesc": "عنوان IP لخدمة الاشتراك. (سيبه فاضي عشان يستمع على كل الـ IPs)",
"subPort": "بورت الاستماع", "subPort": "بورت الاستماع",

View file

@ -277,7 +277,10 @@
"getConfigError": "An error occurred while retrieving the config file.", "getConfigError": "An error occurred while retrieving the config file.",
"backupPostgresNote": "This panel runs on PostgreSQL. Back Up downloads a pg_dump archive (.dump) and Restore loads it back with pg_restore. The server needs the PostgreSQL client tools (pg_dump and pg_restore) installed.", "backupPostgresNote": "This panel runs on PostgreSQL. Back Up downloads a pg_dump archive (.dump) and Restore loads it back with pg_restore. The server needs the PostgreSQL client tools (pg_dump and pg_restore) installed.",
"exportDatabasePgDesc": "Click to download a PostgreSQL dump (.dump) of your current database to your device.", "exportDatabasePgDesc": "Click to download a PostgreSQL dump (.dump) of your current database to your device.",
"importDatabasePgDesc": "Click to select and upload a .dump file to restore your PostgreSQL database. This replaces all current data." "importDatabasePgDesc": "Click to select and upload a .dump file to restore your PostgreSQL database. This replaces all current data.",
"migrationDownload": "Download Migration",
"migrationDownloadDesc": "Click to download a portable .dump (SQL text) export of your SQLite database.",
"migrationDownloadPgDesc": "Click to download a .db SQLite database built from your PostgreSQL data, ready to run this panel on SQLite."
}, },
"inbounds": { "inbounds": {
"title": "Inbounds", "title": "Inbounds",
@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)", "subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
"subRoutingRules": "Routing rules", "subRoutingRules": "Routing rules",
"subRoutingRulesDesc": "Global routing rules for the VPN client. (Only for Happ)", "subRoutingRulesDesc": "Global routing rules for the VPN client. (Only for Happ)",
"subClashEnableRouting": "Enable routing",
"subClashEnableRoutingDesc": "Include global Clash/Mihomo routing rules in generated YAML subscriptions.",
"subClashRoutingRules": "Global routing rules",
"subClashRoutingRulesDesc": "Default Clash/Mihomo rules prepended to every generated YAML subscription before MATCH,PROXY.",
"subListen": "Listen IP", "subListen": "Listen IP",
"subListenDesc": "The IP address for the subscription service. (leave blank to listen on all IPs)", "subListenDesc": "The IP address for the subscription service. (leave blank to listen on all IPs)",
"subPort": "Listen Port", "subPort": "Listen Port",

View file

@ -277,7 +277,10 @@
"getConfigError": "Ocurrió un error al obtener el archivo de configuración", "getConfigError": "Ocurrió un error al obtener el archivo de configuración",
"backupPostgresNote": "Este panel funciona con PostgreSQL. «Copia de seguridad» descarga un archivo pg_dump (.dump) y «Restaurar» lo vuelve a cargar con pg_restore. El servidor necesita tener instaladas las herramientas cliente de PostgreSQL (pg_dump y pg_restore).", "backupPostgresNote": "Este panel funciona con PostgreSQL. «Copia de seguridad» descarga un archivo pg_dump (.dump) y «Restaurar» lo vuelve a cargar con pg_restore. El servidor necesita tener instaladas las herramientas cliente de PostgreSQL (pg_dump y pg_restore).",
"exportDatabasePgDesc": "Haz clic para descargar un volcado de PostgreSQL (.dump) de tu base de datos actual en tu dispositivo.", "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 un archivo .dump y restaurar tu base de datos PostgreSQL. Esto reemplaza todos los datos actuales." "importDatabasePgDesc": "Haz clic para seleccionar y subir un archivo .dump y restaurar tu base de datos PostgreSQL. Esto reemplaza todos los datos actuales.",
"migrationDownload": "Descargar migración",
"migrationDownloadDesc": "Haz clic para descargar una exportación portable .dump (texto SQL) de tu base de datos 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."
}, },
"inbounds": { "inbounds": {
"title": "Entradas", "title": "Entradas",
@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)", "subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)",
"subRoutingRules": "Reglas de enrutamiento", "subRoutingRules": "Reglas de enrutamiento",
"subRoutingRulesDesc": "Reglas de enrutamiento globales para el cliente VPN. (Solo para Happ)", "subRoutingRulesDesc": "Reglas de enrutamiento globales para el cliente VPN. (Solo para Happ)",
"subClashEnableRouting": "Habilitar enrutamiento",
"subClashEnableRoutingDesc": "Incluir reglas globales de enrutamiento Clash/Mihomo en las suscripciones YAML generadas.",
"subClashRoutingRules": "Reglas globales de enrutamiento",
"subClashRoutingRulesDesc": "Reglas Clash/Mihomo agregadas al inicio de cada suscripción YAML antes de MATCH,PROXY.",
"subListen": "Listening IP", "subListen": "Listening IP",
"subListenDesc": "Dejar en blanco por defecto para monitorear todas las IPs.", "subListenDesc": "Dejar en blanco por defecto para monitorear todas las IPs.",
"subPort": "Puerto de Suscripción", "subPort": "Puerto de Suscripción",

View file

@ -277,7 +277,10 @@
"getConfigError": "خطا در دریافت فایل پیکربندی", "getConfigError": "خطا در دریافت فایل پیکربندی",
"backupPostgresNote": "این پنل روی PostgreSQL اجرا می‌شود. «پشتیبان‌گیری» یک آرشیو pg_dump (.dump) دانلود می‌کند و «بازیابی» آن را با pg_restore بازمی‌گرداند. سرور باید ابزارهای کلاینت PostgreSQL (pg_dump و pg_restore) را نصب داشته باشد.", "backupPostgresNote": "این پنل روی PostgreSQL اجرا می‌شود. «پشتیبان‌گیری» یک آرشیو pg_dump (.dump) دانلود می‌کند و «بازیابی» آن را با pg_restore بازمی‌گرداند. سرور باید ابزارهای کلاینت PostgreSQL (pg_dump و pg_restore) را نصب داشته باشد.",
"exportDatabasePgDesc": "برای دانلود یک دامپ PostgreSQL (.dump) از پایگاه داده فعلی روی دستگاهتان کلیک کنید.", "exportDatabasePgDesc": "برای دانلود یک دامپ PostgreSQL (.dump) از پایگاه داده فعلی روی دستگاهتان کلیک کنید.",
"importDatabasePgDesc": "برای انتخاب و بارگذاری یک فایل .dump جهت بازیابی پایگاه داده PostgreSQL کلیک کنید. این کار همه داده‌های فعلی را جایگزین می‌کند." "importDatabasePgDesc": "برای انتخاب و بارگذاری یک فایل .dump جهت بازیابی پایگاه داده PostgreSQL کلیک کنید. این کار همه داده‌های فعلی را جایگزین می‌کند.",
"migrationDownload": "دانلود فایل مهاجرت",
"migrationDownloadDesc": "برای دانلود یک خروجی قابل‌حمل .dump (متن SQL) از پایگاه‌دادهٔ SQLite خود کلیک کنید.",
"migrationDownloadPgDesc": "برای دانلود یک پایگاه‌دادهٔ SQLite با پسوند .db که از داده‌های PostgreSQL شما ساخته می‌شود کلیک کنید؛ آمادهٔ اجرای این پنل روی SQLite."
}, },
"inbounds": { "inbounds": {
"title": "ورودی‌ها", "title": "ورودی‌ها",
@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "تنظیمات سراسری برای فعال‌سازی مسیریابی در کلاینت VPN. (فقط برای Happ)", "subEnableRoutingDesc": "تنظیمات سراسری برای فعال‌سازی مسیریابی در کلاینت VPN. (فقط برای Happ)",
"subRoutingRules": "قوانین مسیریابی", "subRoutingRules": "قوانین مسیریابی",
"subRoutingRulesDesc": "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)", "subRoutingRulesDesc": "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)",
"subClashEnableRouting": "فعال‌سازی مسیریابی",
"subClashEnableRoutingDesc": "قوانین مسیریابی سراسری Clash/Mihomo را در اشتراک‌های YAML تولیدشده وارد کن.",
"subClashRoutingRules": "قوانین مسیریابی سراسری",
"subClashRoutingRulesDesc": "قوانین Clash/Mihomo که پیش از MATCH,PROXY به ابتدای هر اشتراک YAML افزوده می‌شوند.",
"subListen": "آدرس آی‌پی", "subListen": "آدرس آی‌پی",
"subListenDesc": "آدرس آی‌پی برای سرویس سابسکریپشن. برای گوش دادن به‌تمام آی‌پی‌ها خالی‌بگذارید", "subListenDesc": "آدرس آی‌پی برای سرویس سابسکریپشن. برای گوش دادن به‌تمام آی‌پی‌ها خالی‌بگذارید",
"subPort": "پورت", "subPort": "پورت",

View file

@ -277,7 +277,10 @@
"getConfigError": "Terjadi kesalahan saat mengambil file konfigurasi", "getConfigError": "Terjadi kesalahan saat mengambil file konfigurasi",
"backupPostgresNote": "Panel ini berjalan di PostgreSQL. «Cadangkan» mengunduh arsip pg_dump (.dump) dan «Pulihkan» memuatnya kembali dengan pg_restore. Server memerlukan alat klien PostgreSQL (pg_dump dan pg_restore) terpasang.", "backupPostgresNote": "Panel ini berjalan di PostgreSQL. «Cadangkan» mengunduh arsip pg_dump (.dump) dan «Pulihkan» memuatnya kembali dengan pg_restore. Server memerlukan alat klien PostgreSQL (pg_dump dan pg_restore) terpasang.",
"exportDatabasePgDesc": "Klik untuk mengunduh dump PostgreSQL (.dump) dari basis data Anda saat ini ke perangkat Anda.", "exportDatabasePgDesc": "Klik untuk mengunduh dump PostgreSQL (.dump) dari basis data Anda saat ini ke perangkat Anda.",
"importDatabasePgDesc": "Klik untuk memilih dan mengunggah berkas .dump guna memulihkan basis data PostgreSQL Anda. Ini menggantikan semua data saat ini." "importDatabasePgDesc": "Klik untuk memilih dan mengunggah berkas .dump guna memulihkan basis data PostgreSQL Anda. Ini menggantikan semua data saat ini.",
"migrationDownload": "Unduh migrasi",
"migrationDownloadDesc": "Klik untuk mengunduh ekspor .dump (teks SQL) portabel dari basis data SQLite Anda.",
"migrationDownloadPgDesc": "Klik untuk mengunduh basis data SQLite .db yang dibuat dari data PostgreSQL Anda, siap menjalankan panel ini di SQLite."
}, },
"inbounds": { "inbounds": {
"title": "Inbound", "title": "Inbound",
@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)", "subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)",
"subRoutingRules": "Aturan routing", "subRoutingRules": "Aturan routing",
"subRoutingRulesDesc": "Aturan routing global untuk klien VPN. (Hanya untuk Happ)", "subRoutingRulesDesc": "Aturan routing global untuk klien VPN. (Hanya untuk Happ)",
"subClashEnableRouting": "Aktifkan routing",
"subClashEnableRoutingDesc": "Sertakan aturan routing global Clash/Mihomo dalam langganan YAML yang dibuat.",
"subClashRoutingRules": "Aturan routing global",
"subClashRoutingRulesDesc": "Aturan Clash/Mihomo yang ditambahkan di awal setiap langganan YAML sebelum MATCH,PROXY.",
"subListen": "IP Pendengar", "subListen": "IP Pendengar",
"subListenDesc": "Alamat IP untuk layanan langganan. (biarkan kosong untuk mendengarkan semua IP)", "subListenDesc": "Alamat IP untuk layanan langganan. (biarkan kosong untuk mendengarkan semua IP)",
"subPort": "Port Pendengar", "subPort": "Port Pendengar",

View file

@ -277,7 +277,10 @@
"getConfigError": "設定ファイルの取得中にエラーが発生しました", "getConfigError": "設定ファイルの取得中にエラーが発生しました",
"backupPostgresNote": "このパネルは PostgreSQL で動作しています。「バックアップ」は pg_dump アーカイブ (.dump) をダウンロードし、「復元」は pg_restore で読み込み直します。サーバーに PostgreSQL クライアントツール (pg_dump と pg_restore) がインストールされている必要があります。", "backupPostgresNote": "このパネルは PostgreSQL で動作しています。「バックアップ」は pg_dump アーカイブ (.dump) をダウンロードし、「復元」は pg_restore で読み込み直します。サーバーに PostgreSQL クライアントツール (pg_dump と pg_restore) がインストールされている必要があります。",
"exportDatabasePgDesc": "現在のデータベースの PostgreSQL ダンプ (.dump) を端末にダウンロードするにはクリックしてください。", "exportDatabasePgDesc": "現在のデータベースの PostgreSQL ダンプ (.dump) を端末にダウンロードするにはクリックしてください。",
"importDatabasePgDesc": "PostgreSQL データベースを復元するために .dump ファイルを選択してアップロードするにはクリックしてください。現在のすべてのデータが置き換えられます。" "importDatabasePgDesc": "PostgreSQL データベースを復元するために .dump ファイルを選択してアップロードするにはクリックしてください。現在のすべてのデータが置き換えられます。",
"migrationDownload": "移行ファイルをダウンロード",
"migrationDownloadDesc": "SQLite データベースのポータブルな .dumpSQL テキスト)エクスポートをダウンロードするにはクリックします。",
"migrationDownloadPgDesc": "PostgreSQL のデータから作成した .db SQLite データベースをダウンロードします。このパネルを SQLite で実行する準備が整います。"
}, },
"inbounds": { "inbounds": {
"title": "インバウンド", "title": "インバウンド",
@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)", "subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)",
"subRoutingRules": "ルーティングルール", "subRoutingRules": "ルーティングルール",
"subRoutingRulesDesc": "VPNクライアントのグローバルルーティングルール。(Happのみ)", "subRoutingRulesDesc": "VPNクライアントのグローバルルーティングルール。(Happのみ)",
"subClashEnableRouting": "ルーティングを有効化",
"subClashEnableRoutingDesc": "生成されたYAMLサブスクリプションにClash/Mihomoのグローバルルーティングルールを含めます。",
"subClashRoutingRules": "グローバルルーティングルール",
"subClashRoutingRulesDesc": "各YAMLサブスクリプションのMATCH,PROXYより前に追加されるClash/Mihomoルール。",
"subListen": "監視IP", "subListen": "監視IP",
"subListenDesc": "サブスクリプションサービスが監視するIPアドレス空白にするとすべてのIPを監視", "subListenDesc": "サブスクリプションサービスが監視するIPアドレス空白にするとすべてのIPを監視",
"subPort": "監視ポート", "subPort": "監視ポート",

View file

@ -277,7 +277,10 @@
"getConfigError": "Ocorreu um erro ao recuperar o arquivo de configuração", "getConfigError": "Ocorreu um erro ao recuperar o arquivo de configuração",
"backupPostgresNote": "Este painel é executado em PostgreSQL. «Backup» baixa um arquivo pg_dump (.dump) e «Restaurar» o recarrega com pg_restore. O servidor precisa ter as ferramentas cliente do PostgreSQL (pg_dump e pg_restore) instaladas.", "backupPostgresNote": "Este painel é executado em PostgreSQL. «Backup» baixa um arquivo pg_dump (.dump) e «Restaurar» o recarrega com pg_restore. O servidor precisa ter as ferramentas cliente do PostgreSQL (pg_dump e pg_restore) instaladas.",
"exportDatabasePgDesc": "Clique para baixar um dump do PostgreSQL (.dump) do seu banco de dados atual para o seu dispositivo.", "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 arquivo .dump para restaurar seu banco de dados PostgreSQL. Isso substitui todos os dados atuais." "importDatabasePgDesc": "Clique para selecionar e enviar um arquivo .dump para restaurar seu banco de dados PostgreSQL. Isso substitui todos os dados atuais.",
"migrationDownload": "Baixar migração",
"migrationDownloadDesc": "Clique para baixar uma exportação portátil .dump (texto SQL) do seu banco de dados 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."
}, },
"inbounds": { "inbounds": {
"title": "Entradas", "title": "Entradas",
@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)", "subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)",
"subRoutingRules": "Regras de roteamento", "subRoutingRules": "Regras de roteamento",
"subRoutingRulesDesc": "Regras de roteamento globais para o cliente VPN. (Apenas para Happ)", "subRoutingRulesDesc": "Regras de roteamento globais para o cliente VPN. (Apenas para Happ)",
"subClashEnableRouting": "Ativar roteamento",
"subClashEnableRoutingDesc": "Incluir regras globais de roteamento Clash/Mihomo nas assinaturas YAML geradas.",
"subClashRoutingRules": "Regras globais de roteamento",
"subClashRoutingRulesDesc": "Regras Clash/Mihomo adicionadas ao início de cada assinatura YAML antes de MATCH,PROXY.",
"subListen": "IP de Escuta", "subListen": "IP de Escuta",
"subListenDesc": "O endereço IP para o serviço de assinatura. (deixe em branco para escutar em todos os IPs)", "subListenDesc": "O endereço IP para o serviço de assinatura. (deixe em branco para escutar em todos os IPs)",
"subPort": "Porta de Escuta", "subPort": "Porta de Escuta",

View file

@ -277,7 +277,10 @@
"getConfigError": "Произошла ошибка при получении конфигурационного файла", "getConfigError": "Произошла ошибка при получении конфигурационного файла",
"backupPostgresNote": "Эта панель работает на PostgreSQL. «Резервная копия» скачивает архив pg_dump (.dump), а «Восстановление» загружает его обратно через pg_restore. На сервере должны быть установлены клиентские инструменты PostgreSQL (pg_dump и pg_restore).", "backupPostgresNote": "Эта панель работает на PostgreSQL. «Резервная копия» скачивает архив pg_dump (.dump), а «Восстановление» загружает его обратно через pg_restore. На сервере должны быть установлены клиентские инструменты PostgreSQL (pg_dump и pg_restore).",
"exportDatabasePgDesc": "Нажмите, чтобы скачать дамп PostgreSQL (.dump) текущей базы данных на ваше устройство.", "exportDatabasePgDesc": "Нажмите, чтобы скачать дамп PostgreSQL (.dump) текущей базы данных на ваше устройство.",
"importDatabasePgDesc": "Нажмите, чтобы выбрать и загрузить файл .dump для восстановления базы данных PostgreSQL. Это заменит все текущие данные." "importDatabasePgDesc": "Нажмите, чтобы выбрать и загрузить файл .dump для восстановления базы данных PostgreSQL. Это заменит все текущие данные.",
"migrationDownload": "Скачать файл миграции",
"migrationDownloadDesc": "Нажмите, чтобы скачать переносимый экспорт .dump (текст SQL) вашей базы данных SQLite.",
"migrationDownloadPgDesc": "Нажмите, чтобы скачать базу данных SQLite (.db), собранную из ваших данных PostgreSQL и готовую для запуска панели на SQLite."
}, },
"inbounds": { "inbounds": {
"title": "Входящие", "title": "Входящие",
@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)", "subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)",
"subRoutingRules": "Правила маршрутизации", "subRoutingRules": "Правила маршрутизации",
"subRoutingRulesDesc": "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)", "subRoutingRulesDesc": "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)",
"subClashEnableRouting": "Включить маршрутизацию",
"subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.",
"subClashRoutingRules": "Глобальные правила маршрутизации",
"subClashRoutingRulesDesc": "Правила Clash/Mihomo, добавляемые в начало каждой YAML-подписки перед MATCH,PROXY.",
"subListen": "Прослушивание IP", "subListen": "Прослушивание IP",
"subListenDesc": "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса", "subListenDesc": "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса",
"subPort": "Порт подписки", "subPort": "Порт подписки",

View file

@ -277,7 +277,10 @@
"getConfigError": "Yapılandırma dosyası alınırken bir hata oluştu", "getConfigError": "Yapılandırma dosyası alınırken bir hata oluştu",
"backupPostgresNote": "Bu panel PostgreSQL üzerinde çalışıyor. «Yedekle» bir pg_dump arşivi (.dump) indirir, «Geri Yükle» ise onu pg_restore ile geri yükler. Sunucuda PostgreSQL istemci araçlarının (pg_dump ve pg_restore) kurulu olması gerekir.", "backupPostgresNote": "Bu panel PostgreSQL üzerinde çalışıyor. «Yedekle» bir pg_dump arşivi (.dump) indirir, «Geri Yükle» ise onu pg_restore ile geri yükler. Sunucuda PostgreSQL istemci araçlarının (pg_dump ve pg_restore) kurulu olması gerekir.",
"exportDatabasePgDesc": "Mevcut veritabanınızın PostgreSQL dökümünü (.dump) cihazınıza indirmek için tıklayın.", "exportDatabasePgDesc": "Mevcut veritabanınızın PostgreSQL dökümünü (.dump) cihazınıza indirmek için tıklayın.",
"importDatabasePgDesc": "PostgreSQL veritabanınızı geri yüklemek için bir .dump dosyası seçip yüklemek üzere tıklayın. Bu, tüm mevcut verilerin yerini alır." "importDatabasePgDesc": "PostgreSQL veritabanınızı geri yüklemek için bir .dump dosyası seçip yüklemek üzere tıklayın. Bu, tüm mevcut verilerin yerini alır.",
"migrationDownload": "Geçiş dosyasını indir",
"migrationDownloadDesc": "SQLite veritabanınızın taşınabilir .dump (SQL metni) dışa aktarımını indirmek için tıklayın.",
"migrationDownloadPgDesc": "PostgreSQL verilerinizden oluşturulan ve bu paneli SQLite üzerinde çalıştırmaya hazır bir .db SQLite veritabanı indirmek için tıklayın."
}, },
"inbounds": { "inbounds": {
"title": "Gelenler", "title": "Gelenler",
@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "VPN istemcisinde yönlendirmeyi etkinleştirmek için genel ayar. (Yalnızca Happ için)", "subEnableRoutingDesc": "VPN istemcisinde yönlendirmeyi etkinleştirmek için genel ayar. (Yalnızca Happ için)",
"subRoutingRules": "Yönlendirme kuralları", "subRoutingRules": "Yönlendirme kuralları",
"subRoutingRulesDesc": "VPN istemcisi için genel yönlendirme kuralları. (Yalnızca Happ için)", "subRoutingRulesDesc": "VPN istemcisi için genel yönlendirme kuralları. (Yalnızca Happ için)",
"subClashEnableRouting": "Yönlendirmeyi etkinleştir",
"subClashEnableRoutingDesc": "Oluşturulan YAML aboneliklerine genel Clash/Mihomo yönlendirme kurallarını ekle.",
"subClashRoutingRules": "Genel yönlendirme kuralları",
"subClashRoutingRulesDesc": "Her YAML aboneliğinin başına MATCH,PROXY öncesinde eklenen Clash/Mihomo kuralları.",
"subListen": "Dinleme IP", "subListen": "Dinleme IP",
"subListenDesc": "Abonelik hizmeti için IP adresi. (tüm IP'leri dinlemek için boş bırakın)", "subListenDesc": "Abonelik hizmeti için IP adresi. (tüm IP'leri dinlemek için boş bırakın)",
"subPort": "Dinleme Portu", "subPort": "Dinleme Portu",

View file

@ -277,7 +277,10 @@
"getConfigError": "Виникла помилка під час отримання файлу конфігурації", "getConfigError": "Виникла помилка під час отримання файлу конфігурації",
"backupPostgresNote": "Ця панель працює на PostgreSQL. «Резервна копія» завантажує архів pg_dump (.dump), а «Відновлення» завантажує його назад через pg_restore. На сервері мають бути встановлені клієнтські інструменти PostgreSQL (pg_dump і pg_restore).", "backupPostgresNote": "Ця панель працює на PostgreSQL. «Резервна копія» завантажує архів pg_dump (.dump), а «Відновлення» завантажує його назад через pg_restore. На сервері мають бути встановлені клієнтські інструменти PostgreSQL (pg_dump і pg_restore).",
"exportDatabasePgDesc": "Натисніть, щоб завантажити дамп PostgreSQL (.dump) вашої поточної бази даних на ваш пристрій.", "exportDatabasePgDesc": "Натисніть, щоб завантажити дамп PostgreSQL (.dump) вашої поточної бази даних на ваш пристрій.",
"importDatabasePgDesc": "Натисніть, щоб вибрати та завантажити файл .dump для відновлення бази даних PostgreSQL. Це замінить усі поточні дані." "importDatabasePgDesc": "Натисніть, щоб вибрати та завантажити файл .dump для відновлення бази даних PostgreSQL. Це замінить усі поточні дані.",
"migrationDownload": "Завантажити файл міграції",
"migrationDownloadDesc": "Натисніть, щоб завантажити переносний експорт .dump (текст SQL) вашої бази даних SQLite.",
"migrationDownloadPgDesc": "Натисніть, щоб завантажити базу даних SQLite (.db), створену з ваших даних PostgreSQL і готову для запуску панелі на SQLite."
}, },
"inbounds": { "inbounds": {
"title": "Вхідні", "title": "Вхідні",
@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)", "subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)",
"subRoutingRules": "Правила маршрутизації", "subRoutingRules": "Правила маршрутизації",
"subRoutingRulesDesc": "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)", "subRoutingRulesDesc": "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)",
"subClashEnableRouting": "Увімкнути маршрутизацію",
"subClashEnableRoutingDesc": "Додавати глобальні правила маршрутизації Clash/Mihomo до згенерованих YAML-підписок.",
"subClashRoutingRules": "Глобальні правила маршрутизації",
"subClashRoutingRulesDesc": "Правила Clash/Mihomo, що додаються на початок кожної YAML-підписки перед MATCH,PROXY.",
"subListen": "Слухати IP", "subListen": "Слухати IP",
"subListenDesc": "IP-адреса для служби підписки. (залиште порожнім, щоб слухати всі IP-адреси)", "subListenDesc": "IP-адреса для служби підписки. (залиште порожнім, щоб слухати всі IP-адреси)",
"subPort": "Слухати порт", "subPort": "Слухати порт",

View file

@ -277,7 +277,10 @@
"getConfigError": "Lỗi xảy ra khi truy xuất tệp cấu hình", "getConfigError": "Lỗi xảy ra khi truy xuất tệp cấu hình",
"backupPostgresNote": "Bảng điều khiển này chạy trên PostgreSQL. «Sao lưu» tải xuống một tệp lưu trữ pg_dump (.dump) và «Khôi phục» nạp lại bằng pg_restore. Máy chủ cần cài đặt các công cụ máy khách PostgreSQL (pg_dump và pg_restore).", "backupPostgresNote": "Bảng điều khiển này chạy trên PostgreSQL. «Sao lưu» tải xuống một tệp lưu trữ pg_dump (.dump) và «Khôi phục» nạp lại bằng pg_restore. Máy chủ cần cài đặt các công cụ máy khách PostgreSQL (pg_dump và pg_restore).",
"exportDatabasePgDesc": "Nhấn để tải xuống bản kết xuất PostgreSQL (.dump) của cơ sở dữ liệu hiện tại về thiết bị của bạn.", "exportDatabasePgDesc": "Nhấn để tải xuống bản kết xuất PostgreSQL (.dump) của cơ sở dữ liệu hiện tại về thiết bị của bạn.",
"importDatabasePgDesc": "Nhấn để chọn và tải lên một tệp .dump nhằm khôi phục cơ sở dữ liệu PostgreSQL của bạn. Thao tác này sẽ thay thế toàn bộ dữ liệu hiện tại." "importDatabasePgDesc": "Nhấn để chọn và tải lên một tệp .dump nhằm khôi phục cơ sở dữ liệu PostgreSQL của bạn. Thao tác này sẽ thay thế toàn bộ dữ liệu hiện tại.",
"migrationDownload": "Tải tệp di trú",
"migrationDownloadDesc": "Nhấp để tải xuống bản xuất .dump (văn bản SQL) di động của cơ sở dữ liệu SQLite của bạn.",
"migrationDownloadPgDesc": "Nhấp để tải xuống cơ sở dữ liệu SQLite .db được tạo từ dữ liệu PostgreSQL của bạn, sẵn sàng chạy bảng điều khiển này trên SQLite."
}, },
"inbounds": { "inbounds": {
"title": "Inbound", "title": "Inbound",
@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)", "subEnableRoutingDesc": "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
"subRoutingRules": "Quy tắc định tuyến", "subRoutingRules": "Quy tắc định tuyến",
"subRoutingRulesDesc": "Quy tắc định tuyến toàn cầu cho client VPN. (Chỉ dành cho Happ)", "subRoutingRulesDesc": "Quy tắc định tuyến toàn cầu cho client VPN. (Chỉ dành cho Happ)",
"subClashEnableRouting": "Bật định tuyến",
"subClashEnableRoutingDesc": "Bao gồm quy tắc định tuyến Clash/Mihomo toàn cầu trong các đăng ký YAML được tạo.",
"subClashRoutingRules": "Quy tắc định tuyến toàn cầu",
"subClashRoutingRulesDesc": "Quy tắc Clash/Mihomo được thêm vào đầu mỗi đăng ký YAML trước MATCH,PROXY.",
"subListen": "Listening IP", "subListen": "Listening IP",
"subListenDesc": "Mặc định để trống để nghe tất cả các IP", "subListenDesc": "Mặc định để trống để nghe tất cả các IP",
"subPort": "Cổng gói đăng ký", "subPort": "Cổng gói đăng ký",

View file

@ -277,7 +277,10 @@
"getConfigError": "检索配置文件时出错", "getConfigError": "检索配置文件时出错",
"backupPostgresNote": "此面板运行在 PostgreSQL 上。「备份」会下载一个 pg_dump 归档(.dump「恢复」会通过 pg_restore 重新载入。服务器需要安装 PostgreSQL 客户端工具pg_dump 和 pg_restore。", "backupPostgresNote": "此面板运行在 PostgreSQL 上。「备份」会下载一个 pg_dump 归档(.dump「恢复」会通过 pg_restore 重新载入。服务器需要安装 PostgreSQL 客户端工具pg_dump 和 pg_restore。",
"exportDatabasePgDesc": "点击将当前数据库的 PostgreSQL 转储(.dump下载到您的设备。", "exportDatabasePgDesc": "点击将当前数据库的 PostgreSQL 转储(.dump下载到您的设备。",
"importDatabasePgDesc": "点击选择并上传 .dump 文件以恢复您的 PostgreSQL 数据库。此操作将替换所有当前数据。" "importDatabasePgDesc": "点击选择并上传 .dump 文件以恢复您的 PostgreSQL 数据库。此操作将替换所有当前数据。",
"migrationDownload": "下载迁移文件",
"migrationDownloadDesc": "点击下载当前 SQLite 数据库的可移植 .dumpSQL 文本)导出文件。",
"migrationDownloadPgDesc": "点击下载由 PostgreSQL 数据构建的 .db SQLite 数据库,可用于在 SQLite 上运行本面板。"
}, },
"inbounds": { "inbounds": {
"title": "入站", "title": "入站",
@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(僅限 Happ", "subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(僅限 Happ",
"subRoutingRules": "路由規則", "subRoutingRules": "路由規則",
"subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ", "subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ",
"subClashEnableRouting": "启用路由",
"subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。",
"subClashRoutingRules": "全局路由规则",
"subClashRoutingRulesDesc": "添加到每个 YAML 订阅开头、MATCH,PROXY 之前的 Clash/Mihomo 规则。",
"subListen": "监听 IP", "subListen": "监听 IP",
"subListenDesc": "订阅服务监听的 IP 地址(留空表示监听所有 IP", "subListenDesc": "订阅服务监听的 IP 地址(留空表示监听所有 IP",
"subPort": "监听端口", "subPort": "监听端口",

View file

@ -277,7 +277,10 @@
"getConfigError": "檢索設定檔時發生錯誤", "getConfigError": "檢索設定檔時發生錯誤",
"backupPostgresNote": "此面板執行於 PostgreSQL 上。「備份」會下載一個 pg_dump 封存檔(.dump「還原」會透過 pg_restore 重新載入。伺服器需要安裝 PostgreSQL 用戶端工具pg_dump 與 pg_restore。", "backupPostgresNote": "此面板執行於 PostgreSQL 上。「備份」會下載一個 pg_dump 封存檔(.dump「還原」會透過 pg_restore 重新載入。伺服器需要安裝 PostgreSQL 用戶端工具pg_dump 與 pg_restore。",
"exportDatabasePgDesc": "點擊將目前資料庫的 PostgreSQL 傾印(.dump下載到您的裝置。", "exportDatabasePgDesc": "點擊將目前資料庫的 PostgreSQL 傾印(.dump下載到您的裝置。",
"importDatabasePgDesc": "點擊選擇並上傳 .dump 檔案以還原您的 PostgreSQL 資料庫。此操作將取代所有目前的資料。" "importDatabasePgDesc": "點擊選擇並上傳 .dump 檔案以還原您的 PostgreSQL 資料庫。此操作將取代所有目前的資料。",
"migrationDownload": "下載遷移檔案",
"migrationDownloadDesc": "點擊下載目前 SQLite 資料庫的可攜式 .dumpSQL 文字)匯出檔。",
"migrationDownloadPgDesc": "點擊下載由 PostgreSQL 資料建立的 .db SQLite 資料庫,可用於在 SQLite 上執行本面板。"
}, },
"inbounds": { "inbounds": {
"title": "入站", "title": "入站",
@ -1001,6 +1004,10 @@
"subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ", "subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ",
"subRoutingRules": "路由規則", "subRoutingRules": "路由規則",
"subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ", "subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ",
"subClashEnableRouting": "啟用路由",
"subClashEnableRoutingDesc": "在產生的 YAML 訂閱中包含 Clash/Mihomo 全域路由規則。",
"subClashRoutingRules": "全域路由規則",
"subClashRoutingRulesDesc": "加入到每個 YAML 訂閱開頭、MATCH,PROXY 之前的 Clash/Mihomo 規則。",
"subListen": "監聽 IP", "subListen": "監聽 IP",
"subListenDesc": "訂閱服務監聽的 IP 地址(留空表示監聽所有 IP", "subListenDesc": "訂閱服務監聽的 IP 地址(留空表示監聽所有 IP",
"subPort": "監聽埠", "subPort": "監聽埠",

69
x-ui.sh
View file

@ -1269,6 +1269,16 @@ ssl_cert_issue_main() {
echo "Panel paths set for domain: $domain" echo "Panel paths set for domain: $domain"
echo " - Certificate File: $webCertFile" echo " - Certificate File: $webCertFile"
echo " - Private Key File: $webKeyFile" echo " - Private Key File: $webKeyFile"
# Register the acme.sh install-cert hook so auto-renewal copies the
# renewed cert to these paths and reloads the panel. Without it acme.sh
# renews but never updates /root/cert, silently serving a stale cert.
if command -v ~/.acme.sh/acme.sh &> /dev/null && ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
~/.acme.sh/acme.sh --installcert -d "${domain}" \
--key-file "${webKeyFile}" \
--fullchain-file "${webCertFile}" \
--reloadcmd "x-ui restart" 2>&1 || true
echo "Registered acme.sh auto-renewal hook for ${domain}."
fi
restart restart
else else
echo "Certificate or private key not found for domain: $domain." echo "Certificate or private key not found for domain: $domain."
@ -1448,8 +1458,8 @@ ssl_cert_issue_for_ip() {
LOGE "Failed to issue certificate for IP: ${server_ip}" LOGE "Failed to issue certificate for IP: ${server_ip}"
LOGE "Make sure port ${WebPort} is open and the server is accessible from the internet" LOGE "Make sure port ${WebPort} is open and the server is accessible from the internet"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified # Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${server_ip} 2> /dev/null rm -rf ~/.acme.sh/${server_ip} ~/.acme.sh/${server_ip}_ecc 2> /dev/null
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} 2> /dev/null [[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} ~/.acme.sh/${ipv6_addr}_ecc 2> /dev/null
rm -rf ${certPath} 2> /dev/null rm -rf ${certPath} 2> /dev/null
return 1 return 1
else else
@ -1468,8 +1478,8 @@ ssl_cert_issue_for_ip() {
if [[ ! -f "${certPath}/fullchain.pem" || ! -f "${certPath}/privkey.pem" ]]; then if [[ ! -f "${certPath}/fullchain.pem" || ! -f "${certPath}/privkey.pem" ]]; then
LOGE "Certificate files not found after installation" LOGE "Certificate files not found after installation"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified # Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${server_ip} 2> /dev/null rm -rf ~/.acme.sh/${server_ip} ~/.acme.sh/${server_ip}_ecc 2> /dev/null
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} 2> /dev/null [[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} ~/.acme.sh/${ipv6_addr}_ecc 2> /dev/null
rm -rf ${certPath} 2> /dev/null rm -rf ${certPath} 2> /dev/null
return 1 return 1
fi fi
@ -1576,14 +1586,30 @@ ssl_cert_issue() {
LOGD "Your domain is: ${domain}, checking it..." LOGD "Your domain is: ${domain}, checking it..."
SSL_ISSUED_DOMAIN="${domain}" SSL_ISSUED_DOMAIN="${domain}"
# detect existing certificate and reuse it if present # detect existing certificate and reuse it only if its files are actually
# present and non-empty. acme.sh stores ECC certs under ${domain}_ecc and RSA
# certs under ${domain}; a failed issuance can leave a domain entry in --list
# with no usable cert files, which must not be reused (it produces a 0-byte
# fullchain.pem). Broken partial state is cleaned up so issuance can proceed.
local cert_exists=0 local cert_exists=0
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
cert_exists=1 local acmeCertDir=""
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}") if [[ -s ~/.acme.sh/${domain}_ecc/fullchain.cer && -s ~/.acme.sh/${domain}_ecc/${domain}.key ]]; then
LOGI "Existing certificate found for ${domain}, will reuse it." acmeCertDir=~/.acme.sh/${domain}_ecc
[[ -n "${certInfo}" ]] && LOGI "${certInfo}" elif [[ -s ~/.acme.sh/${domain}/fullchain.cer && -s ~/.acme.sh/${domain}/${domain}.key ]]; then
else acmeCertDir=~/.acme.sh/${domain}
fi
if [[ -n "${acmeCertDir}" ]]; then
cert_exists=1
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
LOGI "Existing certificate found for ${domain}, will reuse it."
[[ -n "${certInfo}" ]] && LOGI "${certInfo}"
else
LOGW "Found incomplete acme.sh state for ${domain} (no valid certificate files); cleaning it up and re-issuing."
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
fi
fi
if [[ ${cert_exists} -eq 0 ]]; then
LOGI "Your domain is ready for issuing certificates now..." LOGI "Your domain is ready for issuing certificates now..."
fi fi
@ -1611,7 +1637,7 @@ ssl_cert_issue() {
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
LOGE "Issuing certificate failed, please check logs." LOGE "Issuing certificate failed, please check logs."
rm -rf ~/.acme.sh/${domain} rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
exit 1 exit 1
else else
LOGE "Issuing certificate succeeded, installing certificates..." LOGE "Issuing certificate succeeded, installing certificates..."
@ -1664,7 +1690,7 @@ ssl_cert_issue() {
else else
LOGE "Installing certificate failed, exiting." LOGE "Installing certificate failed, exiting."
if [[ ${cert_exists} -eq 0 ]]; then if [[ ${cert_exists} -eq 0 ]]; then
rm -rf ~/.acme.sh/${domain} rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
fi fi
exit 1 exit 1
fi fi
@ -2248,6 +2274,18 @@ failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnect
ignoreregex = ignoreregex =
EOF EOF
# Ports to exempt from the ban so an over-limit proxy client can never lock
# the administrator out of SSH or the panel. The ban still covers every other
# TCP port (including all Xray inbounds), so IP-limit keeps working for inbounds
# added later without regenerating these files.
local ssh_ports
ssh_ports=$(grep -oP '^[[:space:]]*Port[[:space:]]+\K[0-9]+' /etc/ssh/sshd_config 2>/dev/null | paste -sd, -)
[[ -z "${ssh_ports}" ]] && ssh_ports="22"
local panel_port
panel_port=$(${xui_folder}/x-ui setting -show true 2>/dev/null | grep -Eo 'port: .+' | awk '{print $2}')
local exempt_ports="${ssh_ports}"
[[ -n "${panel_port}" ]] && exempt_ports="${exempt_ports},${panel_port}"
cat << EOF > /etc/fail2ban/action.d/3x-ipl.conf cat << EOF > /etc/fail2ban/action.d/3x-ipl.conf
[INCLUDES] [INCLUDES]
before = iptables-allports.conf before = iptables-allports.conf
@ -2263,16 +2301,17 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]' actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype> actionban = <iptables> -I f2b-<name> 1 -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> ${iplimit_banned_log_path} echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> ${iplimit_banned_log_path}
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype> actionunban = <iptables> -D f2b-<name> -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path} echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
[Init] [Init]
name = default name = default
protocol = tcp protocol = tcp
chain = INPUT chain = INPUT
exemptports = ${exempt_ports}
EOF EOF
echo -e "${green}Ip Limit jail files created with a bantime of ${bantime} minutes.${plain}" echo -e "${green}Ip Limit jail files created with a bantime of ${bantime} minutes.${plain}"
@ -2690,7 +2729,7 @@ migrate_to_postgres() {
echo "" echo ""
echo -e "${yellow}This copies your current SQLite data into a PostgreSQL database,${plain}" echo -e "${yellow}This copies your current SQLite data into a PostgreSQL database,${plain}"
echo -e "${yellow}then switches the panel to PostgreSQL and restarts it.${plain}" echo -e "${yellow}then switches the panel to PostgreSQL and restarts it.${plain}"
echo -e "${yellow}The destination PostgreSQL database must be empty.${plain}" echo -e "${red}Any existing panel tables in the destination will be cleared and overwritten.${plain}"
confirm "Continue?" "n" || return 0 confirm "Continue?" "n" || return 0
local dsn="" pg_mode local dsn="" pg_mode