Files
3x-ui/internal/web/service/kernel_client_guard_test.go
T
Aleksey fee7b1621d fix(amneziawg): validate peer subnets before enabling kernel mode (#6965)
A kernel-mode AmneziaWG inbound routes and masquerades only its interface subnet, so peers whose AllowedIPs lay outside it (a restored backup, a changed subnet) handshook but got no internet. Enabling kernel mode, saving an active kernel inbound, and adding, enabling or re-addressing a client now refuse out-of-subnet peers with the offending client named. Legacy stray peers still start and can still be edited or disabled.
2026-11-03 15:52:26 +01:00

284 lines
12 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"}}},
{"outside server subnet", "outside kernel interface subnets", model.Client{Email: "bad-subnet@awg", Enable: true, PublicKey: "peer-key", AllowedIPs: []string{"10.9.1.2/32"}}},
}
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"}}},
{"outside server subnet", "outside kernel interface subnets", model.Client{Email: "kept@awg", AllowedIPs: []string{"10.9.1.2/32"}}},
}
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
update.Enable = true
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 TestKernelAWGLegacyClientRecovery(t *testing.T) {
cases := []struct {
name, action string
clientEnabled, inboundEnabled, wantReject bool
}{
{"disable legacy peer", "disable", true, true, false},
{"renew legacy peer", "renew", true, true, false},
{"edit legacy peer", "edit", true, true, false},
{"rename legacy peer", "rename", true, true, false},
{"edit disabled peer", "edit", false, true, false},
{"edit disabled inbound", "change", true, false, false},
{"enable legacy peer", "enable", false, true, true},
{"change to another invalid address", "change", true, true, true},
{"repair legacy address", "repair", true, true, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
setupBulkDB(t)
inboundSvc, svc := &InboundService{}, &ClientService{}
ib := mkInbound(t, 52009, model.AmneziaWG, kernelAWGSettings(nil))
seed := model.Client{Email: "legacy@awg", Enable: tc.clientEnabled, PublicKey: "valid-key", PrivateKey: "valid-private", AllowedIPs: []string{"10.8.1.2/32"}}
if _, err := svc.CreateOne(inboundSvc, ib.Id, seed); err != nil {
t.Fatal(err)
}
rec := lookupClientRecord(t, seed.Email)
ib, _ = inboundSvc.GetInbound(ib.Id)
// Model a restored legacy row without going through save validation.
legacy := strings.Replace(ib.Settings, "10.8.1.0", "10.9.1.0", 1)
if legacy == ib.Settings {
t.Fatal("failed to change legacy subnet")
}
if err := database.GetDB().Model(ib).Updates(map[string]any{"settings": legacy, "enable": tc.inboundEnabled}).Error; err != nil {
t.Fatal(err)
}
update := seed
update.ExpiryTime = 4102444800000
update.AllowedIPsByInbound = map[int][]string{ib.Id: seed.AllowedIPs}
var err error
switch tc.action {
case "disable":
_, _, err = svc.SetClientEnableByEmail(inboundSvc, seed.Email, false)
case "enable":
_, _, err = svc.SetClientEnableByEmail(inboundSvc, seed.Email, true)
case "renew":
_, err = svc.ResetClientExpiryTimeByEmail(inboundSvc, seed.Email, update.ExpiryTime)
default:
if tc.action == "rename" {
update.Email = "renamed@awg"
}
if tc.action == "change" {
update.AllowedIPsByInbound[ib.Id] = []string{"10.10.1.2/32"}
}
if tc.action == "repair" {
update.AllowedIPsByInbound[ib.Id] = []string{"10.9.1.2/32"}
}
_, err = svc.Update(inboundSvc, rec.Id, update, 0)
}
if tc.wantReject {
if err == nil || !strings.Contains(err.Error(), "outside kernel interface subnets") {
t.Fatalf("error = %v, want subnet rejection", err)
}
} else if err != nil {
t.Fatalf("legacy recovery rejected: %v", err)
}
after, getErr := inboundSvc.GetInbound(ib.Id)
if getErr != nil {
t.Fatal(getErr)
}
if tc.wantReject && after.Settings != legacy {
t.Fatal("rejected update changed inbound settings")
}
if tc.action == "disable" && mustAWGClients(t, after)[0].Enable {
t.Fatal("client remained enabled")
}
if tc.action == "renew" && mustAWGClients(t, after)[0].ExpiryTime != update.ExpiryTime {
t.Fatal("client expiry was not renewed")
}
})
}
}
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")
}
})
}