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.
This commit is contained in:
Sanaei
2026-09-09 00:57:24 +02:00
parent efc603f59c
commit cfd596a489
2 changed files with 52 additions and 3 deletions
+6 -2
View File
@@ -205,13 +205,17 @@ func buildUAPIConfig(inst amneziawg.Instance, opts DeviceOptions) (string, error
writeOptionalLine(&b, "i4", o.I4)
writeOptionalLine(&b, "i5", o.I5)
// An omitted line means "unchanged" to amneziawg-go, so a cleared key can
// only reach a live device as the all-zero one that disables the feature.
hpHex := strings.Repeat("0", 64)
if opts.HeaderProtectionKey != "" {
hpHex, err := wireguard.KeyToHex(opts.HeaderProtectionKey)
var err error
hpHex, err = wireguard.KeyToHex(opts.HeaderProtectionKey)
if err != nil {
return "", fmt.Errorf("invalid header protection key: %w", err)
}
fmt.Fprintf(&b, "header_protection_key=%s\n", hpHex)
}
fmt.Fprintf(&b, "header_protection_key=%s\n", hpHex)
if opts.ContentPaddingAddition != "" {
fmt.Fprintf(&b, "content_padding_addition=%s\n", opts.ContentPaddingAddition)
}
+46 -1
View File
@@ -191,7 +191,12 @@ func TestBuildUAPIConfigHeaderProtectionAndContentPaddingLines(t *testing.T) {
if err != nil {
t.Fatalf("buildUAPIConfig with empty options: %v", err)
}
if strings.Contains(conf, "header_protection_key=") || strings.Contains(conf, "content_padding_addition=") {
// 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)
}
@@ -624,3 +629,43 @@ func TestValidatedObfuscationAlwaysApplies(t *testing.T) {
})
}
}
// 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)
}
}