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
+53 -24
View File
@@ -432,6 +432,13 @@ func markInboundNodesDirty(inboundIds []int) error {
}
func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) error {
return s.fillProtocolDefaultsAgainstExisting(c, ib, nil)
}
func (s *ClientService) fillProtocolDefaultsAgainstExisting(c *model.Client, ib *model.Inbound, existing []model.Client, identityEmail ...string) error {
if err := validateClientKeepAliveAgainstExisting(ib.Protocol, *c, existing, identityEmail...); err != nil {
return err
}
switch ib.Protocol {
case model.VMESS, model.VLESS:
if c.ID == "" {
@@ -688,6 +695,23 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
if err := validateClientTrafficReset(updated.TrafficReset, updated.TrafficResetDay); err != nil {
return false, err
}
existingClientsByInbound := make(map[int][]model.Client, len(attachedIds))
if updated.KeepAlive != nil {
for _, inboundID := range attachedIds {
inbound, getErr := inboundSvc.GetInbound(inboundID)
if getErr != nil {
return false, fmt.Errorf("inbound %d: %w", inboundID, getErr)
}
clients, getErr := inboundSvc.GetClients(inbound)
if getErr != nil {
return false, fmt.Errorf("inbound %d: %w", inboundID, getErr)
}
existingClientsByInbound[inboundID] = clients
if err := validateClientKeepAliveAgainstExisting(inbound.Protocol, updated, clients, existing.Email); err != nil {
return false, fmt.Errorf("inbound %d: %w", inboundID, err)
}
}
}
normalizeClientTrafficReset(&updated)
if updated.SubID == "" {
updated.SubID = existing.SubID
@@ -765,7 +789,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
if existing.Email == "" {
continue
}
if err := s.fillProtocolDefaults(&updated, inbound); err != nil {
if err := s.fillProtocolDefaultsAgainstExisting(&updated, inbound, existingClientsByInbound[ibId], existing.Email); err != nil {
return false, err
}
clientForInbound := updated
@@ -822,32 +846,37 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
if len(inboundIds) == 0 {
merged := *existing
applyClientRecordMerge(&merged, updated.ToRecord())
keepAliveLegacy, keepAliveRange, keepAliveErr := model.KeepAliveStorage(merged.KeepAlive)
if keepAliveErr != nil {
return needRestart, keepAliveErr
}
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("id = ?", id).
Updates(map[string]any{
"sub_id": merged.SubID,
"uuid": merged.UUID,
"password": merged.Password,
"auth": merged.Auth,
"secret": merged.Secret,
"flow": merged.Flow,
"security": merged.Security,
"wg_private_key": merged.PrivateKey,
"wg_public_key": merged.PublicKey,
"wg_allowed_ips": merged.AllowedIPs,
"wg_pre_shared_key": merged.PreSharedKey,
"wg_keep_alive": merged.KeepAlive,
"limit_ip": merged.LimitIP,
"total_gb": merged.TotalGB,
"expiry_time": merged.ExpiryTime,
"tg_id": merged.TgID,
"comment": merged.Comment,
"reset": merged.Reset,
"reset_day": merged.ResetDay,
"reset_weekday": merged.ResetWeekday,
"reset_max": merged.ResetMax,
"traffic_reset": merged.TrafficReset,
"traffic_reset_day": merged.TrafficResetDay,
"sub_id": merged.SubID,
"uuid": merged.UUID,
"password": merged.Password,
"auth": merged.Auth,
"secret": merged.Secret,
"flow": merged.Flow,
"security": merged.Security,
"wg_private_key": merged.PrivateKey,
"wg_public_key": merged.PublicKey,
"wg_allowed_ips": merged.AllowedIPs,
"wg_pre_shared_key": merged.PreSharedKey,
"wg_keep_alive": keepAliveLegacy,
"wg_keep_alive_range": keepAliveRange,
"limit_ip": merged.LimitIP,
"total_gb": merged.TotalGB,
"expiry_time": merged.ExpiryTime,
"tg_id": merged.TgID,
"comment": merged.Comment,
"reset": merged.Reset,
"reset_day": merged.ResetDay,
"reset_weekday": merged.ResetWeekday,
"reset_max": merged.ResetMax,
"traffic_reset": merged.TrafficReset,
"traffic_reset_day": merged.TrafficResetDay,
}).Error; err != nil {
return needRestart, err
}
+26 -8
View File
@@ -334,6 +334,17 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
if err := validateClientsRenewal(clients); err != nil {
return false, err
}
inbound, err := inboundSvc.GetInbound(data.Id)
if err != nil {
return false, err
}
existingClients, err := inboundSvc.GetClients(inbound)
if err != nil {
return false, err
}
if err := validateClientsKeepAliveAgainstExisting(inbound.Protocol, clients, existingClients); err != nil {
return false, err
}
var settings map[string]any
err = json.Unmarshal([]byte(data.Settings), &settings)
@@ -372,11 +383,6 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
return false, err
}
existingClients, err := inboundSvc.GetClients(oldInbound)
if err != nil {
return false, err
}
// A client already on this inbound is skipped instead of appended again:
// checkEmailsExistForClients exempts a matching subId so one identity can
// live on several inbounds, which let retried or raced adds duplicate the
@@ -670,7 +676,10 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
if err := validateClientsRenewal(clients); err != nil {
return false, err
}
inbound, err := inboundSvc.GetInbound(data.Id)
if err != nil {
return false, err
}
var settings map[string]any
err = json.Unmarshal([]byte(data.Settings), &settings)
if err != nil {
@@ -691,6 +700,15 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
if err != nil {
return false, err
}
for i, client := range clients {
identityEmail := client.Email
if i == 0 {
identityEmail = oldEmail
}
if err := validateClientKeepAliveAgainstExisting(inbound.Protocol, client, oldClients, identityEmail); err != nil {
return false, fmt.Errorf("client %q: %w", client.Email, err)
}
}
newClientId := ""
switch oldInbound.Protocol {
@@ -844,8 +862,8 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
if clients[0].PreSharedKey != "" {
newMap["preSharedKey"] = clients[0].PreSharedKey
}
if ka := clients[0].KeepAliveSeconds(); ka > 0 {
newMap["keepAlive"] = ka
if clients[0].KeepAlive != nil {
newMap["keepAlive"] = clients[0].KeepAlive
}
if oldInbound.Protocol == model.AmneziaWG && clients[0].ForwardedPorts != "" {
newMap["forwardedPorts"] = clients[0].ForwardedPorts
+65
View File
@@ -0,0 +1,65 @@
package service
import (
"fmt"
"strconv"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func validateClientKeepAlive(protocol model.Protocol, client model.Client) error {
if client.KeepAlive == nil {
return nil
}
switch protocol {
case model.AmneziaWG:
return client.KeepAlive.Validate(true)
case model.WireGuard:
return client.KeepAlive.Validate(false)
default:
return nil
}
}
func validateClientsKeepAlive(protocol model.Protocol, clients []model.Client) error {
for _, client := range clients {
if err := validateClientKeepAlive(protocol, client); err != nil {
return fmt.Errorf("client %q: %w", client.Email, err)
}
}
return nil
}
func validateClientKeepAliveAgainstExisting(protocol model.Protocol, client model.Client, existing []model.Client, identityEmail ...string) error {
if err := validateClientKeepAlive(protocol, client); err == nil || client.KeepAlive == nil {
return err
}
if protocol != model.AmneziaWG && protocol != model.WireGuard {
return nil
}
value := client.KeepAlive.String()
identity := client.Email
if len(identityEmail) > 0 && identityEmail[0] != "" {
identity = identityEmail[0]
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || strconv.FormatInt(parsed, 10) != value {
return validateClientKeepAlive(protocol, client)
}
for _, old := range existing {
if strings.EqualFold(old.Email, identity) && old.KeepAlive != nil && old.KeepAlive.String() == value {
return nil
}
}
return validateClientKeepAlive(protocol, client)
}
func validateClientsKeepAliveAgainstExisting(protocol model.Protocol, clients, existing []model.Client) error {
for _, client := range clients {
if err := validateClientKeepAliveAgainstExisting(protocol, client, existing); err != nil {
return fmt.Errorf("client %q: %w", client.Email, err)
}
}
return nil
}
@@ -0,0 +1,374 @@
package service
import (
"encoding/json"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
func newKeepAliveRangeClient(t *testing.T, email, value string) model.Client {
t.Helper()
privateKey, publicKey, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate peer keypair: %v", err)
}
keepAlive := model.KeepAliveValue(value)
return model.Client{
Email: email, Enable: true, PrivateKey: privateKey, PublicKey: publicKey,
KeepAlive: &keepAlive,
}
}
func keepAliveTestAWGInbound(t *testing.T, port int) *model.Inbound {
t.Helper()
return mkInbound(t, port, model.AmneziaWG, `{"server":{"privateKey":"server","publicKey":"server-pub","subnetIp":"10.8.1.0","subnetCidr":24},"clients":[]}`)
}
func TestClientCreateAllowsAmneziaWGKeepAliveRangeAndPersistsNativeValue(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
clientSvc := &ClientService{}
inbound := keepAliveTestAWGInbound(t, 51871)
client := newKeepAliveRangeClient(t, "range@awg", "15-25")
if _, err := clientSvc.CreateOne(inboundSvc, inbound.Id, client); err != nil {
t.Fatalf("create AmneziaWG client with keepAlive range: %v", err)
}
var record model.ClientRecord
if err := database.GetDB().Where("email = ?", client.Email).First(&record).Error; err != nil {
t.Fatalf("load saved client: %v", err)
}
if record.KeepAlive != "15-25" {
t.Fatalf("saved keepAlive = %q, want 15-25", record.KeepAlive)
}
savedInbound, err := inboundSvc.GetInbound(inbound.Id)
if err != nil {
t.Fatal(err)
}
clients, err := inboundSvc.GetClients(savedInbound)
if err != nil {
t.Fatalf("load client settings: %v", err)
}
if len(clients) != 1 || clients[0].KeepAliveText() != "15-25" {
t.Fatalf("inbound keepAlive = %#v, want 15-25", clients)
}
}
func TestClientCreateRejectsKeepAliveRangeForWireGuardBeforeAnyWrite(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
clientSvc := &ClientService{}
wireguard := mkInbound(t, 51872, model.WireGuard, `{"secretKey":"server","clients":[]}`)
awg := keepAliveTestAWGInbound(t, 51873)
client := newKeepAliveRangeClient(t, "range@mixed", "15-25")
_, err := clientSvc.Create(inboundSvc, &ClientCreatePayload{
Client: client, InboundIds: []int{awg.Id, wireguard.Id},
})
if err == nil || !strings.Contains(err.Error(), "keepAlive ranges are supported only for AmneziaWG clients") {
t.Fatalf("mixed-protocol create error = %v, want WireGuard range rejection", err)
}
var records int64
if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", client.Email).Count(&records).Error; err != nil {
t.Fatal(err)
}
if records != 0 {
t.Fatalf("client records written before protocol validation: %d", records)
}
for _, inbound := range []*model.Inbound{awg, wireguard} {
saved, err := inboundSvc.GetInbound(inbound.Id)
if err != nil {
t.Fatal(err)
}
var settings struct {
Clients []model.Client `json:"clients"`
}
if err := json.Unmarshal([]byte(saved.Settings), &settings); err != nil {
t.Fatal(err)
}
if len(settings.Clients) != 0 {
t.Fatalf("client partially written to %s inbound: %#v", inbound.Protocol, settings.Clients)
}
}
}
func TestClientUpdateRejectsRangeWhenSharedWithWireGuard(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
clientSvc := &ClientService{}
awg := keepAliveTestAWGInbound(t, 51874)
wireguard := mkInbound(t, 51875, model.WireGuard, `{"secretKey":"server","clients":[]}`)
seed := newKeepAliveRangeClient(t, "shared@tunnels", "25")
if _, err := clientSvc.Create(inboundSvc, &ClientCreatePayload{
Client: seed, InboundIds: []int{awg.Id, wireguard.Id},
}); err != nil {
t.Fatalf("create scalar client on both protocols: %v", err)
}
record, err := clientSvc.GetRecordByEmail(nil, seed.Email)
if err != nil {
t.Fatal(err)
}
update := model.Client{Email: seed.Email, Enable: true, KeepAlive: seed.KeepAlive}
*update.KeepAlive = "15-25"
if _, err := clientSvc.Update(inboundSvc, record.Id, update, 0); err == nil || !strings.Contains(err.Error(), "keepAlive ranges are supported only for AmneziaWG clients") {
t.Fatalf("shared client update error = %v, want WireGuard range rejection", err)
}
var unchanged model.ClientRecord
if err := database.GetDB().Where("id = ?", record.Id).First(&unchanged).Error; err != nil {
t.Fatal(err)
}
if unchanged.KeepAlive != "25" {
t.Fatalf("shared record keepAlive after rejected update = %q, want 25", unchanged.KeepAlive)
}
for _, inbound := range []*model.Inbound{awg, wireguard} {
saved, err := inboundSvc.GetInbound(inbound.Id)
if err != nil {
t.Fatal(err)
}
clients, err := inboundSvc.GetClients(saved)
if err != nil {
t.Fatal(err)
}
if len(clients) != 1 || clients[0].KeepAliveText() != "25" {
t.Fatalf("%s inbound keepAlive after rejected update = %#v, want 25", inbound.Protocol, clients)
}
}
}
func TestClientCreateRejectsKeepAliveOutsideNativeRangeBeforeWrite(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
clientSvc := &ClientService{}
inbound := keepAliveTestAWGInbound(t, 51876)
client := newKeepAliveRangeClient(t, "too-large@awg", "65536")
if _, err := clientSvc.CreateOne(inboundSvc, inbound.Id, client); err == nil || !strings.Contains(err.Error(), "keepAlive must be an integer from 0 to 65535") {
t.Fatalf("out-of-range create error = %v, want keepAlive bound error", err)
}
var records int64
if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", client.Email).Count(&records).Error; err != nil {
t.Fatal(err)
}
if records != 0 {
t.Fatalf("out-of-range client was written: %d records", records)
}
saved, err := inboundSvc.GetInbound(inbound.Id)
if err != nil {
t.Fatal(err)
}
clients, err := inboundSvc.GetClients(saved)
if err != nil || len(clients) != 0 {
t.Fatalf("out-of-range client settings = %#v, %v; want no clients", clients, err)
}
}
func TestClientCreateAllowsRangeForSharedAmneziaWGAndVLESSClient(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
clientSvc := &ClientService{}
awg := keepAliveTestAWGInbound(t, 51877)
vless := mkInbound(t, 51878, model.VLESS, `{"clients":[]}`)
client := newKeepAliveRangeClient(t, "range@awg-vless", "15-25")
if _, err := clientSvc.Create(inboundSvc, &ClientCreatePayload{Client: client, InboundIds: []int{awg.Id, vless.Id}}); err != nil {
t.Fatalf("create client shared by AmneziaWG and VLESS: %v", err)
}
var record model.ClientRecord
if err := database.GetDB().Where("email = ?", client.Email).First(&record).Error; err != nil {
t.Fatalf("read shared client: %v", err)
}
if record.KeepAlive != "15-25" {
t.Fatalf("shared client keepAlive = %q, want 15-25", record.KeepAlive)
}
}
func TestClientUpdateAllowsRangeForSharedAmneziaWGAndVLESSClient(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
clientSvc := &ClientService{}
awg := keepAliveTestAWGInbound(t, 51879)
vless := mkInbound(t, 51880, model.VLESS, `{"clients":[]}`)
client := newKeepAliveRangeClient(t, "update@awg-vless", "25")
if _, err := clientSvc.Create(inboundSvc, &ClientCreatePayload{Client: client, InboundIds: []int{awg.Id, vless.Id}}); err != nil {
t.Fatalf("create scalar client shared by AmneziaWG and VLESS: %v", err)
}
record, err := clientSvc.GetRecordByEmail(nil, client.Email)
if err != nil {
t.Fatal(err)
}
updated := model.KeepAliveValue("15-25")
client.KeepAlive = &updated
if _, err := clientSvc.Update(inboundSvc, record.Id, client, 0); err != nil {
t.Fatalf("update shared client to an AmneziaWG range: %v", err)
}
var saved model.ClientRecord
if err := database.GetDB().Where("id = ?", record.Id).First(&saved).Error; err != nil {
t.Fatal(err)
}
if saved.KeepAlive != "15-25" {
t.Fatalf("updated shared client keepAlive = %q, want 15-25", saved.KeepAlive)
}
client.KeepAlive = model.KeepAlivePtr(25)
if _, err := clientSvc.Update(inboundSvc, record.Id, client, 0); err != nil {
t.Fatalf("change shared client range to scalar: %v", err)
}
client.KeepAlive = model.KeepAlivePtr(0)
if _, err := clientSvc.Update(inboundSvc, record.Id, client, 0); err != nil {
t.Fatalf("set explicit zero after range: %v", err)
}
client.KeepAlive = nil
if _, err := clientSvc.Update(inboundSvc, record.Id, client, 0); err != nil {
t.Fatalf("update with omitted keepAlive after explicit zero: %v", err)
}
if err := database.GetDB().Where("id = ?", record.Id).First(&saved).Error; err != nil {
t.Fatal(err)
}
if saved.KeepAlive != "0" {
t.Fatalf("omitted update changed explicit zero to %q", saved.KeepAlive)
}
}
func TestSetClientEnableByEmailPreservesLegacyAmneziaWGKeepAlive(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
clientSvc := &ClientService{}
legacy := newKeepAliveRangeClient(t, "legacy-enable@awg", "25")
inbound := keepAliveTestAWGInbound(t, 51881)
if _, err := clientSvc.CreateOne(inboundSvc, inbound.Id, legacy); err != nil {
t.Fatalf("create client: %v", err)
}
legacy.KeepAlive = model.KeepAlivePtr(70000)
if err := saveKeepAliveTestClientSettings(t, inboundSvc, inbound.Id, legacy); err != nil {
t.Fatalf("seed legacy client settings: %v", err)
}
changed, _, err := clientSvc.SetClientEnableByEmail(inboundSvc, legacy.Email, false)
if err != nil {
t.Fatalf("disable existing client with legacy keepAlive: %v", err)
}
if !changed {
t.Fatal("SetClientEnableByEmail reported no change")
}
saved, err := inboundSvc.GetInbound(inbound.Id)
if err != nil {
t.Fatal(err)
}
clients, err := inboundSvc.GetClients(saved)
if err != nil {
t.Fatal(err)
}
if len(clients) != 1 || clients[0].Enable || clients[0].KeepAliveText() != "70000" {
t.Fatalf("saved legacy client = %#v, want disabled with keepAlive 70000", clients)
}
}
func TestUpdateInboundPreservesUnchangedLegacyAmneziaWGKeepAlive(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
legacy := newKeepAliveRangeClient(t, "legacy-remark@awg", "-5")
legacy.AllowedIPs = []string{"10.8.1.3/32"}
inbound := mkInbound(t, 51882, model.AmneziaWG, clientsSettings(t, []model.Client{legacy}))
loaded, err := inboundSvc.GetInbound(inbound.Id)
if err != nil {
t.Fatal(err)
}
loaded.Remark = "updated remark"
if _, _, err := inboundSvc.UpdateInbound(loaded); err != nil {
t.Fatalf("save unrelated inbound remark with legacy keepAlive: %v", err)
}
saved, err := inboundSvc.GetInbound(inbound.Id)
if err != nil {
t.Fatal(err)
}
clients, err := inboundSvc.GetClients(saved)
if err != nil {
t.Fatal(err)
}
if saved.Remark != "updated remark" {
t.Fatalf("saved inbound remark = %q, want updated remark", saved.Remark)
}
if len(clients) != 1 || clients[0].KeepAliveText() != "-5" {
t.Fatalf("saved client keepAlive = %#v, want -5", clients)
}
}
func saveKeepAliveTestClientSettings(t *testing.T, inboundSvc *InboundService, inboundID int, client model.Client) error {
t.Helper()
inbound, err := inboundSvc.GetInbound(inboundID)
if err != nil {
return err
}
clients, err := inboundSvc.GetClients(inbound)
if err != nil {
return err
}
for i := range clients {
if clients[i].Email == client.Email {
clients[i].KeepAlive = client.KeepAlive
clients[i].AllowedIPs = client.AllowedIPs
clients[i].PrivateKey = client.PrivateKey
clients[i].PublicKey = client.PublicKey
}
}
return database.GetDB().Model(&model.Inbound{}).Where("id = ?", inboundID).Update("settings", clientsSettings(t, clients)).Error
}
func TestClientUpdateGrandfathersOnlyUnchangedLegacyKeepAlive(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
clientSvc := &ClientService{}
client := newKeepAliveRangeClient(t, "legacy-update@awg", "25")
inbound := keepAliveTestAWGInbound(t, 51883)
if _, err := clientSvc.CreateOne(inboundSvc, inbound.Id, client); err != nil {
t.Fatalf("create client: %v", err)
}
client.KeepAlive = model.KeepAlivePtr(70000)
if err := saveKeepAliveTestClientSettings(t, inboundSvc, inbound.Id, client); err != nil {
t.Fatalf("seed legacy client settings: %v", err)
}
fresh, err := inboundSvc.GetInbound(inbound.Id)
if err != nil {
t.Fatal(err)
}
clients, err := inboundSvc.GetClients(fresh)
if err != nil {
t.Fatal(err)
}
if err := clientSvc.SyncInbound(nil, inbound.Id, clients); err != nil {
t.Fatalf("sync legacy record: %v", err)
}
record, err := clientSvc.GetRecordByEmail(nil, client.Email)
if err != nil {
t.Fatal(err)
}
unchanged := model.Client{Email: client.Email, Enable: true, KeepAlive: model.KeepAlivePtr(70000), Comment: "updated comment"}
if _, err := clientSvc.Update(inboundSvc, record.Id, unchanged, 0); err != nil {
t.Fatalf("update unrelated field with unchanged legacy keepAlive: %v", err)
}
renamed := unchanged
renamed.Email = "legacy-renamed@awg"
if _, err := clientSvc.Update(inboundSvc, record.Id, renamed, 0); err != nil {
t.Fatalf("rename client with unchanged legacy keepAlive: %v", err)
}
record, err = clientSvc.GetByID(record.Id)
if err != nil {
t.Fatal(err)
}
if record.Email != "legacy-renamed@awg" || record.Comment != "updated comment" || record.KeepAlive != "70000" {
t.Fatalf("saved renamed record = %#v, want renamed/comment updated with keepAlive 70000", record)
}
changed := renamed
changed.KeepAlive = model.KeepAlivePtr(70001)
if _, err := clientSvc.Update(inboundSvc, record.Id, changed, 0); err == nil || !strings.Contains(err.Error(), "keepAlive must be an integer from 0 to 65535") {
t.Fatalf("changed invalid legacy value error = %v, want keepAlive bound error", err)
}
stored, err := clientSvc.GetByID(record.Id)
if err != nil {
t.Fatal(err)
}
if stored.KeepAlive != "70000" {
t.Fatalf("record after rejected changed legacy value = %q, want 70000", stored.KeepAlive)
}
}
+3 -1
View File
@@ -55,7 +55,9 @@ func applyClientRecordMerge(row *model.ClientRecord, incoming *model.ClientRecor
row.AllowedIPs = incoming.AllowedIPs
}
row.PreSharedKey = incoming.PreSharedKey
row.KeepAlive = incoming.KeepAlive
if incoming.KeepAlive != "" {
row.KeepAlive = incoming.KeepAlive
}
row.SubID = incoming.SubID
row.LimitIP = incoming.LimitIP
row.TotalGB = incoming.TotalGB
@@ -70,8 +70,8 @@ func TestUpdateCanClearKeepAliveOnAnExistingClient(t *testing.T) {
if got := inboundKeepAlive(t, inboundSvc, ib.Id, "ka@x"); got != 0 {
t.Fatalf("inbound keepAlive after an explicit 0 = %d, want 0", got)
}
if got := lookupClientRecord(t, "ka@x").KeepAlive; got != 0 {
t.Fatalf("stored wg_keep_alive after an explicit 0 = %d, want 0", got)
if got := lookupClientRecord(t, "ka@x").KeepAlive.String(); got != "0" {
t.Fatalf("stored wg_keep_alive after an explicit 0 = %s, want 0", got)
}
}
@@ -96,7 +96,7 @@ func TestUpdateWithoutKeepAlivePreservesTheStoredValue(t *testing.T) {
if got := inboundKeepAlive(t, inboundSvc, ib.Id, "ka@x"); got != 25 {
t.Fatalf("inbound keepAlive after an edit that omitted it = %d, want 25", got)
}
if got := lookupClientRecord(t, "ka@x").KeepAlive; got != 25 {
t.Fatalf("stored wg_keep_alive after an edit that omitted it = %d, want 25", got)
if got := lookupClientRecord(t, "ka@x").KeepAlive.String(); got != "25" {
t.Fatalf("stored wg_keep_alive after an edit that omitted it = %s, want 25", got)
}
}
+15 -2
View File
@@ -1338,6 +1338,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
if err := validateClientsRenewal(clients); err != nil {
return inbound, false, err
}
if err := validateClientsKeepAlive(inbound.Protocol, clients); err != nil {
return inbound, false, err
}
for _, traffic := range inbound.ClientStats {
if err := validateClientRenewal(model.Client{Reset: traffic.Reset, ResetDay: traffic.ResetDay, ResetWeekday: traffic.ResetWeekday}); err != nil {
return inbound, false, err
@@ -1912,7 +1915,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
return needRestart, nil
}
func (s *InboundService) validateUpdatedInboundClients(inbound *model.Inbound) error {
func (s *InboundService) validateUpdatedInboundClients(inbound, existing *model.Inbound) error {
clients, err := s.GetClients(inbound)
if err != nil {
return err
@@ -1920,6 +1923,16 @@ func (s *InboundService) validateUpdatedInboundClients(inbound *model.Inbound) e
if err := validateClientsRenewal(clients); err != nil {
return err
}
var existingClients []model.Client
if inbound.Protocol == existing.Protocol {
existingClients, err = s.GetClients(existing)
if err != nil {
return err
}
}
if err := validateClientsKeepAliveAgainstExisting(inbound.Protocol, clients, existingClients); err != nil {
return err
}
for _, client := range clients {
switch inbound.Protocol {
case model.Hysteria:
@@ -2038,7 +2051,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
inbound.Enable = stored.Enable
}
// On the clients actually saved: a protocol switch keeps the stored ones.
if err := s.validateUpdatedInboundClients(inbound); err != nil {
if err := s.validateUpdatedInboundClients(inbound, stored); err != nil {
return err
}
conflict, cErr := checkPortConflictTx(tx, inbound, inbound.Id)
@@ -56,7 +56,7 @@ func seedWGInbound(t *testing.T, tag string, port int, clients []model.Client) {
}
}
func seedDualTunnelClient(t *testing.T, enabled bool, wgKeepAlive *int) string {
func seedDualTunnelClient(t *testing.T, enabled bool, wgKeepAlive *model.KeepAliveValue) string {
t.Helper()
setupSettingTestDB(t)
db := database.GetDB()
@@ -202,8 +202,8 @@ func TestGetXrayConfigWireGuardUsesInboundLocalTunnelFields(t *testing.T) {
if err := database.GetDB().Where("email = ?", email).First(&shared).Error; err != nil {
t.Fatalf("read shared client: %v", err)
}
if shared.AllowedIPs != "10.8.1.5/32" || shared.PreSharedKey != "awg-psk" || shared.PublicKey != "pub-awg" || shared.KeepAlive != 25 {
t.Fatalf("test setup did not persist AmneziaWG identity: allowedIPs=%q preSharedKey=%q publicKey=%q keepAlive=%d", shared.AllowedIPs, shared.PreSharedKey, shared.PublicKey, shared.KeepAlive)
if shared.AllowedIPs != "10.8.1.5/32" || shared.PreSharedKey != "awg-psk" || shared.PublicKey != "pub-awg" || shared.KeepAlive.Seconds() != 25 {
t.Fatalf("test setup did not persist AmneziaWG identity: allowedIPs=%q preSharedKey=%q publicKey=%q keepAlive=%d", shared.AllowedIPs, shared.PreSharedKey, shared.PublicKey, shared.KeepAlive.Seconds())
}
peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual"))
@@ -232,8 +232,8 @@ func TestGetXrayConfigWireGuardOmitsKeepAliveAbsentFromSettings(t *testing.T) {
if err := database.GetDB().Where("email = ?", email).First(&shared).Error; err != nil {
t.Fatalf("read shared client: %v", err)
}
if shared.PublicKey != "pub-awg" || shared.KeepAlive != 25 {
t.Fatalf("test setup did not persist AmneziaWG identity: publicKey=%q keepAlive=%d", shared.PublicKey, shared.KeepAlive)
if shared.PublicKey != "pub-awg" || shared.KeepAlive.Seconds() != 25 {
t.Fatalf("test setup did not persist AmneziaWG identity: publicKey=%q keepAlive=%d", shared.PublicKey, shared.KeepAlive.Seconds())
}
peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual"))