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:
YoungReckless4
2026-10-24 07:36:49 +03:00
committed by GitHub
parent 207c705300
commit 35e09191de
59 changed files with 1871 additions and 146 deletions
@@ -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)
}
}
+116
View File
@@ -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
}
+108 -48
View File
@@ -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: "vless@example.test", 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 {