Files
3x-ui/internal/amneziawgnet/device_test.go
T
Sanaei cfd596a489 fix(amneziawg): let a cleared header protection key reach a running device
amneziawg-go reads an absent UAPI line as "keep the current value", and
addressFingerprint keys only on the addresses and MTU, so an obfuscation-only
edit reconfigures in place rather than rebuilding. Clearing headerProtectionKey
therefore never took effect: the device kept protecting headers with the old
key. The stale key also keeps the S1-S4 minimum in force, so lowering S3/S4 in
the same edit made every later IpcSet fail with -22 — after replace_peers had
already dropped the peers.

Send the all-zero key when the field is empty, which is how the UAPI expresses
"disabled"; an empty value would be rejected, since it decodes to zero bytes.
2026-09-09 00:57:24 +02:00

672 lines
24 KiB
Go

package amneziawgnet
import (
"context"
"fmt"
"io"
"net"
"net/netip"
"strings"
"testing"
"time"
awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
"github.com/amnezia-vpn/amneziawg-go/v3/device"
"github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// TestNewDeviceHandshakeForwarderAndIdentity is Phase 1's real end-to-end
// proof, not just a compile check: a genuine amneziawg-go client (via that
// project's own tun/netstack.CreateNetTUN -- the client side doesn't need a
// forwarder or peer-identity resolution, only this package's server side
// does) completes a real 3-way handshake against a Device built by
// NewDevice, dials a destination that was never configured anywhere on the
// server, and the test verifies AttachTCPForwarder recovers that exact
// destination *and* PeerIndex.Lookup resolves the connection's source back
// to the right peer's Email -- Phase 1a/1b/1c working together, the same
// mechanism Phase 0's throwaway spike validated, now as a real, repo-owned,
// repeatable test instead of scratch code.
func TestNewDeviceHandshakeForwarderAndIdentity(t *testing.T) {
serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate server keypair: %v", err)
}
clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate client keypair: %v", err)
}
const listenPort = 58712 // fixed loopback test port, matches the validated Phase 0 spike approach
const wantEmail = "test-peer@example.com"
inst := amneziawg.Instance{
Id: 1,
InterfaceName: "awgtest1",
ListenPort: listenPort,
PrivateKey: serverPriv,
PublicKey: serverPub,
Address: []string{"10.201.0.1/24"},
MTU: 1420,
Obfuscation: amneziawg.Obfuscation31{
Jc: 4, Jmin: 40, Jmax: 70,
S1: 20, S2: 30, S3: 20, S4: 20,
},
Peers: []amneziawg.Peer{{
Email: wantEmail,
PublicKey: clientPub,
AllowedIPs: []string{"10.201.0.2/32"},
}},
}
dev, err := newUnconfiguredDevice(inst, DeviceOptions{})
if err != nil {
t.Fatalf("newUnconfiguredDevice: %v", err)
}
defer dev.Close()
idx := NewPeerIndex(inst.Peers)
type recovered struct {
email string
ok bool
dest netip.AddrPort
}
got := make(chan recovered, 1)
// Never configured anywhere server-side: the forwarder must recover it
// purely from the decapsulated packet, not from any routing table.
wantDest := netip.MustParseAddrPort("10.201.9.9:9999")
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
defer conn.Close()
srcAddrPort, parseErr := netip.ParseAddrPort(conn.RemoteAddr().String())
var peer amneziawg.Peer
var ok bool
if parseErr == nil {
peer, ok = idx.Lookup(srcAddrPort.Addr().Unmap())
}
got <- recovered{email: peer.Email, ok: ok, dest: dest}
io.Copy(io.Discard, conn)
})
// Configure (IpcSet) must come after AttachTCPForwarder -- see
// newUnconfiguredDevice's doc comment: IpcSet is what starts the peer's
// receive goroutine, which must never be able to run before the
// forwarder is registered on the stack.
if err := dev.Configure(inst, DeviceOptions{}); err != nil {
t.Fatalf("Configure: %v", err)
}
clientTun, clientNet, err := netstack.CreateNetTUN(
[]netip.Addr{netip.MustParseAddr("10.201.0.2")},
[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
if err != nil {
t.Fatalf("client CreateNetTUN: %v", err)
}
clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
defer clientDev.Close()
clientPrivHex, err := wireguard.KeyToHex(clientPriv)
if err != nil {
t.Fatalf("client key to hex: %v", err)
}
serverPubHex, err := wireguard.KeyToHex(serverPub)
if err != nil {
t.Fatalf("server key to hex: %v", err)
}
// allowed_ip=0.0.0.0/0 on the client matches a real VPN client's own
// config (route everything through the tunnel) -- it's also what makes
// dialing an arbitrary, never-configured destination like wantDest
// actually get routed to the server peer at all: a narrower AllowedIPs
// here would make the client's own Device drop the packet as
// non-matching before it ever reached the wire.
clientConf := fmt.Sprintf(
"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
clientPrivHex, serverPubHex, listenPort)
if err := clientDev.IpcSet(clientConf); err != nil {
t.Fatalf("client IpcSet: %v", err)
}
if err := clientDev.Up(); err != nil {
t.Fatalf("client Up: %v", err)
}
// Retry the dial rather than guessing a fixed handshake delay: the
// first attempts may race the handshake, later ones should succeed
// once it completes.
dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var lastErr error
for {
conn, dialErr := clientNet.DialContext(dialCtx, "tcp", wantDest.String())
if dialErr == nil {
conn.Close()
break
}
lastErr = dialErr
select {
case <-dialCtx.Done():
t.Fatalf("client dial never succeeded: %v", lastErr)
case <-time.After(100 * time.Millisecond):
}
}
select {
case r := <-got:
if !r.ok {
t.Fatal("forwarder: peer identity lookup failed for the recovered connection")
}
if r.email != wantEmail {
t.Errorf("resolved peer email = %q, want %q", r.email, wantEmail)
}
if r.dest != wantDest {
t.Errorf("recovered destination = %v, want %v", r.dest, wantDest)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for the forwarder to hand back the recovered connection")
}
}
// TestBuildUAPIConfigHeaderProtectionAndContentPaddingLines is a cheap,
// network-free companion to the real round-trip test below: confirms the 2
// AWG 3.0 UAPI lines only appear when set, and that a malformed
// HeaderProtectionKey surfaces a clear, wrapped error instead of silently
// producing a UAPI string amneziawg-go's own IpcSet would reject uselessly.
func TestBuildUAPIConfigHeaderProtectionAndContentPaddingLines(t *testing.T) {
priv, _, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate keypair: %v", err)
}
inst := amneziawg.Instance{
PrivateKey: priv,
Obfuscation: amneziawg.Obfuscation31{
S1: 20, S2: 20, S3: 20, S4: 20,
},
}
conf, err := buildUAPIConfig(inst, DeviceOptions{})
if err != nil {
t.Fatalf("buildUAPIConfig with empty options: %v", err)
}
// header_protection_key is the exception: an omitted line reads as
// "unchanged", so clearing the key has to be sent as the all-zero one.
if !strings.Contains(conf, "header_protection_key="+strings.Repeat("0", 64)+"\n") {
t.Fatalf("an unset key must be emitted as the all-zero key, got:\n%s", conf)
}
if strings.Contains(conf, "content_padding_addition=") {
t.Fatalf("empty DeviceOptions must not emit AWG 3.0 lines, got:\n%s", conf)
}
key, err := wireguard.GenerateWireguardPSK()
if err != nil {
t.Fatalf("generate header protection key: %v", err)
}
conf, err = buildUAPIConfig(inst, DeviceOptions{HeaderProtectionKey: key, ContentPaddingAddition: "20-40"})
if err != nil {
t.Fatalf("buildUAPIConfig with AWG 3.0 options: %v", err)
}
if !strings.Contains(conf, "header_protection_key=") {
t.Errorf("expected a header_protection_key= line, got:\n%s", conf)
}
if !strings.Contains(conf, "content_padding_addition=20-40\n") {
t.Errorf("expected a content_padding_addition=20-40 line, got:\n%s", conf)
}
if _, err := buildUAPIConfig(inst, DeviceOptions{HeaderProtectionKey: "not-a-valid-base64-key"}); err == nil {
t.Fatal("a malformed HeaderProtectionKey must be rejected, not silently passed through")
}
}
// TestNewDeviceHeaderProtectionAndContentPaddingRoundTrip is the real proof
// behind AmneziaWG 3.0's admin-facing HeaderProtectionKey/
// ContentPaddingAddition fields: a genuine amneziawg-go client, configured
// with matching header_protection_key/content_padding_addition UAPI lines
// (S1-S4 all >= 12, the hard requirement amneziawg-go's own IpcSet enforces
// for header protection), completes a real handshake against a Device built
// via NewDevice/DeviceOptions and exchanges real application data both
// directions through it. This is more than a handshake-completed check --
// it also confirms actual payload bytes survive content padding on both the
// send and receive sides, the specific area a third-party AmneziaWG
// installer project's docs flagged a past interop concern for (see the
// migration plan's own risk note); it is not a substitute for real-VPS
// verification against the official client, but it is the cheapest
// available local check against a regression in either engine's own padding
// handling.
func TestNewDeviceHeaderProtectionAndContentPaddingRoundTrip(t *testing.T) {
serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate server keypair: %v", err)
}
clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate client keypair: %v", err)
}
headerProtectionKey, err := wireguard.GenerateWireguardPSK()
if err != nil {
t.Fatalf("generate header protection key: %v", err)
}
const listenPort = 58713 // fixed loopback test port, distinct from the handshake test above
const contentPaddingAddition = "20-40"
inst := amneziawg.Instance{
Id: 2,
InterfaceName: "awgtest2",
ListenPort: listenPort,
PrivateKey: serverPriv,
PublicKey: serverPub,
Address: []string{"10.202.0.1/24"},
MTU: 1420,
Obfuscation: amneziawg.Obfuscation31{
Jc: 4, Jmin: 40, Jmax: 70,
S1: 20, S2: 30, S3: 20, S4: 20, // all >= 12, required for header protection
},
Peers: []amneziawg.Peer{{
Email: "hp-peer@example.com",
PublicKey: clientPub,
AllowedIPs: []string{"10.202.0.2/32"},
}},
}
opts := DeviceOptions{
HeaderProtectionKey: headerProtectionKey,
ContentPaddingAddition: contentPaddingAddition,
}
dev, err := newUnconfiguredDevice(inst, opts)
if err != nil {
t.Fatalf("newUnconfiguredDevice: %v", err)
}
defer dev.Close()
const wantRequest = "hello from client"
const wantReply = "hello from server"
serverDone := make(chan error, 1)
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
defer conn.Close()
buf := make([]byte, len(wantRequest))
if _, err := io.ReadFull(conn, buf); err != nil {
serverDone <- fmt.Errorf("server read: %w", err)
return
}
if string(buf) != wantRequest {
serverDone <- fmt.Errorf("server got %q, want %q", buf, wantRequest)
return
}
if _, err := conn.Write([]byte(wantReply)); err != nil {
serverDone <- fmt.Errorf("server write: %w", err)
return
}
serverDone <- nil
})
// Configure (IpcSet) must come after AttachTCPForwarder -- see
// newUnconfiguredDevice's doc comment.
if err := dev.Configure(inst, opts); err != nil {
t.Fatalf("Configure: %v", err)
}
clientTun, clientNet, err := netstack.CreateNetTUN(
[]netip.Addr{netip.MustParseAddr("10.202.0.2")},
[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
if err != nil {
t.Fatalf("client CreateNetTUN: %v", err)
}
clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
defer clientDev.Close()
clientPrivHex, err := wireguard.KeyToHex(clientPriv)
if err != nil {
t.Fatalf("client key to hex: %v", err)
}
serverPubHex, err := wireguard.KeyToHex(serverPub)
if err != nil {
t.Fatalf("server key to hex: %v", err)
}
headerProtectionKeyHex, err := wireguard.KeyToHex(headerProtectionKey)
if err != nil {
t.Fatalf("header protection key to hex: %v", err)
}
clientConf := fmt.Sprintf(
"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\nheader_protection_key=%s\ncontent_padding_addition=%s\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
clientPrivHex, headerProtectionKeyHex, contentPaddingAddition, serverPubHex, listenPort)
if err := clientDev.IpcSet(clientConf); err != nil {
t.Fatalf("client IpcSet: %v", err)
}
if err := clientDev.Up(); err != nil {
t.Fatalf("client Up: %v", err)
}
dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var conn net.Conn
for {
c, dialErr := clientNet.DialContext(dialCtx, "tcp", "10.202.9.9:9999")
if dialErr == nil {
conn = c
break
}
select {
case <-dialCtx.Done():
t.Fatalf("client dial never succeeded: %v", dialErr)
case <-time.After(100 * time.Millisecond):
}
}
defer conn.Close()
if _, err := conn.Write([]byte(wantRequest)); err != nil {
t.Fatalf("client write: %v", err)
}
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("SetReadDeadline: %v", err)
}
reply := make([]byte, len(wantReply))
if _, err := io.ReadFull(conn, reply); err != nil {
t.Fatalf("client read reply: %v", err)
}
if string(reply) != wantReply {
t.Fatalf("client got reply %q, want %q", reply, wantReply)
}
select {
case err := <-serverDone:
if err != nil {
t.Fatalf("server side: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for the server side to finish")
}
}
func TestBuildUAPIConfigRandomTrailersAndDisableCookiesLines(t *testing.T) {
priv, _, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate keypair: %v", err)
}
inst := amneziawg.Instance{PrivateKey: priv}
// Unlike HeaderProtectionKey/ContentPaddingAddition, these two lines
// must always be present -- see DeviceOptions.RandomTrailers's own doc
// comment on why an absent line (instead of an explicit "false") would
// break the reconfigure-in-place diff for a true->false edit.
conf, err := buildUAPIConfig(inst, DeviceOptions{})
if err != nil {
t.Fatalf("buildUAPIConfig with empty options: %v", err)
}
if !strings.Contains(conf, "random_trailers=false\n") {
t.Errorf("expected an explicit random_trailers=false line even when unset, got:\n%s", conf)
}
if !strings.Contains(conf, "disable_cookies=false\n") {
t.Errorf("expected an explicit disable_cookies=false line even when unset, got:\n%s", conf)
}
conf, err = buildUAPIConfig(inst, DeviceOptions{RandomTrailers: true, DisableCookies: true})
if err != nil {
t.Fatalf("buildUAPIConfig with both enabled: %v", err)
}
if !strings.Contains(conf, "random_trailers=true\n") {
t.Errorf("expected a random_trailers=true line, got:\n%s", conf)
}
if !strings.Contains(conf, "disable_cookies=true\n") {
t.Errorf("expected a disable_cookies=true line, got:\n%s", conf)
}
}
// TestNewDeviceRandomTrailersAndDisableCookiesRoundTrip is the real proof
// behind AmneziaWG 3.1's two new device-wide toggles: a genuine amneziawg-go
// client with matching random_trailers=true/disable_cookies=true UAPI lines
// completes a real handshake against a Device built via NewDevice/
// DeviceOptions and exchanges real application data both directions through
// it. This specifically exercises amneziawg-go's receive.go size-matching
// path for RandomTrailers (device_test.go's HeaderProtection test doesn't
// enable it), which only accepts a message when
// `size == expectedSize || randomTrailers && size > expectedSize` -- proof
// that setting it on both ends really does interoperate, not just that
// IpcSet accepts the value.
func TestNewDeviceRandomTrailersAndDisableCookiesRoundTrip(t *testing.T) {
serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate server keypair: %v", err)
}
clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate client keypair: %v", err)
}
const listenPort = 58721 // fixed loopback test port, distinct from every other test in this package
inst := amneziawg.Instance{
Id: 3,
InterfaceName: "awgtest3",
ListenPort: listenPort,
PrivateKey: serverPriv,
PublicKey: serverPub,
Address: []string{"10.203.0.1/24"},
MTU: 1420,
Peers: []amneziawg.Peer{{
Email: "trailer-peer@example.com",
PublicKey: clientPub,
AllowedIPs: []string{"10.203.0.2/32"},
}},
}
opts := DeviceOptions{RandomTrailers: true, DisableCookies: true}
dev, err := newUnconfiguredDevice(inst, opts)
if err != nil {
t.Fatalf("newUnconfiguredDevice: %v", err)
}
defer dev.Close()
const wantRequest = "hello from client, with a trailer"
const wantReply = "hello from server, with a trailer"
serverDone := make(chan error, 1)
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
defer conn.Close()
buf := make([]byte, len(wantRequest))
if _, err := io.ReadFull(conn, buf); err != nil {
serverDone <- fmt.Errorf("server read: %w", err)
return
}
if string(buf) != wantRequest {
serverDone <- fmt.Errorf("server got %q, want %q", buf, wantRequest)
return
}
if _, err := conn.Write([]byte(wantReply)); err != nil {
serverDone <- fmt.Errorf("server write: %w", err)
return
}
serverDone <- nil
})
// Configure (IpcSet) must come after AttachTCPForwarder -- see
// newUnconfiguredDevice's doc comment.
if err := dev.Configure(inst, opts); err != nil {
t.Fatalf("Configure: %v", err)
}
clientTun, clientNet, err := netstack.CreateNetTUN(
[]netip.Addr{netip.MustParseAddr("10.203.0.2")},
[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
if err != nil {
t.Fatalf("client CreateNetTUN: %v", err)
}
clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
defer clientDev.Close()
clientPrivHex, err := wireguard.KeyToHex(clientPriv)
if err != nil {
t.Fatalf("client key to hex: %v", err)
}
serverPubHex, err := wireguard.KeyToHex(serverPub)
if err != nil {
t.Fatalf("server key to hex: %v", err)
}
clientConf := fmt.Sprintf(
"private_key=%s\nrandom_trailers=true\ndisable_cookies=true\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
clientPrivHex, serverPubHex, listenPort)
if err := clientDev.IpcSet(clientConf); err != nil {
t.Fatalf("client IpcSet: %v", err)
}
if err := clientDev.Up(); err != nil {
t.Fatalf("client Up: %v", err)
}
dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var conn net.Conn
for {
c, dialErr := clientNet.DialContext(dialCtx, "tcp", "10.203.9.9:9999")
if dialErr == nil {
conn = c
break
}
select {
case <-dialCtx.Done():
t.Fatalf("client dial never succeeded: %v", dialErr)
case <-time.After(100 * time.Millisecond):
}
}
defer conn.Close()
if _, err := conn.Write([]byte(wantRequest)); err != nil {
t.Fatalf("client write: %v", err)
}
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("SetReadDeadline: %v", err)
}
reply := make([]byte, len(wantReply))
if _, err := io.ReadFull(conn, reply); err != nil {
t.Fatalf("client read reply: %v", err)
}
if string(reply) != wantReply {
t.Fatalf("client got reply %q, want %q", reply, wantReply)
}
select {
case err := <-serverDone:
if err != nil {
t.Fatalf("server side: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for the server side to finish")
}
}
// TestValidatedObfuscationAlwaysApplies pins the contract ValidateObfuscation
// exists for: whatever it accepts, amneziawg-go's own IpcSet must accept too.
func TestValidatedObfuscationAlwaysApplies(t *testing.T) {
priv, pub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("server keypair: %v", err)
}
_, peerPub, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("peer keypair: %v", err)
}
base := amneziawg.Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
cases := []struct {
name string
mut func(*amneziawg.Obfuscation31)
}{
{"generated defaults", func(o *amneziawg.Obfuscation31) { *o = amneziawg.GenerateObfuscation31() }},
{"S1 over uint16", func(o *amneziawg.Obfuscation31) { o.S1 = 70000 }},
{"S2 over uint16", func(o *amneziawg.Obfuscation31) { o.S2 = 70000 }},
{"negative Jc", func(o *amneziawg.Obfuscation31) { o.Jc = -1 }},
{"negative Jmin and Jmax", func(o *amneziawg.Obfuscation31) { o.Jmin, o.Jmax = -5, -1 }},
{"Jc over uint32", func(o *amneziawg.Obfuscation31) { o.Jc = 5000000000 }},
{"I1 unknown tag", func(o *amneziawg.Obfuscation31) { o.I1 = "<rand 100>" }},
{"I1 missing close", func(o *amneziawg.Obfuscation31) { o.I1 = "<r 100" }},
{"I1 empty tag", func(o *amneziawg.Obfuscation31) { o.I1 = "<>" }},
// The specs validateObfChain deliberately accepts must really apply.
{"I1 chained tags", func(o *amneziawg.Obfuscation31) { o.I1 = "<b ff00><r 10>" }},
{"I1 valueless tag", func(o *amneziawg.Obfuscation31) { o.I1 = "<t><rc 5>" }},
{"I1 no tags at all", func(o *amneziawg.Obfuscation31) { o.I1 = "plain text" }},
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
o := base
tc.mut(&o)
if err := amneziawg.ValidateObfuscation(o); err != nil {
return // rejected before saving, which is the whole point
}
inst := amneziawg.Instance{
Id: 88, InterfaceName: "awgcontract", ListenPort: 58900 + i,
PrivateKey: priv, PublicKey: pub,
Address: []string{"10.198.0.1/24"}, MTU: 1420,
Obfuscation: o,
Peers: []amneziawg.Peer{{
Email: "contract@example.com", PublicKey: peerPub,
AllowedIPs: []string{"10.198.0.2/32"},
}},
}
opts := DeviceOptions{
HeaderProtectionKey: o.HeaderProtectionKey,
ContentPaddingAddition: o.ContentPaddingAddition,
RekeyAfterTime: o.RekeyAfterTime,
RekeyTimeout: o.RekeyTimeout,
RejectAfterTime: o.RejectAfterTime,
KeepaliveTimeout: o.KeepaliveTimeout,
MaxHandshakeAttempts: o.MaxHandshakeAttempts,
RandomTrailers: o.RandomTrailers,
DisableCookies: o.DisableCookies,
}
dev, err := newUnconfiguredDevice(inst, opts)
if err != nil {
t.Fatalf("newUnconfiguredDevice: %v", err)
}
defer dev.Close()
conf, err := buildUAPIConfig(inst, opts)
if err != nil {
t.Fatalf("buildUAPIConfig: %v", err)
}
if err := dev.IpcSet(conf); err != nil {
t.Fatalf("ValidateObfuscation accepted this config but amneziawg-go rejected it: %v", err)
}
})
}
}
// Clearing HeaderProtectionKey on a running inbound must actually reach the
// device: amneziawg-go treats an absent UAPI line as "keep the current value",
// so an omitted key leaves header protection permanently on. Worse, the stale
// key keeps the S1-S4 minimum alive, so lowering them then fails IpcSet with
// -22 on every reconcile after the peers were already replaced.
func TestBuildUAPIConfigClearedHeaderProtectionKeyIsSentAsZero(t *testing.T) {
priv, _, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate keypair: %v", err)
}
inst := amneziawg.Instance{
PrivateKey: priv,
Obfuscation: amneziawg.Obfuscation31{S1: 20, S2: 20, S3: 20, S4: 20},
}
key, err := wireguard.GenerateWireguardPSK()
if err != nil {
t.Fatalf("generate header protection key: %v", err)
}
withKey, err := buildUAPIConfig(inst, DeviceOptions{HeaderProtectionKey: key})
if err != nil {
t.Fatalf("buildUAPIConfig with a key: %v", err)
}
cleared, err := buildUAPIConfig(inst, DeviceOptions{})
if err != nil {
t.Fatalf("buildUAPIConfig with the key cleared: %v", err)
}
if withKey == cleared {
t.Fatal("clearing the key produced an identical UAPI config, so the device would never see the change")
}
zero := "header_protection_key=" + strings.Repeat("0", 64) + "\n"
if !strings.Contains(cleared, zero) {
t.Fatalf("cleared config must carry the all-zero key, got:\n%s", cleared)
}
if strings.Contains(withKey, zero) {
t.Fatalf("a configured key must not be emitted as zero, got:\n%s", withKey)
}
}