Files
3x-ui/internal/web/service/kernel_client_guard_test.go
T
MHSanaei a9191cb468 fix(amneziawg): let an emptied forwardedPorts withdraw a client's forwards
UpdateInboundClient carried the stored forwardedPorts forward whenever
the incoming value was empty, and Client.ForwardedPorts was a plain
omitempty string, so clearing the field in the client editor, as its
hint says to, reported success while the settings kept the old ports
and the host kept relaying them to the client's device.

The field is now a pointer, as KeepAlive is: nil (not sent) keeps the
stored spec and "" withdraws it. The client record follows the live
spec on every update, and a one-time seeder fills records that still
hold their creation value from the inbound settings, so the editor no
longer posts an empty field that would drop forwards added later.
2026-11-01 08:55:38 +01:00

200 lines
8.2 KiB
Go

package service
import (
"encoding/json"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func kernelAWGSettings(clients []model.Client) string {
b, _ := json.Marshal(map[string]any{
"server": map[string]any{"subnetIp": "10.8.1.0", "subnetCidr": 24, "kernelMode": true},
"clients": clients,
})
return string(b)
}
func TestKernelAWGClientWritesRejectUnsafeValuesAndUnsupportedFeatures(t *testing.T) {
cases := []struct {
name, want string
client model.Client
}{
{"public key hook injection", "control characters", model.Client{Email: "bad-key@awg", Enable: true, PublicKey: "peer-key\n[Interface]\nPostDown = touch /tmp/pwned", AllowedIPs: []string{"10.8.1.2/32"}}},
{"forwarded ports", "port forwarding is not supported", model.Client{Email: "bad-forward@awg", Enable: true, PublicKey: "peer-key", AllowedIPs: []string{"10.8.1.2/32"}, ForwardedPorts: model.ForwardedPortsPtr("54321")}},
{"IPv6 allowed IP", "per-peer IPv6 egress is not supported", model.Client{Email: "bad-v6@awg", Enable: true, PublicKey: "peer-key", AllowedIPs: []string{"2001:db8::2/128"}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
svc := &ClientService{}
ib := mkInbound(t, 52000, model.AmneziaWG, kernelAWGSettings(nil))
tc.client.AllowedIPsByInbound = map[int][]string{ib.Id: tc.client.AllowedIPs}
_, err := svc.CreateOne(inboundSvc, ib.Id, tc.client)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("CreateOne error = %v, want an error containing %q", err, tc.want)
}
var records int64
if dbErr := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", tc.client.Email).Count(&records).Error; dbErr != nil {
t.Fatal(dbErr)
}
if records != 0 {
t.Fatalf("rejected create persisted %d client records", records)
}
after, getErr := inboundSvc.GetInbound(ib.Id)
if getErr != nil {
t.Fatal(getErr)
}
if len(mustAWGClients(t, after)) != 0 {
t.Fatal("rejected create changed inbound settings")
}
})
}
}
func mustAWGClients(t *testing.T, ib *model.Inbound) []model.Client {
t.Helper()
clients, err := (&InboundService{}).GetClients(ib)
if err != nil {
t.Fatal(err)
}
return clients
}
func TestKernelAWGClientUpdateRejectsUnsafeValuesAndUnsupportedFeatures(t *testing.T) {
cases := []struct {
name, want string
update model.Client
}{
{"preshared key hook injection", "control characters", model.Client{Email: "kept@awg", PreSharedKey: "psk\n[Interface]\nPreDown = touch /tmp/pwned"}},
{"forwarded ports", "port forwarding is not supported", model.Client{Email: "kept@awg", ForwardedPorts: model.ForwardedPortsPtr("54321")}},
{"IPv6 allowed IP", "per-peer IPv6 egress is not supported", model.Client{Email: "kept@awg", AllowedIPs: []string{"2001:db8::2/128"}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
svc := &ClientService{}
seed := model.Client{Email: "kept@awg", Enable: true, PublicKey: "valid-key", PrivateKey: "valid-private", AllowedIPs: []string{"10.8.1.2/32"}}
ib := mkInbound(t, 52001, model.AmneziaWG, kernelAWGSettings(nil))
if _, err := svc.CreateOne(inboundSvc, ib.Id, seed); err != nil {
t.Fatalf("seed client: %v", err)
}
rec := lookupClientRecord(t, seed.Email)
before, getErr := inboundSvc.GetInbound(ib.Id)
if getErr != nil {
t.Fatal(getErr)
}
update := tc.update
if len(update.AllowedIPs) > 0 {
update.AllowedIPsByInbound = map[int][]string{ib.Id: update.AllowedIPs}
}
_, err := svc.Update(inboundSvc, rec.Id, update, 0)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("ClientService.Update error = %v, want an error containing %q", err, tc.want)
}
after, err := inboundSvc.GetInbound(ib.Id)
if err != nil {
t.Fatal(err)
}
if after.Settings != before.Settings {
t.Fatalf("rejected update changed settings")
}
var unchanged model.ClientRecord
if err := database.GetDB().First(&unchanged, rec.Id).Error; err != nil {
t.Fatal(err)
}
if unchanged.PreSharedKey != rec.PreSharedKey || unchanged.AllowedIPs != rec.AllowedIPs {
t.Fatalf("rejected update changed client record: before %+v after %+v", rec, unchanged)
}
})
}
}
func TestKernelAWGClientFeatureChecksPreserveSupportedPaths(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
svc := &ClientService{}
kernel := mkInbound(t, 52002, model.AmneziaWG, kernelAWGSettings(nil))
if _, err := svc.CreateOne(inboundSvc, kernel.Id, model.Client{Email: "valid@kernel", Enable: true, PublicKey: "valid-key", AllowedIPs: []string{"10.8.1.2/32"}}); err != nil {
t.Fatalf("valid IPv4 kernel peer was rejected: %v", err)
}
embedded := mkInbound(t, 52003, model.AmneziaWG, amneziawgClientTestSettings)
if _, err := svc.CreateOne(inboundSvc, embedded.Id, model.Client{Email: "forward@embedded", Enable: true, PublicKey: "valid-key", AllowedIPs: []string{"10.8.1.3/32"}, ForwardedPorts: model.ForwardedPortsPtr("54321")}); err != nil {
t.Fatalf("embedded AWG port forwarding was rejected: %v", err)
}
}
func TestKernelAWGSharedCreateAndUpdateValidateAllTargetsBeforeWrites(t *testing.T) {
t.Run("create", func(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
svc := &ClientService{}
kernel := mkInbound(t, 52005, model.AmneziaWG, kernelAWGSettings(nil))
embedded := mkInbound(t, 52006, model.AmneziaWG, amneziawgClientTestSettings)
_, err := svc.Create(inboundSvc, &ClientCreatePayload{
Client: model.Client{Email: "shared-create@awg", Enable: true, PublicKey: "valid-key", AllowedIPs: []string{"10.8.1.2/32"}, ForwardedPorts: model.ForwardedPortsPtr("54321")},
InboundIds: []int{embedded.Id, kernel.Id},
})
if err == nil || !strings.Contains(err.Error(), "port forwarding is not supported") {
t.Fatalf("Create error = %v, want kernel feature rejection", err)
}
for _, ib := range []*model.Inbound{embedded, kernel} {
after, getErr := inboundSvc.GetInbound(ib.Id)
if getErr != nil {
t.Fatal(getErr)
}
if len(mustAWGClients(t, after)) != 0 {
t.Fatalf("failed shared create partially changed inbound %d", ib.Id)
}
}
var records int64
if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", "shared-create@awg").Count(&records).Error; err != nil {
t.Fatal(err)
}
if records != 0 {
t.Fatalf("failed shared create persisted %d client records", records)
}
})
t.Run("update", func(t *testing.T) {
setupBulkDB(t)
inboundSvc := &InboundService{}
svc := &ClientService{}
kernel := mkInbound(t, 52007, model.AmneziaWG, kernelAWGSettings(nil))
embedded := mkInbound(t, 52008, model.AmneziaWG, amneziawgClientTestSettings)
seed := model.Client{Email: "shared-update@awg", Enable: true, PublicKey: "valid-key", AllowedIPsByInbound: map[int][]string{kernel.Id: {"10.8.1.2/32"}, embedded.Id: {"10.8.1.3/32"}}}
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{Client: seed, InboundIds: []int{kernel.Id, embedded.Id}}); err != nil {
t.Fatalf("seed shared client: %v", err)
}
beforeKernel, _ := inboundSvc.GetInbound(kernel.Id)
beforeEmbedded, _ := inboundSvc.GetInbound(embedded.Id)
rec := lookupClientRecord(t, seed.Email)
_, err := svc.Update(inboundSvc, rec.Id, model.Client{Email: seed.Email, ForwardedPorts: model.ForwardedPortsPtr("54321")}, 0)
if err == nil || !strings.Contains(err.Error(), "port forwarding is not supported") {
t.Fatalf("Update error = %v, want kernel feature rejection", err)
}
afterKernel, getErr := inboundSvc.GetInbound(kernel.Id)
if getErr != nil {
t.Fatal(getErr)
}
afterEmbedded, getErr := inboundSvc.GetInbound(embedded.Id)
if getErr != nil {
t.Fatal(getErr)
}
if afterKernel.Settings != beforeKernel.Settings || afterEmbedded.Settings != beforeEmbedded.Settings {
t.Fatal("failed shared update partially changed inbound settings")
}
var unchanged model.ClientRecord
if err := database.GetDB().First(&unchanged, rec.Id).Error; err != nil {
t.Fatal(err)
}
if unchanged.ForwardedPorts != rec.ForwardedPorts {
t.Fatal("failed shared update changed the client record")
}
})
}