diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index e29982637..3a3e2b35c 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1147,6 +1147,8 @@ "type": "string" }, "keepAlive": { + "description": "Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value", + "nullable": true, "type": "integer" }, "limitIp": { diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index a66eeec7b..2d5ab643a 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -275,7 +275,7 @@ export const EXAMPLES: Record = { "forwardedPorts": "", "group": "", "id": "", - "keepAlive": 0, + "keepAlive": null, "limitIp": 0, "password": "", "preSharedKey": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 84c9820a5..1e5154a4d 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1121,6 +1121,8 @@ export const SCHEMAS: Record = { "type": "string" }, "keepAlive": { + "description": "Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value", + "nullable": true, "type": "integer" }, "limitIp": { diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 215772233..64fe0ed3b 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -271,7 +271,7 @@ export interface Client { forwardedPorts?: string; group?: string; id?: string; - keepAlive?: number; + keepAlive?: number | null; limitIp: number; password?: string; preSharedKey?: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index e95ea30b2..9ba2aa489 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -292,7 +292,7 @@ export const ClientSchema = z.object({ forwardedPorts: z.string().optional(), group: z.string().optional(), id: z.string().optional(), - keepAlive: z.number().int().optional(), + keepAlive: z.number().int().nullable().optional(), limitIp: z.number().int(), password: z.string().optional(), preSharedKey: z.string().optional(), diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 3f5912557..cae6b9406 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -1279,6 +1279,7 @@ export default function ClientFormModal({ name="wgKeepAlive" label={t('pages.clients.tunnelKeepAlive')} extra={t('pages.clients.tunnelKeepAliveHint')} + transform={{ output: (v) => Number(v) || 0 }} > diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 5e2db9dc8..d7bc6c562 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -441,8 +441,8 @@ func WireguardPeerFromClient(c Client) map[string]any { if c.PreSharedKey != "" { peer["preSharedKey"] = c.PreSharedKey } - if c.KeepAlive > 0 { - peer["keepAlive"] = c.KeepAlive + if ka := c.KeepAliveSeconds(); ka > 0 { + peer["keepAlive"] = ka } return peer } @@ -890,7 +890,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"` + KeepAlive *int `json:"keepAlive,omitempty"` // Seconds between PersistentKeepalive packets; 0 sends none, omit to keep the stored value 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"` @@ -1113,6 +1113,18 @@ type Host struct { func (Host) TableName() string { return "hosts" } +// KeepAliveSeconds is the client's PersistentKeepalive, 0 when unset. +func (c Client) KeepAliveSeconds() int { + if c.KeepAlive == nil { + return 0 + } + return *c.KeepAlive +} + +// 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) ToRecord() *ClientRecord { rec := &ClientRecord{ Email: c.Email, @@ -1141,7 +1153,7 @@ func (c *Client) ToRecord() *ClientRecord { PublicKey: c.PublicKey, AllowedIPs: strings.Join(c.AllowedIPs, ","), PreSharedKey: c.PreSharedKey, - KeepAlive: c.KeepAlive, + KeepAlive: c.KeepAliveSeconds(), ForwardedPorts: c.ForwardedPorts, Secret: c.Secret, AdTag: c.AdTag, @@ -1199,7 +1211,7 @@ func (r *ClientRecord) ToClient() *Client { PublicKey: r.PublicKey, AllowedIPs: splitWireguardAllowedIPs(r.AllowedIPs), PreSharedKey: r.PreSharedKey, - KeepAlive: r.KeepAlive, + KeepAlive: KeepAlivePtr(r.KeepAlive), ForwardedPorts: r.ForwardedPorts, Secret: r.Secret, AdTag: r.AdTag, diff --git a/internal/database/model/model_wireguard_test.go b/internal/database/model/model_wireguard_test.go index 6c5a38489..e86be5f6b 100644 --- a/internal/database/model/model_wireguard_test.go +++ b/internal/database/model/model_wireguard_test.go @@ -13,7 +13,7 @@ func TestClientToRecordRoundTripWireGuard(t *testing.T) { PublicKey: "cGVlci1wdWJsaWMta2V5LWJhc2U2NC0zMmJ5dGVzISE=", AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"}, PreSharedKey: "cHNrLWJhc2U2NC0zMmJ5dGVzLXBsYWNlaG9sZGVyISE=", - KeepAlive: 25, + KeepAlive: KeepAlivePtr(25), } rec := c.ToRecord() @@ -29,7 +29,7 @@ func TestClientToRecordRoundTripWireGuard(t *testing.T) { {"PrivateKey", c.PrivateKey, got.PrivateKey}, {"PublicKey", c.PublicKey, got.PublicKey}, {"PreSharedKey", c.PreSharedKey, got.PreSharedKey}, - {"KeepAlive", c.KeepAlive, got.KeepAlive}, + {"KeepAlive", c.KeepAliveSeconds(), got.KeepAliveSeconds()}, } { if f.a != f.b { t.Errorf("%s round-trip = %v, want %v", f.name, f.b, f.a) diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index 2a35747cb..50bc9e19e 100644 --- a/internal/sub/clash_service.go +++ b/internal/sub/clash_service.go @@ -407,8 +407,8 @@ func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model if client.PreSharedKey != "" { proxy["pre-shared-key"] = client.PreSharedKey } - if client.KeepAlive > 0 { - proxy["persistent-keepalive"] = client.KeepAlive + if ka := client.KeepAliveSeconds(); ka > 0 { + proxy["persistent-keepalive"] = ka } for _, addr := range client.AllowedIPs { ip := stripCIDR(addr) diff --git a/internal/sub/clash_service_test.go b/internal/sub/clash_service_test.go index 1147fea4c..964843394 100644 --- a/internal/sub/clash_service_test.go +++ b/internal/sub/clash_service_test.go @@ -828,7 +828,7 @@ func TestBuildWireguardProxyForClash(t *testing.T) { Email: "user", PrivateKey: clientPriv, PreSharedKey: "psk-value", - KeepAlive: 25, + KeepAlive: model.KeepAlivePtr(25), AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"}, } diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index 15cd824c0..bf720fcf2 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -866,8 +866,8 @@ func (s *SubJsonService) genWireguard(inbound *model.Inbound, client model.Clien if client.PreSharedKey != "" { peer["preSharedKey"] = client.PreSharedKey } - if client.KeepAlive > 0 { - peer["keepAlive"] = client.KeepAlive + if ka := client.KeepAliveSeconds(); ka > 0 { + peer["keepAlive"] = ka } settings := map[string]any{ diff --git a/internal/sub/json_service_test.go b/internal/sub/json_service_test.go index c7c6a4890..bb5c4d04b 100644 --- a/internal/sub/json_service_test.go +++ b/internal/sub/json_service_test.go @@ -380,7 +380,7 @@ func TestSubJsonServiceWireguard(t *testing.T) { Email: "user", PrivateKey: clientPriv, PreSharedKey: "psk-value", - KeepAlive: 25, + KeepAlive: model.KeepAlivePtr(25), AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"}, } diff --git a/internal/sub/service.go b/internal/sub/service.go index cc90e3150..a112b3803 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -677,8 +677,8 @@ func (s *SubService) genWireguardLink(inbound *model.Inbound, email string) stri if client.PreSharedKey != "" { params["presharedkey"] = client.PreSharedKey } - if client.KeepAlive > 0 { - params["keepalive"] = strconv.Itoa(client.KeepAlive) + if ka := client.KeepAliveSeconds(); ka > 0 { + params["keepalive"] = strconv.Itoa(ka) } return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", "")) } @@ -779,8 +779,8 @@ func amneziaWGConfigText(server *amneziawg.ServerSettings, client *model.Client, } b.WriteString("AllowedIPs = 0.0.0.0/0, ::/0\n") fmt.Fprintf(&b, "Endpoint = %s:%d", host, port) - if client.KeepAlive > 0 { - fmt.Fprintf(&b, "\nPersistentKeepalive = %d", client.KeepAlive) + if ka := client.KeepAliveSeconds(); ka > 0 { + fmt.Fprintf(&b, "\nPersistentKeepalive = %d", ka) } return b.String() diff --git a/internal/sub/service_amneziawg_test.go b/internal/sub/service_amneziawg_test.go index bfd10e244..42dccf43e 100644 --- a/internal/sub/service_amneziawg_test.go +++ b/internal/sub/service_amneziawg_test.go @@ -214,7 +214,7 @@ func TestAmneziaWGConfigTextPeerFieldOrder(t *testing.T) { server := &amneziawg.ServerSettings{PublicKey: "serverPub", PrimaryDNS: "8.8.8.8", MTU: 1420} t.Run("every optional field set", func(t *testing.T) { - client := &model.Client{PrivateKey: "clientPriv", AllowedIPs: []string{"10.8.1.2/32"}, PreSharedKey: "psk", KeepAlive: 25} + client := &model.Client{PrivateKey: "clientPriv", AllowedIPs: []string{"10.8.1.2/32"}, PreSharedKey: "psk", KeepAlive: model.KeepAlivePtr(25)} conf := amneziaWGConfigText(server, client, "203.0.113.7", 51820, "remark") if got := peerFields(t, conf); !slices.Equal(got, peerFieldOrder) { t.Fatalf("peer fields = %v, want %v\n%s", got, peerFieldOrder, conf) diff --git a/internal/web/runtime/local.go b/internal/web/runtime/local.go index 5efd49e66..e36a25fdf 100644 --- a/internal/web/runtime/local.go +++ b/internal/web/runtime/local.go @@ -241,7 +241,7 @@ func (l *Local) AddClient(ctx context.Context, ib *model.Inbound, client model.C "publicKey": client.PublicKey, "allowedIPs": client.AllowedIPs, "preSharedKey": client.PreSharedKey, - "keepAlive": wgKeepAlive(client.KeepAlive), + "keepAlive": wgKeepAlive(client.KeepAliveSeconds()), } return l.AddUser(ctx, ib, user) } @@ -282,7 +282,7 @@ func (l *Local) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail stri "publicKey": payload.PublicKey, "allowedIPs": payload.AllowedIPs, "preSharedKey": payload.PreSharedKey, - "keepAlive": wgKeepAlive(payload.KeepAlive), + "keepAlive": wgKeepAlive(payload.KeepAliveSeconds()), } return l.AddUser(ctx, ib, user) } diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index ffe6c736b..a0fed936a 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/internal/web/service/client_inbound_apply.go @@ -583,7 +583,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model "publicKey": client.PublicKey, "allowedIPs": client.AllowedIPs, "preSharedKey": client.PreSharedKey, - "keepAlive": keepAliveStr(client.KeepAlive), + "keepAlive": keepAliveStr(client.KeepAliveSeconds()), }) if err1 == nil { logger.Debug("Client added on", rt.Name(), ":", client.Email) @@ -727,7 +727,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo if clients[0].PreSharedKey == "" { clients[0].PreSharedKey = old.PreSharedKey } - if clients[0].KeepAlive == 0 { + if clients[0].KeepAlive == nil { clients[0].KeepAlive = old.KeepAlive } // ForwardedPorts is AmneziaWG-only (WireGuard's own inbound never @@ -794,8 +794,8 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo if clients[0].PreSharedKey != "" { newMap["preSharedKey"] = clients[0].PreSharedKey } - if clients[0].KeepAlive > 0 { - newMap["keepAlive"] = clients[0].KeepAlive + if ka := clients[0].KeepAliveSeconds(); ka > 0 { + newMap["keepAlive"] = ka } if oldInbound.Protocol == model.AmneziaWG && clients[0].ForwardedPorts != "" { newMap["forwardedPorts"] = clients[0].ForwardedPorts @@ -1007,7 +1007,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo "publicKey": clients[0].PublicKey, "allowedIPs": clients[0].AllowedIPs, "preSharedKey": clients[0].PreSharedKey, - "keepAlive": keepAliveStr(clients[0].KeepAlive), + "keepAlive": keepAliveStr(clients[0].KeepAliveSeconds()), }) if err1 == nil { logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email) diff --git a/internal/web/service/client_update_keepalive_test.go b/internal/web/service/client_update_keepalive_test.go new file mode 100644 index 000000000..f9218afbd --- /dev/null +++ b/internal/web/service/client_update_keepalive_test.go @@ -0,0 +1,104 @@ +package service + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func inboundKeepAlive(t *testing.T, inboundSvc *InboundService, ibId int, email string) int { + t.Helper() + ib, err := inboundSvc.GetInbound(ibId) + if err != nil { + t.Fatalf("GetInbound %d: %v", ibId, err) + } + clients, err := inboundSvc.GetClients(ib) + if err != nil { + t.Fatalf("GetClients %d: %v", ibId, err) + } + for i := range clients { + if clients[i].Email == email { + return clients[i].KeepAliveSeconds() + } + } + t.Fatalf("email %q not found on inbound %d", email, ibId) + return 0 +} + +// seedKeepAliveClient attaches one WireGuard client already carrying a +// PersistentKeepalive, and returns its inbound and client-record id. +func seedKeepAliveClient(t *testing.T, email string, keepAlive int) (*model.Inbound, int) { + t.Helper() + svc := &ClientService{} + + seeded := model.Client{ + Email: email, + SubID: "sub-" + email, + Enable: true, + AllowedIPs: []string{"10.0.0.5/32"}, + KeepAlive: model.KeepAlivePtr(keepAlive), + } + ib := mkInbound(t, 51820, model.WireGuard, clientsSettings(t, []model.Client{seeded})) + if err := svc.SyncInbound(nil, ib.Id, []model.Client{seeded}); err != nil { + t.Fatalf("seed linkage: %v", err) + } + return ib, lookupClientRecord(t, email).Id +} + +// The update path restores the stored keepalive whenever the incoming one is +// zero. That was a 0 -> 0 no-op while no UI could set the field; once the +// client form could, "0 disables it" became unreachable on an existing client. +func TestUpdateCanClearKeepAliveOnAnExistingClient(t *testing.T) { + setupBulkDB(t) + inboundSvc := &InboundService{} + svc := &ClientService{} + + ib, recId := seedKeepAliveClient(t, "ka@x", 25) + if got := inboundKeepAlive(t, inboundSvc, ib.Id, "ka@x"); got != 25 { + t.Fatalf("seeded keepAlive = %d, want 25", got) + } + + updated := model.Client{ + Email: "ka@x", + Enable: true, + AllowedIPs: []string{"10.0.0.5/32"}, + KeepAlive: model.KeepAlivePtr(0), + } + if _, err := svc.Update(inboundSvc, recId, updated, 0); err != nil { + t.Fatalf("Update: %v", err) + } + + 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) + } +} + +// The other half of the same contract: a payload that never mentions +// keepAlive (a metadata-only edit from the bot or the API) must still leave +// the stored value alone. +func TestUpdateWithoutKeepAlivePreservesTheStoredValue(t *testing.T) { + setupBulkDB(t) + inboundSvc := &InboundService{} + svc := &ClientService{} + + ib, recId := seedKeepAliveClient(t, "ka@x", 25) + + updated := model.Client{ + Email: "ka@x", + Enable: true, + AllowedIPs: []string{"10.0.0.5/32"}, + } + if _, err := svc.Update(inboundSvc, recId, updated, 0); err != nil { + t.Fatalf("Update: %v", err) + } + + 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) + } +} diff --git a/internal/web/service/client_wireguard.go b/internal/web/service/client_wireguard.go index 67c555f71..2a14be36b 100644 --- a/internal/web/service/client_wireguard.go +++ b/internal/web/service/client_wireguard.go @@ -249,8 +249,8 @@ func defaultWireguardClients(settingsJSON string, existing, clients []model.Clie if c.PreSharedKey != "" { m["preSharedKey"] = c.PreSharedKey } - if c.KeepAlive > 0 { - m["keepAlive"] = c.KeepAlive + if ka := c.KeepAliveSeconds(); ka > 0 { + m["keepAlive"] = ka } interfaceClients[i] = m } diff --git a/internal/web/service/xray_wireguard_config_test.go b/internal/web/service/xray_wireguard_config_test.go index 1cc5922ff..52bca15b4 100644 --- a/internal/web/service/xray_wireguard_config_test.go +++ b/internal/web/service/xray_wireguard_config_test.go @@ -77,7 +77,7 @@ func wgPeerList(t *testing.T, settings map[string]any) []map[string]any { func TestGetXrayConfigWireGuardPeers(t *testing.T) { clients := []model.Client{ - {Email: "alice@wg.test", Enable: true, PublicKey: "pub-alice", AllowedIPs: []string{"10.0.0.2/32"}, KeepAlive: 25}, + {Email: "alice@wg.test", Enable: true, PublicKey: "pub-alice", AllowedIPs: []string{"10.0.0.2/32"}, KeepAlive: model.KeepAlivePtr(25)}, {Email: "bob@wg.test", Enable: true, PublicKey: "pub-bob", AllowedIPs: []string{"10.0.0.3/32"}}, } seedWGInbound(t, "wg-multi", 51820, clients)