[GH-ISSUE #186] Handshake every 15 seconds instead 2+ minutes if s4 used #110

Closed
opened 2026-10-13 21:06:37 +00:00 by bsakaguchi · 7 comments
Owner

Originally created by @Ratio2 on GitHub (Jul 15, 2026).
Original GitHub issue: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/issues/186

Originally assigned to: @ygurov on GitHub.

server:

[Interface]
ListenPort = 5002
PrivateKey = IDySooxk0yAJVeMeFtWWj4/sGoiCG3OGRpeiEeSFTXE=
S4 = 17

[Peer]
PublicKey = nF6dwIJvOnSGnW70i+T+15TQwsgpwvXun5TTUzduKFE=
PresharedKey = ei14Lphncy8wwmhfZ9wYHSLwt9/7xfDVtKdMp8oM+Wg=
AllowedIPs = 10.0.0.2/32, fd00::2/128

client:

[Interface]
Address = 10.0.0.2/32
Address = fd00::2/128
MTU = 1340
Table = off
PrivateKey = 0N6NxvjYia4n7s4w32KHEftikL0WAAKuIWjpqkl7BUg=
S4 = 17

[Peer]
PublicKey = PgujS8OSfaC/wWSV4K5YD7O/gQLLnldLkujLajMUf3M=
PresharedKey = ei14Lphncy8wwmhfZ9wYHSLwt9/7xfDVtKdMp8oM+Wg=
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = 172.31.0.4:5002
PersistentKeepalive = 25
Originally created by @Ratio2 on GitHub (Jul 15, 2026). Original GitHub issue: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/issues/186 Originally assigned to: @ygurov on GitHub. server: ``` [Interface] ListenPort = 5002 PrivateKey = IDySooxk0yAJVeMeFtWWj4/sGoiCG3OGRpeiEeSFTXE= S4 = 17 [Peer] PublicKey = nF6dwIJvOnSGnW70i+T+15TQwsgpwvXun5TTUzduKFE= PresharedKey = ei14Lphncy8wwmhfZ9wYHSLwt9/7xfDVtKdMp8oM+Wg= AllowedIPs = 10.0.0.2/32, fd00::2/128 ``` client: ``` [Interface] Address = 10.0.0.2/32 Address = fd00::2/128 MTU = 1340 Table = off PrivateKey = 0N6NxvjYia4n7s4w32KHEftikL0WAAKuIWjpqkl7BUg= S4 = 17 [Peer] PublicKey = PgujS8OSfaC/wWSV4K5YD7O/gQLLnldLkujLajMUf3M= PresharedKey = ei14Lphncy8wwmhfZ9wYHSLwt9/7xfDVtKdMp8oM+Wg= AllowedIPs = 0.0.0.0/0, ::/0 Endpoint = 172.31.0.4:5002 PersistentKeepalive = 25 ```
Author
Owner

@Ratio2 commented on GitHub (Jul 15, 2026):

Please check keepalive packet detection github.com/amnezia-vpn/amneziawg-linux-kernel-module@2a6e1a02ac/src/send.c (L302)

<!-- gh-comment-id:4985290223 --> @Ratio2 commented on GitHub (Jul 15, 2026): Please check keepalive packet detection https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/blob/2a6e1a02ac024f54a23e18f894a279b7f870b8fb/src/send.c#L302
Author
Owner

@bivlked commented on GitHub (Aug 3, 2026):

I had a go at reproducing this, and @Ratio2 looks right about the keepalive check in send.c. All of the below is against c78a89e (v3.0.20260731-04), which is what the PPA ships right now.

Reproduction

Ubuntu 24.04.4, kernel 6.8.0-87-generic, with amneziawg-dkms 1.0.0-0~202608010147+c78a89e~ubuntu24.04.1 and amneziawg-tools v3.0.20260730 from ppa:amnezia/ppa.

Server in the root netns, client in a second one, veth pair between them. I configured the interfaces with plain awg setconf so nothing touches routes, DNS or resolvconf, and turned IPv6 off on each of them. Bring the tunnel up, send two pings through it, then leave it completely alone and poll awg show <if> latest-handshakes once a second.

# server
[Interface]
PrivateKey = <server key>
ListenPort = 51001
S4 = 17
[Peer]
PublicKey = <client pubkey>
AllowedIPs = 10.1.0.2/32

# client
[Interface]
PrivateKey = <client key>
S4 = 17
[Peer]
PublicKey = <server pubkey>
AllowedIPs = 10.1.0.1/32
Endpoint = 10.99.0.1:51001
PersistentKeepalive = 33

The control tunnel is the same pair of files with the two S4 lines removed, on its own port and subnet.

Over 1499 s of idle time the S4 = 17 tunnel rehandshakes at a median of 15.0 s (n=81, max 156). The control sits at 147.0 s (n=10, min 114, max 164). That was the only difference between the two configs.

I tried two variations and both landed in the same place. Dropping PersistentKeepalive altogether still gives 15.0 s (n=33), so the cycle does not need it. A realistic full set rather than S4 on its own (Jc = 4, Jmin = 40, Jmax = 70, S1 = 100, S2 = 120, S3 = 30, S4 = 17, distinct H1-H4) also gives 15.0 s (n=14), so none of the other parameters change anything either.

The log is fairly blunt about it, on a tunnel carrying no traffic at all:

awgc1: Sending keepalive packet to peer 2
awgs1: Receiving keepalive packet from peer 1
awgc1: Retrying handshake with peer 2 because we stopped hearing back after 15 seconds
awgc1: Sending handshake initiation to peer 2
awgs1: Sending handshake response to peer 1
awgc1: Receiving handshake response from peer 2
awgc1: Sending keepalive packet to peer 2

The peer receives the keepalive. Nothing is lost on the wire. The sender just does not count its own packet as one.

What is happening

src/send.c:255 picks up S4 as int padding = wg->transport_padding; and src/send.c:300 prepends it inside encrypt_packet(), so it is part of skb->len from then on. src/send.c:346 in wg_packet_create_data_done() then classifies the packet with is_keepalive = skb->len == message_data_len(0);, and with S4 > 0 that comparison can never match. data_sent goes true, wg_timers_data_sent() (src/timers.c:154) arms timer_new_handshake at KEEPALIVE_TIMEOUT + REKEY_TIMEOUT plus up to HZ/3 of jitter, with REKEY_TIMEOUT = 5 (src/messages.h:43) and KEEPALIVE_TIMEOUT = 10 (:49).

The occasional long interval in my numbers (max 156 s) fits this too. wg_timers_any_authenticated_packet_received() cancels that timer, so there is a race between the cancellation in the handshake receive path and the arming that happens once the queued keepalive finishes transmitting asynchronously.

Confirming it with a one-line change

Correlation with S4 does not on its own show that the misclassified keepalive is what arms the timer, so I rebuilt the module from the same DKMS sources with one line changed:

-  is_keepalive = skb->len == message_data_len(0);
+  is_keepalive = skb->len == message_data_len(0) + peer->device->transport_padding;

Same config files each time, same two pings to start, then idle:

Phase Module Window S4 = 17 "Retrying handshake" Control
A stock 1499 s 15.0 s present 147.0 s
B stock plus that line 731 s 147.0 s none 147.0 s
A' stock again 329 s 15.0 s present 135.5 s

With that line in, the S4 tunnel becomes indistinguishable from the control and there are no Retrying handshake lines at all. Keepalives still go out in that window (19 of them) and traffic still passes, so nothing gets switched off by it, the classification is just correct. I am not putting it forward as the fix.

ContentPaddingAddition hits the same check

src/send.c:263-267 adds padding_len to the plaintext before encryption, and that also lands in skb->len before line 346, so an empty keepalive gets padded as well. The symptom I saw was different: no handshake storm, but the padded keepalives get dropped. AEAD verification passes, then src/receive.c:438-439 looks for a keepalive by zero decrypted length, does not find one, and the packet is rejected at :455 as dishonest_packet_type and logged as Packet is neither ipv4 nor ipv6 on both ends.

My guess at why there is no storm here is that the receiver, having taken the padded keepalive for data, starts sending keepalives back, and those cancel the sender's timer before it fires. It was the only server of six in that run sending any. I did not trace the timer being armed and then cancelled, so that part is guesswork. Happy to raise it separately if you would rather keep the two apart.


The same length comparison exists in amneziawg-go: device/send.go:577 seals into a buffer that already includes elem.padding, and device/send.go:631 compares len(elem.packet) against MessageKeepaliveSize. I have only read that one, not measured it.

On the fix itself I would rather leave the shape to you, since it depends on how you want queue elements to carry state. The keepalive is created explicitly as an empty skb in src/send.c:317-329, and that intent then gets reconstructed by comparing a length after several optional paddings have been applied. Would carrying an explicit marker through the TX queue be a reasonable direction for the S4 case? The receive side is a different problem, since a local marker cannot travel to the peer.

One caveat on the numbers. Both ends ran on the same host, so they share a scheduler and workqueue, and intervals between two separate machines may well differ. The mechanism should not care, but the timings might.

I still have the logs and the scripted setup, so if you put a fix together I can run the same measurement against it.

<!-- gh-comment-id:5171830781 --> @bivlked commented on GitHub (Aug 3, 2026): I had a go at reproducing this, and @Ratio2 looks right about the keepalive check in `send.c`. All of the below is against `c78a89e` (`v3.0.20260731-04`), which is what the PPA ships right now. ## Reproduction Ubuntu 24.04.4, kernel `6.8.0-87-generic`, with `amneziawg-dkms 1.0.0-0~202608010147+c78a89e~ubuntu24.04.1` and `amneziawg-tools v3.0.20260730` from `ppa:amnezia/ppa`. Server in the root netns, client in a second one, veth pair between them. I configured the interfaces with plain `awg setconf` so nothing touches routes, DNS or resolvconf, and turned IPv6 off on each of them. Bring the tunnel up, send two pings through it, then leave it completely alone and poll `awg show <if> latest-handshakes` once a second. ``` # server [Interface] PrivateKey = <server key> ListenPort = 51001 S4 = 17 [Peer] PublicKey = <client pubkey> AllowedIPs = 10.1.0.2/32 # client [Interface] PrivateKey = <client key> S4 = 17 [Peer] PublicKey = <server pubkey> AllowedIPs = 10.1.0.1/32 Endpoint = 10.99.0.1:51001 PersistentKeepalive = 33 ``` The control tunnel is the same pair of files with the two `S4` lines removed, on its own port and subnet. Over 1499 s of idle time the `S4 = 17` tunnel rehandshakes at a median of 15.0 s (n=81, max 156). The control sits at 147.0 s (n=10, min 114, max 164). That was the only difference between the two configs. I tried two variations and both landed in the same place. Dropping `PersistentKeepalive` altogether still gives 15.0 s (n=33), so the cycle does not need it. A realistic full set rather than `S4` on its own (`Jc = 4`, `Jmin = 40`, `Jmax = 70`, `S1 = 100`, `S2 = 120`, `S3 = 30`, `S4 = 17`, distinct `H1`-`H4`) also gives 15.0 s (n=14), so none of the other parameters change anything either. The log is fairly blunt about it, on a tunnel carrying no traffic at all: ``` awgc1: Sending keepalive packet to peer 2 awgs1: Receiving keepalive packet from peer 1 awgc1: Retrying handshake with peer 2 because we stopped hearing back after 15 seconds awgc1: Sending handshake initiation to peer 2 awgs1: Sending handshake response to peer 1 awgc1: Receiving handshake response from peer 2 awgc1: Sending keepalive packet to peer 2 ``` The peer receives the keepalive. Nothing is lost on the wire. The sender just does not count its own packet as one. ## What is happening `src/send.c:255` picks up `S4` as `int padding = wg->transport_padding;` and `src/send.c:300` prepends it inside `encrypt_packet()`, so it is part of `skb->len` from then on. `src/send.c:346` in `wg_packet_create_data_done()` then classifies the packet with `is_keepalive = skb->len == message_data_len(0);`, and with `S4 > 0` that comparison can never match. `data_sent` goes true, `wg_timers_data_sent()` (`src/timers.c:154`) arms `timer_new_handshake` at `KEEPALIVE_TIMEOUT + REKEY_TIMEOUT` plus up to `HZ/3` of jitter, with `REKEY_TIMEOUT = 5` (`src/messages.h:43`) and `KEEPALIVE_TIMEOUT = 10` (`:49`). The occasional long interval in my numbers (max 156 s) fits this too. `wg_timers_any_authenticated_packet_received()` cancels that timer, so there is a race between the cancellation in the handshake receive path and the arming that happens once the queued keepalive finishes transmitting asynchronously. ## Confirming it with a one-line change Correlation with `S4` does not on its own show that the misclassified keepalive is what arms the timer, so I rebuilt the module from the same DKMS sources with one line changed: ```diff - is_keepalive = skb->len == message_data_len(0); + is_keepalive = skb->len == message_data_len(0) + peer->device->transport_padding; ``` Same config files each time, same two pings to start, then idle: | Phase | Module | Window | `S4 = 17` | "Retrying handshake" | Control | |---|---|--:|--:|--:|--:| | A | stock | 1499 s | 15.0 s | present | 147.0 s | | B | stock plus that line | 731 s | **147.0 s** | **none** | 147.0 s | | A' | stock again | 329 s | 15.0 s | present | 135.5 s | With that line in, the `S4` tunnel becomes indistinguishable from the control and there are no `Retrying handshake` lines at all. Keepalives still go out in that window (19 of them) and traffic still passes, so nothing gets switched off by it, the classification is just correct. I am not putting it forward as the fix. ## ContentPaddingAddition hits the same check `src/send.c:263-267` adds `padding_len` to the plaintext before encryption, and that also lands in `skb->len` before line 346, so an empty keepalive gets padded as well. The symptom I saw was different: no handshake storm, but the padded keepalives get dropped. AEAD verification passes, then `src/receive.c:438-439` looks for a keepalive by zero decrypted length, does not find one, and the packet is rejected at `:455` as `dishonest_packet_type` and logged as `Packet is neither ipv4 nor ipv6` on both ends. My guess at why there is no storm here is that the receiver, having taken the padded keepalive for data, starts sending keepalives back, and those cancel the sender's timer before it fires. It was the only server of six in that run sending any. I did not trace the timer being armed and then cancelled, so that part is guesswork. Happy to raise it separately if you would rather keep the two apart. --- The same length comparison exists in amneziawg-go: `device/send.go:577` seals into a buffer that already includes `elem.padding`, and `device/send.go:631` compares `len(elem.packet)` against `MessageKeepaliveSize`. I have only read that one, not measured it. On the fix itself I would rather leave the shape to you, since it depends on how you want queue elements to carry state. The keepalive is created explicitly as an empty skb in `src/send.c:317-329`, and that intent then gets reconstructed by comparing a length after several optional paddings have been applied. Would carrying an explicit marker through the TX queue be a reasonable direction for the `S4` case? The receive side is a different problem, since a local marker cannot travel to the peer. One caveat on the numbers. Both ends ran on the same host, so they share a scheduler and workqueue, and intervals between two separate machines may well differ. The mechanism should not care, but the timings might. I still have the logs and the scripted setup, so if you put a fix together I can run the same measurement against it.
Author
Owner

@bivlked commented on GitHub (Aug 4, 2026):

Short version: with S4 set, an idle tunnel on amneziawg-go v3.0.3 redoes the
handshake every 15 seconds instead of every 132, so close to nine times more
often. v0.2.19 from the 2.0 line does not, and neither does v3.0.3 with S4
unset. The kernel module in the same run sat at 15 s, matching the numbers I
posted earlier.

This follows up the last paragraph of that comment, where I said the same length
comparison exists in amneziawg-go but that I had only read it. I had only read the
3.0 code, and assumed it spoke for the whole go side. It does not.

How I measured it

Same host and module as before: Ubuntu 24.04.4, kernel 6.8.0-137-generic,
amneziawg-dkms 1.0.0-0~202608010147+c78a89e~ubuntu24.04.1 and
amneziawg-tools v3.0.20260730 from the PPA, module reporting 3.0.20260731-04.

Four pairs, each with its own keys, port, subnet and interfaces, both ends
configured with plain awg setconf. The server side is the kernel module every
time, so go is always the client. Obfuscation parameters are the same in all four
and were copied out of a config generated by my installer
(bivlked/amneziawg-installer):
Jc = 6, Jmin = 75, Jmax = 174, S1 = 119, S2 = 75, S3 = 46, S4 = 23,
distinct H1-H4 ranges, I1. Only two things vary across the four: which go
build is on the client, and whether S4 is there at all.

Separate pairs rather than extra peers on one interface, because S1-S4 and
H1-H4 are device parameters and not per-peer ones. A control with S4 absent
cannot sit on an interface that has S4 set.

Bring each tunnel up, then leave it alone and poll awg show <if> latest-handshakes
once a second for 40 minutes. Nothing sends traffic through the tunnels, and the
client namespace has no route into any of them, so nothing goes through by
accident. The timestamps come from the handshake counter at one second resolution,
so the intervals are whole seconds.

Results

Client S4 intervals median spread
amneziawg-go v0.2.19 23 19 132.0 s 18 of 19 above 130 s
amneziawg-go v3.0.3 23 168 15.0 s 168 of 168 below 20 s
kernel module 23 156 15.0 s 156 of 156 below 20 s
amneziawg-go v3.0.3 unset 21 132.0 s 18 of 21 above 130 s

Each row is one tunnel, so the interval count says how steady that tunnel was, not
how many tunnels agreed.

The kernel row is there as a positive control. Same run, same host, same minutes,
and it lands on the 15.0 s I reported before. Without it a clean result on
v0.2.19 would prove nothing, since it could just as easily mean my setup was
incapable of showing the problem at all.

The 132 s is not an arbitrary number. RekeyAfterTime is 120 s and
PersistentKeepalive is 33 s, so keepalives land on 33, 66, 99, 132, and the
first one past 120 s is what triggers the rekey. The occasional 5 s entry in those
rows always comes directly after a 132 s one and is a retry at RekeyTimeout,
not a second cycle.

The go log says the same thing more bluntly:

Client keepalives sent handshake initiations Retrying handshake
v0.2.19, S4 = 23 92 20 0
v3.0.3, S4 = 23 169 169 153
v3.0.3, S4 unset 94 22 0

Those are window totals, not a per-event correlation. Still, on the affected build
the two counts come out equal at 169, and the other two sent 92 and 94 keepalives
without printing a single retry line.

Why v0.2.19 and v3.0.3 behave differently

It comes down to where the check sits relative to the padding.

In v0.2.19 the check runs first and the transport padding is prepended
afterwards, so the length it looks at is still the unpadded one:

// device/send.go
if len(elem.packet) != MessageKeepaliveSize {          // 575
    dataSent = true
}
if padding := device.paddings.transport; padding > 0 { // 578
    ...
    elem.packet = elem.buffer[:padding+len(elem.packet)]
}

In v3.0.3 the padding is inside elem.packet by the time the check runs,
because Seal writes into a buffer that starts with it:

// device/send.go
elem.packet = elem.keypair.send.Seal(
    elem.buffer[:elem.padding+MessageTransportHeaderSize],  // 577-578
    ...
)
...
if len(elem.packet) != MessageKeepaliveSize {               // 631
    dataSent = true
}

From there it is the path from my earlier comment: dataSent goes true,
timersDataSent() arms newHandshake, and device/constants.go has
KeepaliveTimeout at 10 s and RekeyTimeout at 5 s.

Both halves of that arrived in 457d920 ("feat: amneziawg 3.0", 24 Jul 2026),
which introduced the padded Seal buffer and removed the block that used to
prepend the padding after the check. The check itself was never touched. It just
stopped seeing an unpadded length.

Confirming it with a one-line change

Correlation with S4 does not by itself show that the misclassified keepalive is
what arms the timer, so I rebuilt v3.0.3 with one line changed. The cast is
needed because elem.padding is uint32 and len() returns int:

-  if len(elem.packet) != MessageKeepaliveSize {
+  if len(elem.packet)-int(elem.padding) != MessageKeepaliveSize {

Twelve minutes per phase, same parameters throughout, control pair in all three.
The phases run in the order below and the namespaces, keys and configs are rebuilt
for each one, so what carries over is the parameter set rather than the files:

Phase Binary S4 = 23 Control, S4 unset
A stock 15.0 s, 49 intervals 132.0 s, 5 intervals
B stock plus that line 132.0 s, 5 intervals 132.0 s, 5 intervals
A' stock again 15.0 s, 49 intervals 132.0 s, 5 intervals

With the line in, the S4 tunnel lands on the same 132.0 s as the control, and
taking it back out restores the 15 s. Five intervals per phase is just what twelve
minutes of 132 s cycles gives you, and in those phases min and max are 132 too. No
gaps or out of order timestamps in any phase.

As before, I am not putting the diff forward as the fix. It is there to pin the
mechanism.

Which clients this actually reaches

I measured v0.2.19, while the current stable Android release (2.0.1) builds
against v0.2.18. Those two tags carry a byte identical device/send.go, same
SHA256, so the code path I measured is the one that release ships. The 3.0 line is
in the prerelease v3.0.1, which builds against amneziawg-go/v3 v3.0.1.

That is a go.mod check at those tags rather than an inspection of a published
APK, and I only did it for Android. I did not work out which line the shipped
Windows and Apple builds use, so I would not read anything into it past Android.

If it does hold more widely, the timing is at least convenient: the affected line
is the one still going out as a prerelease.

What this does not cover

Both ends of every pair ran on the same host and shared a scheduler, same caveat as
my earlier numbers. I measured the send side only, and the receiving end was the
kernel module in every pair, so the receive path where a padded keepalive fails
the zero length check is still untested. ContentPaddingAddition was not
exercised, the pairs differed only in S4. Each condition was a single tunnel
rather than several, and all of this is the library rather than the client
applications built on it.

Happy to share the scripts, and the offer from my earlier comment stands: if you
put a fix together for either implementation, I can run the same measurement
against it.

<!-- gh-comment-id:5184365915 --> @bivlked commented on GitHub (Aug 4, 2026): Short version: with `S4` set, an idle tunnel on `amneziawg-go v3.0.3` redoes the handshake every 15 seconds instead of every 132, so close to nine times more often. `v0.2.19` from the 2.0 line does not, and neither does `v3.0.3` with `S4` unset. The kernel module in the same run sat at 15 s, matching the numbers I posted earlier. This follows up the last paragraph of that comment, where I said the same length comparison exists in `amneziawg-go` but that I had only read it. I had only read the 3.0 code, and assumed it spoke for the whole go side. It does not. ## How I measured it Same host and module as before: Ubuntu 24.04.4, kernel `6.8.0-137-generic`, `amneziawg-dkms 1.0.0-0~202608010147+c78a89e~ubuntu24.04.1` and `amneziawg-tools v3.0.20260730` from the PPA, module reporting `3.0.20260731-04`. Four pairs, each with its own keys, port, subnet and interfaces, both ends configured with plain `awg setconf`. The server side is the kernel module every time, so go is always the client. Obfuscation parameters are the same in all four and were copied out of a config generated by my installer ([bivlked/amneziawg-installer](https://github.com/bivlked/amneziawg-installer)): `Jc = 6`, `Jmin = 75`, `Jmax = 174`, `S1 = 119`, `S2 = 75`, `S3 = 46`, `S4 = 23`, distinct `H1`-`H4` ranges, `I1`. Only two things vary across the four: which go build is on the client, and whether `S4` is there at all. Separate pairs rather than extra peers on one interface, because `S1`-`S4` and `H1`-`H4` are device parameters and not per-peer ones. A control with `S4` absent cannot sit on an interface that has `S4` set. Bring each tunnel up, then leave it alone and poll `awg show <if> latest-handshakes` once a second for 40 minutes. Nothing sends traffic through the tunnels, and the client namespace has no route into any of them, so nothing goes through by accident. The timestamps come from the handshake counter at one second resolution, so the intervals are whole seconds. ## Results | Client | `S4` | intervals | median | spread | |---|---|--:|--:|---| | `amneziawg-go v0.2.19` | 23 | 19 | **132.0 s** | 18 of 19 above 130 s | | `amneziawg-go v3.0.3` | 23 | 168 | **15.0 s** | 168 of 168 below 20 s | | kernel module | 23 | 156 | **15.0 s** | 156 of 156 below 20 s | | `amneziawg-go v3.0.3` | unset | 21 | **132.0 s** | 18 of 21 above 130 s | Each row is one tunnel, so the interval count says how steady that tunnel was, not how many tunnels agreed. The kernel row is there as a positive control. Same run, same host, same minutes, and it lands on the 15.0 s I reported before. Without it a clean result on `v0.2.19` would prove nothing, since it could just as easily mean my setup was incapable of showing the problem at all. The 132 s is not an arbitrary number. `RekeyAfterTime` is 120 s and `PersistentKeepalive` is 33 s, so keepalives land on 33, 66, 99, 132, and the first one past 120 s is what triggers the rekey. The occasional 5 s entry in those rows always comes directly after a 132 s one and is a retry at `RekeyTimeout`, not a second cycle. The go log says the same thing more bluntly: | Client | keepalives sent | handshake initiations | `Retrying handshake` | |---|--:|--:|--:| | `v0.2.19`, `S4 = 23` | 92 | 20 | **0** | | `v3.0.3`, `S4 = 23` | 169 | 169 | **153** | | `v3.0.3`, `S4` unset | 94 | 22 | **0** | Those are window totals, not a per-event correlation. Still, on the affected build the two counts come out equal at 169, and the other two sent 92 and 94 keepalives without printing a single retry line. ## Why `v0.2.19` and `v3.0.3` behave differently It comes down to where the check sits relative to the padding. In `v0.2.19` the check runs first and the transport padding is prepended afterwards, so the length it looks at is still the unpadded one: ```go // device/send.go if len(elem.packet) != MessageKeepaliveSize { // 575 dataSent = true } if padding := device.paddings.transport; padding > 0 { // 578 ... elem.packet = elem.buffer[:padding+len(elem.packet)] } ``` In `v3.0.3` the padding is inside `elem.packet` by the time the check runs, because `Seal` writes into a buffer that starts with it: ```go // device/send.go elem.packet = elem.keypair.send.Seal( elem.buffer[:elem.padding+MessageTransportHeaderSize], // 577-578 ... ) ... if len(elem.packet) != MessageKeepaliveSize { // 631 dataSent = true } ``` From there it is the path from my earlier comment: `dataSent` goes true, `timersDataSent()` arms `newHandshake`, and `device/constants.go` has `KeepaliveTimeout` at 10 s and `RekeyTimeout` at 5 s. Both halves of that arrived in `457d920` ("feat: amneziawg 3.0", 24 Jul 2026), which introduced the padded `Seal` buffer and removed the block that used to prepend the padding after the check. The check itself was never touched. It just stopped seeing an unpadded length. ## Confirming it with a one-line change Correlation with `S4` does not by itself show that the misclassified keepalive is what arms the timer, so I rebuilt `v3.0.3` with one line changed. The cast is needed because `elem.padding` is `uint32` and `len()` returns `int`: ```diff - if len(elem.packet) != MessageKeepaliveSize { + if len(elem.packet)-int(elem.padding) != MessageKeepaliveSize { ``` Twelve minutes per phase, same parameters throughout, control pair in all three. The phases run in the order below and the namespaces, keys and configs are rebuilt for each one, so what carries over is the parameter set rather than the files: | Phase | Binary | `S4 = 23` | Control, `S4` unset | |---|---|--:|--:| | A | stock | 15.0 s, 49 intervals | 132.0 s, 5 intervals | | B | stock plus that line | **132.0 s, 5 intervals** | 132.0 s, 5 intervals | | A' | stock again | 15.0 s, 49 intervals | 132.0 s, 5 intervals | With the line in, the `S4` tunnel lands on the same 132.0 s as the control, and taking it back out restores the 15 s. Five intervals per phase is just what twelve minutes of 132 s cycles gives you, and in those phases min and max are 132 too. No gaps or out of order timestamps in any phase. As before, I am not putting the diff forward as the fix. It is there to pin the mechanism. ## Which clients this actually reaches I measured `v0.2.19`, while the current stable Android release (`2.0.1`) builds against `v0.2.18`. Those two tags carry a byte identical `device/send.go`, same SHA256, so the code path I measured is the one that release ships. The 3.0 line is in the prerelease `v3.0.1`, which builds against `amneziawg-go/v3 v3.0.1`. That is a `go.mod` check at those tags rather than an inspection of a published APK, and I only did it for Android. I did not work out which line the shipped Windows and Apple builds use, so I would not read anything into it past Android. If it does hold more widely, the timing is at least convenient: the affected line is the one still going out as a prerelease. ## What this does not cover Both ends of every pair ran on the same host and shared a scheduler, same caveat as my earlier numbers. I measured the send side only, and the receiving end was the kernel module in every pair, so the receive path where a padded keepalive fails the zero length check is still untested. `ContentPaddingAddition` was not exercised, the pairs differed only in `S4`. Each condition was a single tunnel rather than several, and all of this is the library rather than the client applications built on it. Happy to share the scripts, and the offer from my earlier comment stands: if you put a fix together for either implementation, I can run the same measurement against it.
Author
Owner

@ygurov commented on GitHub (Aug 4, 2026):

Hi @Ratio2 @bivlked,
Thanks for letting me know about, and deep debugging of this issue. To be fixed in https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/pull/208, TBD in awg-go

<!-- gh-comment-id:5185498253 --> @ygurov commented on GitHub (Aug 4, 2026): Hi @Ratio2 @bivlked, Thanks for letting me know about, and deep debugging of this issue. To be fixed in https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/pull/208, TBD in awg-go
Author
Owner

@ygurov commented on GitHub (Aug 5, 2026):

Done in awg-go by github.com/amnezia-vpn/amneziawg-go@08d68cdae2

<!-- gh-comment-id:5191778372 --> @ygurov commented on GitHub (Aug 5, 2026): Done in awg-go by https://github.com/amnezia-vpn/amneziawg-go/commit/08d68cdae27762c3e07f36bbb12d2bad32f81926
Author
Owner

@ygurov commented on GitHub (Aug 5, 2026):

@Ratio2 @bivlked
Thanks for your contribution! Closing issue as resolved

<!-- gh-comment-id:5191782797 --> @ygurov commented on GitHub (Aug 5, 2026): @Ratio2 @bivlked Thanks for your contribution! Closing issue as resolved
Author
Owner

@bivlked commented on GitHub (Aug 6, 2026):

As offered earlier, I ran the same measurement against the fix. Posting it here for the record even though the issue is closed. Short version: in the configuration I tested, the loop is gone.

Two phases, one host, kernel module on both ends. Each phase is a single 1200 s window in which two tunnels were measured at the same time, the only difference between them being S4:

  • t1: S4 = 17, PersistentKeepalive = 33
  • t2: control, S4 unset

Idle tunnels. Server in the root netns, client in a second netns over a veth pair, configuration applied with plain awg setconf, so no routes or DNS are involved. Intervals come from polling awg show <if> latest-handshakes once per second. I use the median rather than the mean, since at these sample sizes a single outlier in either direction moves the mean noticeably.

tunnel before, 3.0.20260731-04 after, 3.0.20260805
t1, S4 = 17 n=77, median 15.0 s n=9, median 147.0 s
t2, control n=8, median 148.0 s n=8, median 147.0 s

For scale: between the two phases the control's median moved by one second, while t1 moved roughly tenfold. The interval sequences show it better than the summary does:

before, t1:  27, 15, 16, 15, 15, 16, 15, 15, 16, ...   (77 intervals)
after,  t1:  156, 147, 148, 5, 146, 148, 147, 148, 147
after,  t2:  123, 147, 148, 147, 148, 147, 148, 147

Ubuntu 24.04.4, kernel 6.8.0-137-generic, amneziawg-tools v3.0.20260730. The "before" phase ran on amneziawg-dkms 1.0.0-0~202608010147+c78a89e~ubuntu24.04.1, the same package as in my original report, so those numbers line up with what I posted then. The fix is already published in the PPA as 1.0.0-0~202608051436+ce16310~ubuntu24.04.1.

One practical note for anyone repeating this: upgrading the package puts the new module on disk but does not replace the one already loaded. Right after the upgrade the on-disk module was 3.0.20260805 while /sys/module/amneziawg/version still read 3.0.20260731-04. So I brought the interface down, ran modprobe -r and modprobe, and checked both version and srcversion (D7B9BC6847E2900B014FE7B to 2CAAA0D41116817D097CF12) before starting the second phase.

What this run does not cover:

  • ContentPaddingAddition was not tested, only S4. The PR covers both triggers, but I only have data for one of them.
  • amneziawg-go was not tested here; that change is a separate commit.
  • One host, both ends on the kernel module, one parameter profile, one kernel. Loss, NAT and MTU were not part of this.
  • In the "after" run t1 has one isolated 5 s interval, roughly 450 s into the window, right after a normal one. I cannot explain that one from this data. It does not look like a leftover of the loop, which was 77 intervals in a row at 15-16 s, while this is a single event among eight normal ones. Still, I would rather mention it than round it away. Without it t1 reads 156, 147, 148, 146, 148, 147, 148, 147.

Thanks for turning this around so quickly.

<!-- gh-comment-id:5208578272 --> @bivlked commented on GitHub (Aug 6, 2026): As offered earlier, I ran the same measurement against the fix. Posting it here for the record even though the issue is closed. Short version: in the configuration I tested, the loop is gone. Two phases, one host, kernel module on both ends. Each phase is a single 1200 s window in which two tunnels were measured at the same time, the only difference between them being `S4`: - `t1`: `S4 = 17`, `PersistentKeepalive = 33` - `t2`: control, `S4` unset Idle tunnels. Server in the root netns, client in a second netns over a veth pair, configuration applied with plain `awg setconf`, so no routes or DNS are involved. Intervals come from polling `awg show <if> latest-handshakes` once per second. I use the median rather than the mean, since at these sample sizes a single outlier in either direction moves the mean noticeably. | tunnel | before, `3.0.20260731-04` | after, `3.0.20260805` | |---|---|---| | t1, `S4 = 17` | n=77, median **15.0 s** | n=9, median **147.0 s** | | t2, control | n=8, median 148.0 s | n=8, median 147.0 s | For scale: between the two phases the control's median moved by one second, while t1 moved roughly tenfold. The interval sequences show it better than the summary does: ``` before, t1: 27, 15, 16, 15, 15, 16, 15, 15, 16, ... (77 intervals) after, t1: 156, 147, 148, 5, 146, 148, 147, 148, 147 after, t2: 123, 147, 148, 147, 148, 147, 148, 147 ``` Ubuntu 24.04.4, kernel 6.8.0-137-generic, `amneziawg-tools v3.0.20260730`. The "before" phase ran on `amneziawg-dkms 1.0.0-0~202608010147+c78a89e~ubuntu24.04.1`, the same package as in my original report, so those numbers line up with what I posted then. The fix is already published in the PPA as `1.0.0-0~202608051436+ce16310~ubuntu24.04.1`. One practical note for anyone repeating this: upgrading the package puts the new module on disk but does not replace the one already loaded. Right after the upgrade the on-disk module was `3.0.20260805` while `/sys/module/amneziawg/version` still read `3.0.20260731-04`. So I brought the interface down, ran `modprobe -r` and `modprobe`, and checked both `version` and `srcversion` (`D7B9BC6847E2900B014FE7B` to `2CAAA0D41116817D097CF12`) before starting the second phase. What this run does not cover: - **`ContentPaddingAddition` was not tested**, only `S4`. The PR covers both triggers, but I only have data for one of them. - `amneziawg-go` was not tested here; that change is a separate commit. - One host, both ends on the kernel module, one parameter profile, one kernel. Loss, NAT and MTU were not part of this. - In the "after" run t1 has one isolated 5 s interval, roughly 450 s into the window, right after a normal one. I cannot explain that one from this data. It does not look like a leftover of the loop, which was 77 intervals in a row at 15-16 s, while this is a single event among eight normal ones. Still, I would rather mention it than round it away. Without it t1 reads 156, 147, 148, 146, 148, 147, 148, 147. Thanks for turning this around so quickly.
Sign in to join this conversation.
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
amnezia-vpn/amneziawg-linux-kernel-module#110
No description provided.