Files
3x-ui/internal/web/service/inbound_amneziawg_test.go
T
Kirill Rudenko 8979072bd9 fix(amneziawg): bound S1-S3 by the receive buffer, reject overlapping H (#6642)
* fix(amneziawg): bound S1-S3 by the receive buffer, reject overlapping H

The native AmneziaWG validator, both Zod schemas, both forms and the docs now
follow the rules amneziawg-go actually enforces.

S1-S3. A padded handshake message is 148+S1, 92+S2 or 64+S3 bytes
(device/send.go). The peer reads each datagram into a [MaxMessageSize]byte
buffer, where MaxMessageSize = MaxSegmentSize (device/pools.go,
constants.go). MaxSegmentSize is 65535 on Linux/Android, 2016 on Windows and
1700 on iOS (device/queueconstants_*.go). The limits are therefore
S1 <= 1552, S2 <= 1608 and S3 <= 1636. Before, S1/S2 allowed 65535, which
iOS peers silently drop, and S3 was capped at 64, a number inherited from the
coinman-dev/3ax-ui port in #6105 with no stated reason. That cap blocked real
configs such as Amnezia Premium's S3=1045. RandomTrailers only tops a packet
up to 500 bytes (DefaultUdpWindow), so it never pushes a message past these
limits.

H1-H4. amneziawg-go refuses the whole device when the header ranges overlap
("headers must not overlap", device/uapi.go mergeWithDevice), and so does the
kernel module (src/netlink.c). The panel did not check this, so an inbound
with overlapping ranges saved and then failed to apply. A blank H is never
sent, so the engine keeps its default, WireGuard's own type 1-4; the check
treats blank fields that way. The docs said 1-4 "must not be used". They are
valid and are the engine default, only unobfuscated without a
HeaderProtectionKey. The docs also said amneziawg-go rejects S1+56 == S2. It
does not (IpcSet accepts it). The panel keeps that rule as a fingerprint
guard, and the docs now say so.

Tests: the new params_test cases and the Zod bounds fail on the old code.
TestValidatedObfuscationAlwaysApplies runs every accepted set through a real
amneziawg-go IpcSet and now covers overlap, blank-H defaults, H=1-4, the
exact S bounds and the full Amnezia Premium set. Before this fix it failed
with "headers must not overlap".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(amneziawg): bound only inbound padding by the iOS receive buffer

The 1700-byte iOS buffer limits what an inbound's clients can receive,
but ValidateObfuscation also runs for outbounds, and the Xray template
save re-validates every AmneziaWG outbound. An outbound whose remote
server uses S1 above 1552 would have blocked every Xray settings save,
though its values come from that server and are received on Linux.

ValidateObfuscation keeps amneziawg-go's uint16 UAPI width for S1-S3;
ValidateServerObfuscation adds the receive-buffer bounds and is what
inbounds call. The outbound schema and form follow the same split.

---------

Co-authored-by: Kirill Rudenko <rudenko@npp-energy.ru>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-26 22:02:49 +02:00

458 lines
19 KiB
Go

package service
import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/op/go-logging"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// A real X25519 pair, so PublicKeyFromPrivate agrees with the stored value.
var awgTestPrivateKey, awgTestPublicKey = func() (string, string) {
priv, pub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
panic(err)
}
return priv, pub
}()
func TestCheckForwardedPortsConflict_EmptySpecNoConflict(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB())
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
if hit := svc.checkForwardedPortsConflict(ctx, ""); hit != "" {
t.Fatalf("an empty spec must never conflict; got hit=%q", hit)
}
}
func TestCheckForwardedPortsConflict_CollidesWithPanelPort(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB())
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
// getString falls back to defaultValueMap's "webPort": "2053" on a fresh
// DB with no explicit setting row.
hit := svc.checkForwardedPortsConflict(ctx, "2053")
if !strings.Contains(hit, "panel") {
t.Fatalf("expected a collision naming the panel's own port, got %q", hit)
}
}
func TestCheckForwardedPortsConflict_CollidesWithEnabledInboundPort(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "vless-8080", "0.0.0.0", 8080, model.VLESS, `{"network":"tcp"}`, `{}`)
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB())
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
hit := svc.checkForwardedPortsConflict(ctx, "8075-8085")
if !strings.Contains(hit, "vless-8080") {
t.Fatalf("expected a collision naming the colliding inbound, got %q", hit)
}
}
func TestCheckForwardedPortsConflict_IgnoresDisabledInboundPort(t *testing.T) {
setupConflictDB(t)
disabled := &model.Inbound{Tag: "vless-8080-off", Enable: false, Listen: "0.0.0.0", Port: 8080, Protocol: model.VLESS, StreamSettings: `{"network":"tcp"}`}
if err := database.GetDB().Create(disabled).Error; err != nil {
t.Fatalf("seed disabled inbound: %v", err)
}
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB())
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
if hit := svc.checkForwardedPortsConflict(ctx, "8080"); hit != "" {
t.Fatalf("a disabled inbound's port must not be reserved; got hit=%q", hit)
}
}
func TestCheckForwardedPortsConflict_NoCollisionWhenPortsDontOverlap(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "vless-8080", "0.0.0.0", 8080, model.VLESS, `{"network":"tcp"}`, `{}`)
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB())
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
if hit := svc.checkForwardedPortsConflict(ctx, "9075-9085"); hit != "" {
t.Fatalf("unrelated ports must not conflict; got hit=%q", hit)
}
}
// A port-forward spec matching a port used only by an inbound hosted on a
// DIFFERENT node must not conflict: that inbound's DNAT/listen socket lives
// on the node's own host, never on this panel's, so there is nothing here
// for the forwarded port to actually collide with. Mirrors
// TestCheckPortConflict_NodeScope's own reasoning for the general port-
// conflict check.
func TestCheckForwardedPortsConflict_IgnoresPortOnDifferentNode(t *testing.T) {
setupConflictDB(t)
seedInboundConflictNode(t, "node1-8080", "0.0.0.0", 8080, model.VLESS, `{"network":"tcp"}`, `{}`, new(1))
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB())
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
if hit := svc.checkForwardedPortsConflict(ctx, "8080"); hit != "" {
t.Fatalf("a port used only on a different node must not conflict; got hit=%q", hit)
}
}
// inboundAmneziaWGServer is pure (no DB), so it needs neither setupConflictDB
// nor CGO/sqlite -- it can run in any Go environment.
func TestInboundAmneziaWGServer_RedactsPrivateKey(t *testing.T) {
settings := `{"server":{"privateKey":"super-secret","publicKey":"pub","mtu":1420,"headerProtectionKey":"MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18="},"clients":[]}`
got := inboundAmneziaWGServer(string(model.AmneziaWG), settings)
if got == nil {
t.Fatal("expected a non-nil server block")
}
if got.PrivateKey != "" {
t.Fatalf("PrivateKey must be redacted, got %q", got.PrivateKey)
}
if got.PublicKey != "pub" || got.MTU != 1420 {
t.Fatalf("non-secret fields must still come through unchanged, got %+v", got)
}
// Unlike the private key, the header-protection key is shared with every
// client config, so the clients page must receive it.
if got.HeaderProtectionKey != "MCPfRGcDGotJ6TcnIdDqsemj2cMIiGHnPUHM5ivXN18=" {
t.Fatalf("HeaderProtectionKey must NOT be redacted, got %q", got.HeaderProtectionKey)
}
}
func TestNormalizeAmneziaWGSettings_GeneratesFull31Set(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
inbound := &model.Inbound{Protocol: model.AmneziaWG, Port: 51820, Settings: ""}
if err := svc.normalizeAmneziaWGSettings(inbound, ""); err != nil {
t.Fatalf("normalize empty settings: %v", err)
}
var parsed amneziawg.InboundSettings
if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil {
t.Fatalf("normalized settings must carry a server block (err=%v): %s", err, inbound.Settings)
}
srv := parsed.Server
key, err := base64.StdEncoding.DecodeString(srv.HeaderProtectionKey)
if err != nil || len(key) != 32 {
t.Fatalf("headerProtectionKey = %q, must be base64 of 32 bytes (err=%v)", srv.HeaderProtectionKey, err)
}
for field, v := range map[string]string{
"contentPaddingAddition": srv.ContentPaddingAddition,
"rekeyAfterTime": srv.RekeyAfterTime,
"rekeyTimeout": srv.RekeyTimeout,
"rejectAfterTime": srv.RejectAfterTime,
"keepaliveTimeout": srv.KeepaliveTimeout,
"maxHandshakeAttempts": srv.MaxHandshakeAttempts,
"i1": srv.I1,
} {
if v == "" {
t.Errorf("fresh server block must fill %s", field)
}
}
if !srv.RandomTrailers || !srv.DisableCookies {
t.Errorf("fresh server block defaults RandomTrailers/DisableCookies on, got %v/%v", srv.RandomTrailers, srv.DisableCookies)
}
if srv.I2 != "" || srv.I3 != "" || srv.I4 != "" || srv.I5 != "" {
t.Errorf("generated sets must leave I2-I5 empty, got %q/%q/%q/%q", srv.I2, srv.I3, srv.I4, srv.I5)
}
}
func TestNormalizeAmneziaWGSettings_RejectsBad31Values(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
cases := []struct {
name string
snippet string
}{
{"bad headerProtectionKey", `"headerProtectionKey":"short"`},
{"zero rekeyTimeout", `"rekeyTimeout":"0"`},
{"S1 past what an iOS client can receive", `"s1":1553`},
{"rekey overlapping reject", `"rekeyAfterTime":"100-200","rejectAfterTime":"150-300"`},
{"control chars in i2", `"i2":"<r 64>\nPostUp = evil"`},
{"line-wrapped headerProtectionKey", `"headerProtectionKey":"MCPfRGcDGotJ6Tcn\r\nIdDqsemj2cMIiGHnPUHM5ivXN18="`},
}
for _, c := range cases {
inbound := &model.Inbound{
Protocol: model.AmneziaWG,
Port: 51820,
Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24,` + c.snippet + `},"clients":[]}`,
}
if err := svc.normalizeAmneziaWGSettings(inbound, ""); err == nil {
t.Errorf("%s must be rejected", c.name)
}
}
}
func TestNormalizeAmneziaWGSettings_CanonicalizesRangeValues(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
inbound := &model.Inbound{
Protocol: model.AmneziaWG,
Port: 51820,
Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24,` +
`"rekeyAfterTime":"110 - 140","rejectAfterTime":"190-250","keepaliveTimeout":" "},"clients":[]}`,
}
if err := svc.normalizeAmneziaWGSettings(inbound, ""); err != nil {
t.Fatalf("normalize: %v", err)
}
var parsed amneziawg.InboundSettings
if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil {
t.Fatalf("re-parse normalized settings (err=%v): %s", err, inbound.Settings)
}
if parsed.Server.RekeyAfterTime != "110-140" {
t.Errorf("rekeyAfterTime = %q, want canonical \"110-140\"", parsed.Server.RekeyAfterTime)
}
// A whitespace-only value must collapse to "feature off", not be stored
// as a value the server emitter renders into an invalid blank line.
if parsed.Server.KeepaliveTimeout != "" {
t.Errorf("keepaliveTimeout = %q, want collapsed to empty", parsed.Server.KeepaliveTimeout)
}
}
func TestInboundAmneziaWGServer_NonAmneziaWGReturnsNil(t *testing.T) {
if got := inboundAmneziaWGServer(string(model.VLESS), `{"server":{"privateKey":"x"}}`); got != nil {
t.Fatalf("a non-AmneziaWG protocol must return nil, got %+v", got)
}
}
func TestInboundAmneziaWGServer_MissingServerBlockReturnsNil(t *testing.T) {
if got := inboundAmneziaWGServer(string(model.AmneziaWG), `{"clients":[]}`); got != nil {
t.Fatalf("settings with no server block must return nil, got %+v", got)
}
}
// A newline inside a client's allowedIPs used to reach the rendered .conf,
// where a following "[Interface]\nPostUp = ..." runs as root the moment
// whoever applies that config (client app, or awg-quick directly) does so.
func TestNormalizeAmneziaWGSettings_RejectsInjectedClientAllowedIPs(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
inbound := &model.Inbound{
Protocol: model.AmneziaWG,
Port: 51820,
Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24},` +
`"clients":[{"email":"a@x","enable":true,"publicKey":"pk",` +
`"allowedIPs":["10.8.1.2/32\n[Interface]\nPostUp = touch /tmp/pwned"]}]}`,
}
err := svc.normalizeAmneziaWGSettings(inbound, "")
if err == nil {
t.Fatalf("an allowedIPs entry carrying a config-injection payload must be rejected; settings became:\n%s", inbound.Settings)
}
if !strings.Contains(err.Error(), "allowedIPs") {
t.Errorf("error should name the offending field, got %q", err)
}
}
func TestNormalizeAmneziaWGSettings_CanonicalizesClientAllowedIPs(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
inbound := &model.Inbound{
Protocol: model.AmneziaWG,
Port: 51820,
Settings: `{"server":{"privateKey":"x","publicKey":"y","subnetIp":"10.8.1.0","subnetCidr":24},` +
`"clients":[{"email":"a@x","enable":true,"publicKey":"pk","allowedIPs":[" 10.8.1.2 "]}]}`,
}
if err := svc.normalizeAmneziaWGSettings(inbound, ""); err != nil {
t.Fatalf("normalize: %v", err)
}
var parsed amneziawg.InboundSettings
if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil {
t.Fatalf("re-parse normalized settings: %v", err)
}
if len(parsed.Clients) != 1 || len(parsed.Clients[0].AllowedIPs) != 1 || parsed.Clients[0].AllowedIPs[0] != "10.8.1.2/32" {
t.Fatalf("allowedIPs = %v, want [\"10.8.1.2/32\"]", parsed.Clients)
}
}
func TestGetAmneziaWGLogs_ClampsCountAndFiltersEvents(t *testing.T) {
logger.InitLogger(logging.DEBUG)
logger.Info("amneziawg: started interface awg1 for inbound 1")
logger.Info("xray: unrelated line that must never show up here")
logger.Warning("amneziawgnet: reconcile failed for inbound 2: handshake timeout")
svc := &ServerService{}
logs := svc.GetAmneziaWGLogs("not-a-number", "")
if logs == nil {
t.Fatal("GetAmneziaWGLogs must never return nil")
}
for _, line := range logs.Events {
if !strings.Contains(strings.ToLower(line), "amneziawg") {
t.Fatalf("non-AmneziaWG line leaked into the event list: %q", line)
}
}
if len(logs.Events) < 2 {
t.Fatalf("both AmneziaWG lines should be present, got %v", logs.Events)
}
// count caps the event list, so an operator asking for 1 gets 1.
if one := svc.GetAmneziaWGLogs("1", ""); len(one.Events) != 1 {
t.Fatalf("count=1 must cap the event list, got %d", len(one.Events))
}
// filter narrows further, case-insensitively.
filtered := svc.GetAmneziaWGLogs("100", "RECONCILE")
if len(filtered.Events) != 1 || !strings.Contains(filtered.Events[0], "reconcile") {
t.Fatalf("filter must narrow to the matching line, got %v", filtered.Events)
}
}
func TestCheckForwardedPortsConflict_RejectsSpecOverCap(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB())
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
spec := fmt.Sprintf("20000-%d", 20000+amneziawg.MaxForwardedPorts)
hit := svc.checkForwardedPortsConflict(ctx, spec)
if !strings.Contains(hit, fmt.Sprintf("%d", amneziawg.MaxForwardedPorts)) {
t.Fatalf("expected a collision naming the %d-port cap, got %q", amneziawg.MaxForwardedPorts, hit)
}
}
// A spec covering exactly MaxForwardedPorts ports is AT the cap, not over
// it, and must be accepted -- ExpandForwardedPorts truncates there by
// design, so a naive len(...) >= cap comparison can't tell the two apart.
func TestCheckForwardedPortsConflict_AcceptsSpecExactlyAtCap(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB())
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
spec := fmt.Sprintf("20000-%d", 20000+amneziawg.MaxForwardedPorts-1)
if hit := svc.checkForwardedPortsConflict(ctx, spec); hit != "" {
t.Fatalf("a spec covering exactly %d ports must be accepted, got collision %q", amneziawg.MaxForwardedPorts, hit)
}
}
// The SOCKS5 relay port an enabled AmneziaWG inbound gets (SOCKSPortForInbound)
// is a phantom, non-DB-row port -- ctx.inbounds alone can't see it, so
// checkForwardedPortsConflict must check it explicitly.
func TestCheckForwardedPortsConflict_CollidesWithAmneziawgnetSocksPort(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{}`)
var awgInbound model.Inbound
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
t.Fatalf("read seeded row: %v", err)
}
relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
svc := &InboundService{}
ctx, err := svc.loadPortConflictContext(database.GetDB())
if err != nil {
t.Fatalf("loadPortConflictContext: %v", err)
}
hit := svc.checkForwardedPortsConflict(ctx, fmt.Sprintf("%d", relayPort))
if !strings.Contains(hit, "SOCKS5") {
t.Fatalf("expected a collision naming the AmneziaWG inbound's SOCKS5 relay port, got %q", hit)
}
}
// A cleared DNS field is meaningful (no DNS line in client configs) and must
// survive the save round-trip instead of resurrecting the frontend defaults.
func TestNormalizeAmneziaWGSettingsKeepsClearedDNS(t *testing.T) {
setupConflictDB(t)
server, err := defaultAmneziaWGServer()
if err != nil {
t.Fatalf("defaultAmneziaWGServer: %v", err)
}
server.PrimaryDNS = ""
server.SecondaryDNS = ""
bs, err := json.Marshal(amneziawg.InboundSettings{Server: server, Clients: []model.Client{}})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
inbound := &model.Inbound{Protocol: model.AmneziaWG, Settings: string(bs)}
if err := (&InboundService{}).normalizeAmneziaWGSettings(inbound, ""); err != nil {
t.Fatalf("normalizeAmneziaWGSettings: %v", err)
}
for _, key := range []string{`"primaryDns"`, `"secondaryDns"`} {
if !strings.Contains(inbound.Settings, key) {
t.Fatalf("cleared %s dropped from persisted settings:\n%s", key, inbound.Settings)
}
}
}
// An enabled peer with no address is skipped by InstanceFromInbound, and when it
// is the only one the entire inbound never starts, with nothing logged anywhere.
func TestNormalizeAmneziaWGSettings_RejectsEmptyClientAllowedIPs(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
inbound := &model.Inbound{Protocol: model.AmneziaWG, Port: 51823, Settings: `{
"server": {"privateKey":"` + awgTestPrivateKey + `","publicKey":"` + awgTestPublicKey + `","subnetIp":"10.8.1.0","subnetCidr":24},
"clients": [{"email":"ghost","enable":true,"publicKey":"` + awgTestPublicKey + `","allowedIPs":[]}]
}`}
err := svc.normalizeAmneziaWGSettings(inbound, "")
if err == nil || !strings.Contains(err.Error(), "allowedIPs is required") {
t.Fatalf("err = %v, want an allowedIPs refusal naming the client", err)
}
if !strings.Contains(fmt.Sprint(err), "ghost") {
t.Fatalf("error must name the offending client, got %v", err)
}
}
// Omitting the server keys on update means "unchanged": minting a fresh pair
// invalidates every client config already distributed, with no warning.
func TestNormalizeAmneziaWGSettings_KeepsStoredServerKeysWhenOmitted(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
stored := `{"server":{"privateKey":"` + awgTestPrivateKey + `","publicKey":"` + awgTestPublicKey + `","subnetIp":"10.8.1.0","subnetCidr":24}}`
inbound := &model.Inbound{Protocol: model.AmneziaWG, Port: 51824, Settings: `{"server":{"subnetIp":"10.8.1.0","subnetCidr":24,"randomTrailers":true}}`}
if err := svc.normalizeAmneziaWGSettings(inbound, stored); err != nil {
t.Fatalf("normalize: %v", err)
}
var parsed amneziawg.InboundSettings
if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil {
t.Fatalf("normalized settings must carry a server block (err=%v): %s", err, inbound.Settings)
}
if parsed.Server.PrivateKey != awgTestPrivateKey || parsed.Server.PublicKey != awgTestPublicKey {
t.Fatalf("server keypair was rotated by an unrelated edit: private=%q public=%q", parsed.Server.PrivateKey, parsed.Server.PublicKey)
}
}
// A payload carrying only the private half used to pass straight through, so
// every rendered client config got "PublicKey = " with nothing after it.
func TestNormalizeAmneziaWGSettings_DerivesServerPublicKeyFromPrivate(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
inbound := &model.Inbound{Protocol: model.AmneziaWG, Port: 51825, Settings: `{"server":{"privateKey":"` + awgTestPrivateKey + `","subnetIp":"10.8.1.0","subnetCidr":24}}`}
if err := svc.normalizeAmneziaWGSettings(inbound, ""); err != nil {
t.Fatalf("normalize: %v", err)
}
var parsed amneziawg.InboundSettings
if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil || parsed.Server == nil {
t.Fatalf("normalized settings must carry a server block (err=%v): %s", err, inbound.Settings)
}
want, err := wgutil.PublicKeyFromPrivate(awgTestPrivateKey)
if err != nil {
t.Fatalf("derive expected key: %v", err)
}
if parsed.Server.PublicKey != want {
t.Fatalf("server publicKey = %q, want %q derived from the supplied private key", parsed.Server.PublicKey, want)
}
}