mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 10:05:21 +08:00
feat(amneziawg): support persistent keepalive ranges (#6881)
AmneziaWG clients accept a randomised PersistentKeepalive range such as 15-25, which makes keepalive timing harder to fingerprint; the panel only stored a single number. A client's keepalive may now be a range, validated the way AmneziaWG parses it. The downloadable .conf and vpn:// profile carry the range, Clash/Mihomo output gets its lower bound, and plain WireGuard stays scalar-only. The range lives in an additive wg_keep_alive_range column, so the integer column and an older panel binary keep working after a rollback.
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
type legacyKeepAliveClient struct {
|
||||
Id int `gorm:"column:id;primaryKey"`
|
||||
Email string `gorm:"column:email"`
|
||||
KeepAlive int `gorm:"column:wg_keep_alive"`
|
||||
}
|
||||
|
||||
func (legacyKeepAliveClient) TableName() string { return "clients" }
|
||||
|
||||
func TestClientKeepAliveSQLiteMigrationPreservesLegacyScalarsAndAddsRanges(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
dbPath := filepath.Join(dbDir, "x-ui.db")
|
||||
legacy, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range []string{
|
||||
"CREATE TABLE clients (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, wg_keep_alive INTEGER DEFAULT 0)",
|
||||
"INSERT INTO clients (email, wg_keep_alive) VALUES ('legacy-25', 25), ('legacy-large', 70000), ('legacy-off', 0), ('legacy-negative', -5)",
|
||||
"CREATE TABLE inbounds (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, port INTEGER NOT NULL, protocol TEXT, settings TEXT)",
|
||||
`INSERT INTO inbounds (id, user_id, port, protocol, settings) VALUES (41, 1, 51888, 'amneziawg', '{"clients":[{"email":"legacy-negative-inbound","keepAlive":-5}]}')`,
|
||||
"CREATE TABLE client_inbounds (client_id INTEGER NOT NULL, inbound_id INTEGER NOT NULL, flow_override TEXT, created_at INTEGER, PRIMARY KEY(client_id, inbound_id))",
|
||||
"INSERT INTO client_inbounds (client_id, inbound_id, flow_override, created_at) VALUES (1, 41, '', 1234)",
|
||||
"CREATE TABLE client_traffics (id INTEGER PRIMARY KEY AUTOINCREMENT, inbound_id INTEGER, email TEXT, up INTEGER, down INTEGER)",
|
||||
"INSERT INTO client_traffics (inbound_id, email, up, down) VALUES (41, 'legacy-25', 1200, 3400)",
|
||||
} {
|
||||
if _, err := legacy.Exec(statement); err != nil {
|
||||
t.Fatalf("%s: %v", statement, err)
|
||||
}
|
||||
}
|
||||
if err := legacy.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
t.Setenv("XUI_DB_TYPE", "sqlite")
|
||||
if err := InitDB(dbPath); err != nil {
|
||||
t.Fatalf("InitDB legacy schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
|
||||
var dataType string
|
||||
if err := GetDB().Raw("SELECT type FROM pragma_table_info('clients') WHERE name = 'wg_keep_alive'").Scan(&dataType).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.EqualFold(dataType, "INTEGER") {
|
||||
t.Fatalf("wg_keep_alive type = %q, want INTEGER", dataType)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
email string
|
||||
want model.KeepAliveValue
|
||||
}{
|
||||
{email: "legacy-25", want: "25"},
|
||||
{email: "legacy-large", want: "70000"},
|
||||
{email: "legacy-off", want: ""},
|
||||
{email: "legacy-negative", want: "-5"},
|
||||
} {
|
||||
var record model.ClientRecord
|
||||
if err := GetDB().Where("email = ?", tc.email).First(&record).Error; err != nil {
|
||||
t.Fatalf("read %s: %v", tc.email, err)
|
||||
}
|
||||
if tc.email == "legacy-25" && record.Id != 1 {
|
||||
t.Fatalf("legacy client ID = %d, want preserved ID 1", record.Id)
|
||||
}
|
||||
if record.KeepAlive != tc.want {
|
||||
t.Fatalf("%s keepAlive = %q, want %q", tc.email, record.KeepAlive, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
email string
|
||||
want int
|
||||
}{{email: "legacy-25", want: 25}, {email: "legacy-large", want: 70000}, {email: "legacy-off", want: 0}} {
|
||||
var legacyClient legacyKeepAliveClient
|
||||
if err := GetDB().Where("email = ?", tc.email).First(&legacyClient).Error; err != nil {
|
||||
t.Fatalf("legacy int model cannot read %s after migration: %v", tc.email, err)
|
||||
}
|
||||
if legacyClient.KeepAlive != tc.want {
|
||||
t.Fatalf("legacy int model read %s keepAlive = %d, want %d", tc.email, legacyClient.KeepAlive, tc.want)
|
||||
}
|
||||
}
|
||||
if err := GetDB().Create(&model.ClientRecord{Email: "new-unset"}).Error; err != nil {
|
||||
t.Fatalf("create new unset client: %v", err)
|
||||
}
|
||||
var legacyUnset legacyKeepAliveClient
|
||||
if err := GetDB().Where("email = ?", "new-unset").First(&legacyUnset).Error; err != nil || legacyUnset.KeepAlive != 0 {
|
||||
t.Fatalf("legacy int model read of new unset keepAlive = %d, %v; want zero", legacyUnset.KeepAlive, err)
|
||||
}
|
||||
if err := GetDB().Create(&legacyKeepAliveClient{Email: "old-panel-write", KeepAlive: 45}).Error; err != nil {
|
||||
t.Fatalf("legacy int model cannot write after migration: %v", err)
|
||||
}
|
||||
var currentWrite model.ClientRecord
|
||||
if err := GetDB().Where("email = ?", "old-panel-write").First(¤tWrite).Error; err != nil || currentWrite.KeepAlive != "45" {
|
||||
t.Fatalf("new model read of legacy write = %q, %v; want 45", currentWrite.KeepAlive, err)
|
||||
}
|
||||
|
||||
zero := &model.ClientRecord{Email: "explicit-zero", KeepAlive: "0"}
|
||||
if err := GetDB().Create(zero).Error; err != nil {
|
||||
t.Fatalf("save explicit zero: %v", err)
|
||||
}
|
||||
var storageType string
|
||||
if err := GetDB().Raw("SELECT typeof(wg_keep_alive) FROM clients WHERE email = ?", zero.Email).Scan(&storageType).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if storageType != "integer" {
|
||||
t.Fatalf("explicit zero SQLite storage class = %q, want integer", storageType)
|
||||
}
|
||||
var rangeValue string
|
||||
if err := GetDB().Raw("SELECT wg_keep_alive_range FROM clients WHERE email = ?", zero.Email).Scan(&rangeValue).Error; err != nil || rangeValue != "0" {
|
||||
t.Fatalf("explicit zero range marker = %q, %v; want 0", rangeValue, err)
|
||||
}
|
||||
if err := GetDB().Model(&legacyKeepAliveClient{}).Where("email = ?", zero.Email).Update("wg_keep_alive", 45).Error; err != nil {
|
||||
t.Fatalf("old int model cannot update a new row: %v", err)
|
||||
}
|
||||
var updatedOldRow model.ClientRecord
|
||||
if err := GetDB().Where("email = ?", zero.Email).First(&updatedOldRow).Error; err != nil || updatedOldRow.KeepAlive != "45" {
|
||||
t.Fatalf("new model read after old update = %q, %v; want 45", updatedOldRow.KeepAlive, err)
|
||||
}
|
||||
|
||||
inbound := &model.Inbound{
|
||||
UserId: 1, Port: 51888, Protocol: model.AmneziaWG, Tag: "legacy-awg-range",
|
||||
Settings: "{\"server\":{\"privateKey\":\"server\",\"publicKey\":\"pub\"},\"clients\":[{\"email\":\"legacy-range\",\"keepAlive\":\"15-25\"}]}",
|
||||
}
|
||||
if err := GetDB().Create(inbound).Error; err != nil {
|
||||
t.Fatalf("save ranged inbound: %v", err)
|
||||
}
|
||||
if err := GetDB().Create(&model.ClientRecord{Email: "range-record", KeepAlive: "15-25"}).Error; err != nil {
|
||||
t.Fatalf("save ranged client record: %v", err)
|
||||
}
|
||||
var rangeRecord model.ClientRecord
|
||||
if err := GetDB().Where("email = ?", "range-record").First(&rangeRecord).Error; err != nil || rangeRecord.KeepAlive != "15-25" {
|
||||
t.Fatalf("read ranged client record = %q, %v; want 15-25", rangeRecord.KeepAlive, err)
|
||||
}
|
||||
if err := CloseDB(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := InitDB(dbPath); err != nil {
|
||||
t.Fatalf("reopen migrated database: %v", err)
|
||||
}
|
||||
|
||||
var link model.ClientInbound
|
||||
if err := GetDB().Where("client_id = ? AND inbound_id = ?", 1, 41).First(&link).Error; err != nil {
|
||||
t.Fatalf("client-inbound link was not preserved: %v", err)
|
||||
}
|
||||
if link.ClientId != 1 || link.InboundId != 41 || link.FlowOverride != "" || link.CreatedAt != 1234 {
|
||||
t.Fatalf("client-inbound link changed during migration: %#v", link)
|
||||
}
|
||||
var traffic xray.ClientTraffic
|
||||
if err := GetDB().Where("inbound_id = ? AND email = ?", 41, "legacy-25").First(&traffic).Error; err != nil {
|
||||
t.Fatalf("client traffic was not preserved: %v", err)
|
||||
}
|
||||
if traffic.Id != 1 || traffic.Up != 1200 || traffic.Down != 3400 {
|
||||
t.Fatalf("client traffic changed during migration: %#v", traffic)
|
||||
}
|
||||
var got model.ClientRecord
|
||||
if err := GetDB().Where("email = ?", "explicit-zero").First(&got).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.KeepAlive != "45" {
|
||||
t.Fatalf("reopened explicit zero after old update = %q, want 45", got.KeepAlive)
|
||||
}
|
||||
var reopened model.Inbound
|
||||
if err := GetDB().First(&reopened, inbound.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload struct {
|
||||
Clients []model.Client `json:"clients"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(reopened.Settings), &payload); err != nil {
|
||||
t.Fatalf("read range from inbound settings after reopen: %v", err)
|
||||
}
|
||||
if len(payload.Clients) != 1 || payload.Clients[0].KeepAliveText() != "15-25" {
|
||||
t.Fatalf("reopened inbound keepAlive = %#v, want 15-25", payload.Clients)
|
||||
}
|
||||
var legacyInbound model.Inbound
|
||||
if err := GetDB().First(&legacyInbound, 41).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(legacyInbound.Settings), &payload); err != nil || len(payload.Clients) != 1 || payload.Clients[0].KeepAliveText() != "-5" {
|
||||
t.Fatalf("legacy negative inbound keepAlive = %#v, %v; want -5", payload.Clients, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestClientKeepAlivePostgresMigrationPreservesLegacyScalars(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("XUI_TEST_PG_DSN"))
|
||||
if dsn == "" {
|
||||
dsn = strings.TrimSpace(os.Getenv("XUI_DB_DSN"))
|
||||
}
|
||||
if dsn == "" {
|
||||
t.Skip("set XUI_TEST_PG_DSN or XUI_DB_DSN to run the PostgreSQL keepAlive migration test")
|
||||
}
|
||||
admin, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open PostgreSQL admin connection: %v", err)
|
||||
}
|
||||
adminHandle, err := admin.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = adminHandle.Close() })
|
||||
|
||||
schema := fmt.Sprintf("keepalive_%d", time.Now().UnixNano())
|
||||
if err := admin.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = CloseDB()
|
||||
if err := admin.Exec("DROP SCHEMA " + schema + " CASCADE").Error; err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
|
||||
scoped := dsn + " search_path=" + schema
|
||||
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
||||
parsed, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("search_path", schema)
|
||||
parsed.RawQuery = query.Encode()
|
||||
scoped = parsed.String()
|
||||
}
|
||||
t.Setenv("XUI_DB_TYPE", "postgres")
|
||||
t.Setenv("XUI_DB_DSN", scoped)
|
||||
|
||||
legacy, err := gorm.Open(postgres.Open(scoped), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy schema: %v", err)
|
||||
}
|
||||
if err := legacy.Exec("CREATE TABLE clients (id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL, wg_keep_alive BIGINT DEFAULT 0)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := legacy.Exec("INSERT INTO clients (email, wg_keep_alive) VALUES ('legacy-25', 25), ('legacy-large', 70000), ('legacy-off', 0)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyHandle, err := legacy.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := legacyHandle.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB legacy schema: %v", err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
email string
|
||||
want model.KeepAliveValue
|
||||
}{
|
||||
{email: "legacy-25", want: "25"},
|
||||
{email: "legacy-large", want: "70000"},
|
||||
{email: "legacy-off", want: ""},
|
||||
} {
|
||||
var record model.ClientRecord
|
||||
if err := GetDB().Where("email = ?", tc.email).First(&record).Error; err != nil {
|
||||
t.Fatalf("read %s: %v", tc.email, err)
|
||||
}
|
||||
if record.KeepAlive != tc.want {
|
||||
t.Fatalf("%s keepAlive = %q, want %q", tc.email, record.KeepAlive, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
email string
|
||||
want int
|
||||
}{{"legacy-25", 25}, {"legacy-large", 70000}, {"legacy-off", 0}} {
|
||||
var old legacyKeepAliveClient
|
||||
if err := GetDB().Where("email = ?", tc.email).First(&old).Error; err != nil || old.KeepAlive != tc.want {
|
||||
t.Fatalf("old int model read %s = %d, %v; want %d", tc.email, old.KeepAlive, err, tc.want)
|
||||
}
|
||||
}
|
||||
if err := GetDB().Create(&model.ClientRecord{Email: "new-unset"}).Error; err != nil {
|
||||
t.Fatalf("create new unset client: %v", err)
|
||||
}
|
||||
var oldUnset legacyKeepAliveClient
|
||||
if err := GetDB().Where("email = ?", "new-unset").First(&oldUnset).Error; err != nil || oldUnset.KeepAlive != 0 {
|
||||
t.Fatalf("old int model read of new unset = %d, %v; want 0", oldUnset.KeepAlive, err)
|
||||
}
|
||||
if err := GetDB().Create(&legacyKeepAliveClient{Email: "old-panel-write", KeepAlive: 45}).Error; err != nil {
|
||||
t.Fatalf("old int model cannot write after migration: %v", err)
|
||||
}
|
||||
var current model.ClientRecord
|
||||
if err := GetDB().Where("email = ?", "old-panel-write").First(¤t).Error; err != nil || current.KeepAlive != "45" {
|
||||
t.Fatalf("current model read of old write = %q, %v; want 45", current.KeepAlive, err)
|
||||
}
|
||||
|
||||
zero := &model.ClientRecord{Email: "explicit-zero", KeepAlive: "0"}
|
||||
if err := GetDB().Create(zero).Error; err != nil {
|
||||
t.Fatalf("save explicit zero: %v", err)
|
||||
}
|
||||
if err := GetDB().Model(&legacyKeepAliveClient{}).Where("email = ?", zero.Email).Update("wg_keep_alive", 45).Error; err != nil {
|
||||
t.Fatalf("old int model cannot update a new row: %v", err)
|
||||
}
|
||||
var updatedOldRow model.ClientRecord
|
||||
if err := GetDB().Where("email = ?", zero.Email).First(&updatedOldRow).Error; err != nil || updatedOldRow.KeepAlive != "45" {
|
||||
t.Fatalf("new model read after old update = %q, %v; want 45", updatedOldRow.KeepAlive, err)
|
||||
}
|
||||
if err := GetDB().Create(&model.ClientRecord{Email: "range-record", KeepAlive: "15-25"}).Error; err != nil {
|
||||
t.Fatalf("save ranged client record: %v", err)
|
||||
}
|
||||
var rangeRecord model.ClientRecord
|
||||
if err := GetDB().Where("email = ?", "range-record").First(&rangeRecord).Error; err != nil || rangeRecord.KeepAlive != "15-25" {
|
||||
t.Fatalf("read ranged client record = %q, %v; want 15-25", rangeRecord.KeepAlive, err)
|
||||
}
|
||||
|
||||
inbound := &model.Inbound{
|
||||
UserId: 1, Port: 51889, Protocol: model.AmneziaWG, Tag: "legacy-awg-range-pg",
|
||||
Settings: "{\"server\":{\"privateKey\":\"server\",\"publicKey\":\"pub\"},\"clients\":[{\"email\":\"legacy-range\",\"keepAlive\":\"15-25\"}]}",
|
||||
}
|
||||
if err := GetDB().Create(inbound).Error; err != nil {
|
||||
t.Fatalf("save ranged inbound: %v", err)
|
||||
}
|
||||
if err := CloseDB(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := InitDB(""); err != nil {
|
||||
t.Fatalf("reopen migrated PostgreSQL database: %v", err)
|
||||
}
|
||||
|
||||
var dataType string
|
||||
if err := GetDB().Raw("SELECT data_type FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'clients' AND column_name = 'wg_keep_alive'").Scan(&dataType).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.EqualFold(dataType, "bigint") {
|
||||
t.Fatalf("wg_keep_alive data type = %q, want bigint", dataType)
|
||||
}
|
||||
var got model.ClientRecord
|
||||
if err := GetDB().Where("email = ?", "explicit-zero").First(&got).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.KeepAlive != "45" {
|
||||
t.Fatalf("reopened explicit zero after old update = %q, want 45", got.KeepAlive)
|
||||
}
|
||||
var reopened model.Inbound
|
||||
if err := GetDB().First(&reopened, inbound.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload struct {
|
||||
Clients []model.Client `json:"clients"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(reopened.Settings), &payload); err != nil {
|
||||
t.Fatalf("read range from inbound settings after reopen: %v", err)
|
||||
}
|
||||
if len(payload.Clients) != 1 || payload.Clients[0].KeepAliveText() != "15-25" {
|
||||
t.Fatalf("reopened inbound keepAlive = %#v, want 15-25", payload.Clients)
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,18 @@ func migrateInboundExcludeFromSubColumn() error {
|
||||
return migrator.AddColumn(&model.Inbound{}, "ExcludeFromSub")
|
||||
}
|
||||
|
||||
func migrateClientKeepAliveRangeColumn() error {
|
||||
migrator := db.Migrator()
|
||||
if !migrator.HasTable(&model.ClientRecord{}) || migrator.HasColumn(&model.ClientRecord{}, "wg_keep_alive_range") {
|
||||
return nil
|
||||
}
|
||||
return migrator.AddColumn(&model.ClientRecord{}, "KeepAliveRange")
|
||||
}
|
||||
|
||||
func initModels() error {
|
||||
if err := migrateClientKeepAliveRangeColumn(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateClientTrafficLastSubFetchColumn(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientKeepAliveJSONPreservesRangeAndScalarCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want any
|
||||
}{
|
||||
{name: "range", input: `{"keepAlive":"15-25"}`, want: "15-25"},
|
||||
{name: "scalar", input: `{"keepAlive":25}`, want: float64(25)},
|
||||
{name: "explicit zero", input: `{"keepAlive":0}`, want: float64(0)},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var client Client
|
||||
if err := json.Unmarshal([]byte(tt.input), &client); err != nil {
|
||||
t.Fatalf("decode keepAlive: %v", err)
|
||||
}
|
||||
blob, err := json.Marshal(client)
|
||||
if err != nil {
|
||||
t.Fatalf("encode client: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(blob, &got); err != nil {
|
||||
t.Fatalf("decode client output: %v", err)
|
||||
}
|
||||
if got["keepAlive"] != tt.want {
|
||||
t.Fatalf("keepAlive JSON = %#v, want %#v", got["keepAlive"], tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var omitted Client
|
||||
blob, err := json.Marshal(omitted)
|
||||
if err != nil {
|
||||
t.Fatalf("encode omitted client: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(blob, &got); err != nil {
|
||||
t.Fatalf("decode omitted output: %v", err)
|
||||
}
|
||||
if _, ok := got["keepAlive"]; ok {
|
||||
t.Fatalf("omitted keepAlive serialized as %#v", got["keepAlive"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeepAliveValueValidatesNewInputAndPreservesLegacyScalar(t *testing.T) {
|
||||
for _, value := range []KeepAliveValue{"0", "25", "65535"} {
|
||||
if err := value.Validate(false); err != nil {
|
||||
t.Errorf("scalar %q rejected: %v", value, err)
|
||||
}
|
||||
}
|
||||
if err := KeepAliveValue("15-25").Validate(true); err != nil {
|
||||
t.Fatalf("AmneziaWG range rejected: %v", err)
|
||||
}
|
||||
if err := KeepAliveValue("15-25").Validate(false); err == nil || err.Error() != "keepAlive ranges are supported only for AmneziaWG clients" {
|
||||
t.Fatalf("WireGuard range error = %v", err)
|
||||
}
|
||||
for _, value := range []KeepAliveValue{"25-15", "0-65536", "65536", "01-2"} {
|
||||
if err := value.Validate(true); err == nil {
|
||||
t.Errorf("invalid new value %q accepted", value)
|
||||
}
|
||||
}
|
||||
|
||||
var legacy KeepAliveValue
|
||||
if err := json.Unmarshal([]byte("70000"), &legacy); err != nil {
|
||||
t.Fatalf("read legacy numeric value: %v", err)
|
||||
}
|
||||
blob, err := json.Marshal(legacy)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal legacy numeric value: %v", err)
|
||||
}
|
||||
if string(blob) != "70000" {
|
||||
t.Fatalf("legacy numeric value = %s, want 70000", blob)
|
||||
}
|
||||
if got := legacy.Seconds(); got != 70000 {
|
||||
t.Fatalf("legacy scalar accessor = %d, want 70000", got)
|
||||
}
|
||||
if err := json.Unmarshal([]byte("-5"), &legacy); err != nil {
|
||||
t.Fatalf("read legacy negative numeric value: %v", err)
|
||||
}
|
||||
blob, err = json.Marshal(legacy)
|
||||
if err != nil || string(blob) != "-5" {
|
||||
t.Fatalf("legacy negative numeric value = %s, %v; want -5", blob, err)
|
||||
}
|
||||
if err := legacy.Validate(true); err == nil {
|
||||
t.Fatal("new-input validator accepted a legacy value above the panel bound")
|
||||
}
|
||||
if got := legacy.Seconds(); got != -5 {
|
||||
t.Fatalf("legacy negative scalar accessor = %d, want -5", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxKeepAliveValue = 65535
|
||||
|
||||
type KeepAliveValue string
|
||||
|
||||
func (v KeepAliveValue) String() string { return string(v) }
|
||||
|
||||
func (v KeepAliveValue) Seconds() int {
|
||||
if n, err := strconv.Atoi(string(v)); err == nil {
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (v KeepAliveValue) MinSeconds() int {
|
||||
parts := strings.SplitN(string(v), "-", 2)
|
||||
n, _ := strconv.Atoi(parts[0])
|
||||
return n
|
||||
}
|
||||
|
||||
func (v KeepAliveValue) Validate(allowRange bool) error {
|
||||
text := string(v)
|
||||
parts := strings.Split(text, "-")
|
||||
if len(parts) == 1 {
|
||||
n, err := strconv.Atoi(text)
|
||||
if err != nil || strconv.Itoa(n) != text || n < 0 || n > maxKeepAliveValue {
|
||||
return fmt.Errorf("keepAlive must be an integer from 0 to %d", maxKeepAliveValue)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(parts) != 2 || !allowRange {
|
||||
return errors.New("keepAlive ranges are supported only for AmneziaWG clients")
|
||||
}
|
||||
min, errMin := strconv.Atoi(parts[0])
|
||||
max, errMax := strconv.Atoi(parts[1])
|
||||
if errMin != nil || errMax != nil || strconv.Itoa(min) != parts[0] || strconv.Itoa(max) != parts[1] || min < 0 || max > maxKeepAliveValue || min > max {
|
||||
return fmt.Errorf("keepAlive range must be ordered integers from 0 to %d", maxKeepAliveValue)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v KeepAliveValue) Value() (driver.Value, error) {
|
||||
return string(v), nil
|
||||
}
|
||||
|
||||
func (v *KeepAliveValue) Scan(src any) error {
|
||||
switch value := src.(type) {
|
||||
case nil:
|
||||
*v = ""
|
||||
case string:
|
||||
*v = KeepAliveValue(value)
|
||||
case []byte:
|
||||
*v = KeepAliveValue(string(value))
|
||||
case int64:
|
||||
if value == 0 {
|
||||
*v = ""
|
||||
} else {
|
||||
*v = KeepAliveValue(strconv.FormatInt(value, 10))
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("cannot scan %T into KeepAliveValue", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v KeepAliveValue) MarshalJSON() ([]byte, error) {
|
||||
if v == "" {
|
||||
return []byte("0"), nil
|
||||
}
|
||||
if _, err := strconv.ParseInt(string(v), 10, 64); err == nil {
|
||||
return []byte(string(v)), nil
|
||||
}
|
||||
if err := v.Validate(true); err != nil || !strings.Contains(string(v), "-") {
|
||||
return nil, fmt.Errorf("invalid stored keepAlive %q", v)
|
||||
}
|
||||
return json.Marshal(string(v))
|
||||
}
|
||||
|
||||
func (v *KeepAliveValue) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
*v = ""
|
||||
return nil
|
||||
}
|
||||
if len(data) > 0 && data[0] == '"' {
|
||||
var text string
|
||||
if err := json.Unmarshal(data, &text); err != nil {
|
||||
return err
|
||||
}
|
||||
candidate := KeepAliveValue(text)
|
||||
if err := candidate.Validate(true); err != nil {
|
||||
return err
|
||||
}
|
||||
*v = candidate
|
||||
return nil
|
||||
}
|
||||
var number json.Number
|
||||
if err := json.Unmarshal(data, &number); err != nil {
|
||||
return errors.New("keepAlive must be a number or an AmneziaWG range string")
|
||||
}
|
||||
n, err := strconv.ParseInt(string(number), 10, 64)
|
||||
if err != nil {
|
||||
return errors.New("keepAlive must be an integer")
|
||||
}
|
||||
*v = KeepAliveValue(strconv.FormatInt(n, 10))
|
||||
return nil
|
||||
}
|
||||
@@ -7,8 +7,11 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
@@ -944,7 +947,7 @@ type Client struct {
|
||||
// before -- fully backward compatible for callers that never set this.
|
||||
AllowedIPsByInbound map[int][]string `json:"allowedIPsByInbound,omitempty"`
|
||||
PreSharedKey string `json:"preSharedKey,omitempty"`
|
||||
KeepAlive *int `json:"keepAlive,omitempty"` // Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value
|
||||
KeepAlive *KeepAliveValue `json:"keepAlive,omitempty"` // Numeric values or AmneziaWG ranges; 0 disables, omission preserves.
|
||||
ForwardedPorts string `json:"forwardedPorts,omitempty"` // AmneziaWG per-client port-forwarding spec, e.g. "80,443,8000-8100"
|
||||
Secret string `json:"secret,omitempty" example:"ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"`
|
||||
AdTag string `json:"adTag,omitempty" example:"0123456789abcdef0123456789abcdef"`
|
||||
@@ -969,39 +972,41 @@ type Client struct {
|
||||
}
|
||||
|
||||
type ClientRecord struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Email string `json:"email" gorm:"uniqueIndex;not null"`
|
||||
SubID string `json:"subId" gorm:"index;column:sub_id"`
|
||||
UUID string `json:"uuid" gorm:"column:uuid"`
|
||||
Password string `json:"password"`
|
||||
Auth string `json:"auth"`
|
||||
Flow string `json:"flow"`
|
||||
Security string `json:"security"`
|
||||
Reverse string `json:"reverse" gorm:"column:reverse"`
|
||||
PrivateKey string `json:"privateKey" gorm:"column:wg_private_key"`
|
||||
PublicKey string `json:"publicKey" gorm:"column:wg_public_key"`
|
||||
AllowedIPs string `json:"allowedIPs" gorm:"column:wg_allowed_ips"`
|
||||
PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"`
|
||||
KeepAlive int `json:"keepAlive" gorm:"column:wg_keep_alive;default:0"`
|
||||
ForwardedPorts string `json:"forwardedPorts" gorm:"column:wg_forwarded_ports"`
|
||||
Secret string `json:"secret" gorm:"column:secret"`
|
||||
AdTag string `json:"adTag" gorm:"column:ad_tag;default:''"`
|
||||
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
|
||||
LimitHwid int `json:"limitHwid" gorm:"column:limit_hwid;default:0"`
|
||||
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
||||
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
||||
Enable bool `json:"enable" gorm:"default:true"`
|
||||
TgID int64 `json:"tgId" gorm:"column:tg_id;index:idx_clients_tg_id"`
|
||||
Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
|
||||
Comment string `json:"comment"`
|
||||
Reset int `json:"reset" gorm:"default:0"`
|
||||
ResetDay int `json:"resetDay" gorm:"column:reset_day;default:0"`
|
||||
ResetWeekday int `json:"resetWeekday" gorm:"column:reset_weekday;default:0"`
|
||||
ResetMax int `json:"resetMax" gorm:"column:reset_max;default:0"`
|
||||
TrafficReset string `json:"trafficReset" gorm:"column:traffic_reset;default:never;index:idx_clients_traffic_reset"`
|
||||
TrafficResetDay int `json:"trafficResetDay" gorm:"column:traffic_reset_day;default:1"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Email string `json:"email" gorm:"uniqueIndex;not null"`
|
||||
SubID string `json:"subId" gorm:"index;column:sub_id"`
|
||||
UUID string `json:"uuid" gorm:"column:uuid"`
|
||||
Password string `json:"password"`
|
||||
Auth string `json:"auth"`
|
||||
Flow string `json:"flow"`
|
||||
Security string `json:"security"`
|
||||
Reverse string `json:"reverse" gorm:"column:reverse"`
|
||||
PrivateKey string `json:"privateKey" gorm:"column:wg_private_key"`
|
||||
PublicKey string `json:"publicKey" gorm:"column:wg_public_key"`
|
||||
AllowedIPs string `json:"allowedIPs" gorm:"column:wg_allowed_ips"`
|
||||
PreSharedKey string `json:"preSharedKey" gorm:"column:wg_pre_shared_key"`
|
||||
KeepAlive KeepAliveValue `json:"keepAlive" gorm:"-"` // Numeric values or AmneziaWG ranges.
|
||||
KeepAliveLegacy int `json:"-" gorm:"column:wg_keep_alive;default:0"`
|
||||
KeepAliveRange KeepAliveValue `json:"-" gorm:"column:wg_keep_alive_range;type:text;default:''"`
|
||||
ForwardedPorts string `json:"forwardedPorts" gorm:"column:wg_forwarded_ports"`
|
||||
Secret string `json:"secret" gorm:"column:secret"`
|
||||
AdTag string `json:"adTag" gorm:"column:ad_tag;default:''"`
|
||||
LimitIP int `json:"limitIp" gorm:"column:limit_ip"`
|
||||
LimitHwid int `json:"limitHwid" gorm:"column:limit_hwid;default:0"`
|
||||
TotalGB int64 `json:"totalGB" gorm:"column:total_gb"`
|
||||
ExpiryTime int64 `json:"expiryTime" gorm:"column:expiry_time"`
|
||||
Enable bool `json:"enable" gorm:"default:true"`
|
||||
TgID int64 `json:"tgId" gorm:"column:tg_id;index:idx_clients_tg_id"`
|
||||
Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
|
||||
Comment string `json:"comment"`
|
||||
Reset int `json:"reset" gorm:"default:0"`
|
||||
ResetDay int `json:"resetDay" gorm:"column:reset_day;default:0"`
|
||||
ResetWeekday int `json:"resetWeekday" gorm:"column:reset_weekday;default:0"`
|
||||
ResetMax int `json:"resetMax" gorm:"column:reset_max;default:0"`
|
||||
TrafficReset string `json:"trafficReset" gorm:"column:traffic_reset;default:never;index:idx_clients_traffic_reset"`
|
||||
TrafficResetDay int `json:"trafficResetDay" gorm:"column:traffic_reset_day;default:1"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||
// Owned solely by the node-snapshot sweep, which soft-orphans instead of
|
||||
// deleting; orphans from any other cause stay at zero and are never reaped.
|
||||
SyncOrphanedAt int64 `json:"-" gorm:"column:sync_orphaned_at;default:0"`
|
||||
@@ -1009,6 +1014,47 @@ type ClientRecord struct {
|
||||
|
||||
func (ClientRecord) TableName() string { return "clients" }
|
||||
|
||||
func (r *ClientRecord) BeforeSave(*gorm.DB) error {
|
||||
legacy, ranged, err := splitKeepAliveStorage(r.KeepAlive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.KeepAliveLegacy = legacy
|
||||
r.KeepAliveRange = ranged
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ClientRecord) AfterFind(*gorm.DB) error {
|
||||
if r.KeepAliveLegacy != 0 {
|
||||
r.KeepAlive = KeepAliveValue(strconv.Itoa(r.KeepAliveLegacy))
|
||||
} else if r.KeepAliveRange != "" {
|
||||
r.KeepAlive = r.KeepAliveRange
|
||||
} else {
|
||||
r.KeepAlive = ""
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitKeepAliveStorage(value KeepAliveValue) (int, KeepAliveValue, error) {
|
||||
if value == "" {
|
||||
return 0, "", nil
|
||||
}
|
||||
if n, err := strconv.Atoi(string(value)); err == nil && strconv.Itoa(n) == string(value) {
|
||||
if n == 0 {
|
||||
return 0, "0", nil
|
||||
}
|
||||
return n, "", nil
|
||||
}
|
||||
if err := value.Validate(true); err != nil || !strings.Contains(string(value), "-") {
|
||||
return 0, "", fmt.Errorf("invalid stored keepAlive %q", value)
|
||||
}
|
||||
return 0, value, nil
|
||||
}
|
||||
|
||||
func KeepAliveStorage(value KeepAliveValue) (int, KeepAliveValue, error) {
|
||||
return splitKeepAliveStorage(value)
|
||||
}
|
||||
|
||||
type ClientGroup struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
||||
@@ -1171,25 +1217,39 @@ type Host struct {
|
||||
|
||||
func (Host) TableName() string { return "hosts" }
|
||||
|
||||
// KeepAliveSeconds is the client's PersistentKeepalive, 0 when unset.
|
||||
// KeepAliveSeconds returns a scalar value; ranges are emitted by native AWG config.
|
||||
func (c Client) KeepAliveSeconds() int {
|
||||
if c.KeepAlive == nil {
|
||||
return 0
|
||||
}
|
||||
return *c.KeepAlive
|
||||
return c.KeepAlive.Seconds()
|
||||
}
|
||||
|
||||
// KeepAlivePtr wraps an explicit PersistentKeepalive, 0 included -- distinct
|
||||
// from a nil KeepAlive, which means the field was never sent.
|
||||
func KeepAlivePtr(v int) *int { return &v }
|
||||
func (c Client) KeepAliveText() string {
|
||||
if c.KeepAlive == nil {
|
||||
return ""
|
||||
}
|
||||
return c.KeepAlive.String()
|
||||
}
|
||||
|
||||
// nonZeroKeepAlive keeps a stored 0 nil: wg_keep_alive cannot tell "off" from
|
||||
// "never set", and an explicit 0 would emit keepAlive on every protocol.
|
||||
func nonZeroKeepAlive(seconds int) *int {
|
||||
if seconds <= 0 {
|
||||
func KeepAlivePtr(v int) *KeepAliveValue {
|
||||
value := KeepAliveValue(fmt.Sprintf("%d", v))
|
||||
return &value
|
||||
}
|
||||
|
||||
func keepAlivePtr(value KeepAliveValue) *KeepAliveValue {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return KeepAlivePtr(seconds)
|
||||
v := value
|
||||
return &v
|
||||
}
|
||||
|
||||
func keepAliveValue(value *KeepAliveValue) KeepAliveValue {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func (c *Client) ToRecord() *ClientRecord {
|
||||
@@ -1221,7 +1281,7 @@ func (c *Client) ToRecord() *ClientRecord {
|
||||
PublicKey: c.PublicKey,
|
||||
AllowedIPs: strings.Join(c.AllowedIPs, ","),
|
||||
PreSharedKey: c.PreSharedKey,
|
||||
KeepAlive: c.KeepAliveSeconds(),
|
||||
KeepAlive: keepAliveValue(c.KeepAlive),
|
||||
ForwardedPorts: c.ForwardedPorts,
|
||||
Secret: c.Secret,
|
||||
AdTag: c.AdTag,
|
||||
@@ -1280,7 +1340,7 @@ func (r *ClientRecord) ToClient() *Client {
|
||||
PublicKey: r.PublicKey,
|
||||
AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs),
|
||||
PreSharedKey: r.PreSharedKey,
|
||||
KeepAlive: nonZeroKeepAlive(r.KeepAlive),
|
||||
KeepAlive: keepAlivePtr(r.KeepAlive),
|
||||
ForwardedPorts: r.ForwardedPorts,
|
||||
Secret: r.Secret,
|
||||
AdTag: r.AdTag,
|
||||
@@ -1524,8 +1584,8 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
|
||||
existing.AllowedIPs = incoming.AllowedIPs
|
||||
}
|
||||
}
|
||||
if existing.KeepAlive != incoming.KeepAlive && incoming.KeepAlive != 0 {
|
||||
if incomingNewer || existing.KeepAlive == 0 {
|
||||
if incoming.KeepAlive != "" && existing.KeepAlive != incoming.KeepAlive {
|
||||
if incomingNewer || existing.KeepAlive == "" {
|
||||
keep("keepAlive", existing.KeepAlive, incoming.KeepAlive, incoming.KeepAlive)
|
||||
existing.KeepAlive = incoming.KeepAlive
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestClientToClientOmitsUnsetKeepAlive(t *testing.T) {
|
||||
rec := &ClientRecord{Email: "[email protected]", UUID: "11111111-2222-3333-4444-555555555555", Enable: true}
|
||||
|
||||
if got := rec.ToClient().KeepAlive; got != nil {
|
||||
t.Fatalf("KeepAlive for a record that never set one = %d, want nil", *got)
|
||||
t.Fatalf("KeepAlive for a record that never set one = %s, want nil", *got)
|
||||
}
|
||||
blob, err := json.Marshal(map[string][]Client{"clients": {*rec.ToClient()}})
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user