mirror of
https://github.com/amnezia-vpn/amneziawg-linux-kernel-module.git
synced 2026-10-14 05:41:56 +00:00
[GH-ISSUE #186] Handshake every 15 seconds instead 2+ minutes if s4 used #110
Labels
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
amnezia-vpn/amneziawg-linux-kernel-module#110
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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:
client:
@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)@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 againstc78a89e(v3.0.20260731-04), which is what the PPA ships right now.Reproduction
Ubuntu 24.04.4, kernel
6.8.0-87-generic, withamneziawg-dkms 1.0.0-0~202608010147+c78a89e~ubuntu24.04.1andamneziawg-tools v3.0.20260730fromppa:amnezia/ppa.Server in the root netns, client in a second one, veth pair between them. I configured the interfaces with plain
awg setconfso 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 pollawg show <if> latest-handshakesonce a second.The control tunnel is the same pair of files with the two
S4lines removed, on its own port and subnet.Over 1499 s of idle time the
S4 = 17tunnel 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
PersistentKeepalivealtogether still gives 15.0 s (n=33), so the cycle does not need it. A realistic full set rather thanS4on its own (Jc = 4,Jmin = 40,Jmax = 70,S1 = 100,S2 = 120,S3 = 30,S4 = 17, distinctH1-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:
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:255picks upS4asint padding = wg->transport_padding;andsrc/send.c:300prepends it insideencrypt_packet(), so it is part ofskb->lenfrom then on.src/send.c:346inwg_packet_create_data_done()then classifies the packet withis_keepalive = skb->len == message_data_len(0);, and withS4 > 0that comparison can never match.data_sentgoes true,wg_timers_data_sent()(src/timers.c:154) armstimer_new_handshakeatKEEPALIVE_TIMEOUT + REKEY_TIMEOUTplus up toHZ/3of jitter, withREKEY_TIMEOUT = 5(src/messages.h:43) andKEEPALIVE_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
S4does 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:Same config files each time, same two pings to start, then idle:
S4 = 17With that line in, the
S4tunnel becomes indistinguishable from the control and there are noRetrying handshakelines 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-267addspadding_lento the plaintext before encryption, and that also lands inskb->lenbefore 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, thensrc/receive.c:438-439looks for a keepalive by zero decrypted length, does not find one, and the packet is rejected at:455asdishonest_packet_typeand logged asPacket is neither ipv4 nor ipv6on 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:577seals into a buffer that already includeselem.padding, anddevice/send.go:631compareslen(elem.packet)againstMessageKeepaliveSize. 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 theS4case? 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.
@bivlked commented on GitHub (Aug 4, 2026):
Short version: with
S4set, an idle tunnel onamneziawg-go v3.0.3redoes thehandshake every 15 seconds instead of every 132, so close to nine times more
often.
v0.2.19from the 2.0 line does not, and neither doesv3.0.3withS4unset. 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-gobut that I had only read it. I had only read the3.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.1andamneziawg-tools v3.0.20260730from the PPA, module reporting3.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 everytime, 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-H4ranges,I1. Only two things vary across the four: which gobuild is on the client, and whether
S4is there at all.Separate pairs rather than extra peers on one interface, because
S1-S4andH1-H4are device parameters and not per-peer ones. A control withS4absentcannot sit on an interface that has
S4set.Bring each tunnel up, then leave it alone and poll
awg show <if> latest-handshakesonce 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
S4amneziawg-go v0.2.19amneziawg-go v3.0.3amneziawg-go v3.0.3Each 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.19would prove nothing, since it could just as easily mean my setup wasincapable of showing the problem at all.
The 132 s is not an arbitrary number.
RekeyAfterTimeis 120 s andPersistentKeepaliveis 33 s, so keepalives land on 33, 66, 99, 132, and thefirst 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:
Retrying handshakev0.2.19,S4 = 23v3.0.3,S4 = 23v3.0.3,S4unsetThose 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.19andv3.0.3behave differentlyIt comes down to where the check sits relative to the padding.
In
v0.2.19the check runs first and the transport padding is prependedafterwards, so the length it looks at is still the unpadded one:
In
v3.0.3the padding is insideelem.packetby the time the check runs,because
Sealwrites into a buffer that starts with it:From there it is the path from my earlier comment:
dataSentgoes true,timersDataSent()armsnewHandshake, anddevice/constants.gohasKeepaliveTimeoutat 10 s andRekeyTimeoutat 5 s.Both halves of that arrived in
457d920("feat: amneziawg 3.0", 24 Jul 2026),which introduced the padded
Sealbuffer and removed the block that used toprepend 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
S4does not by itself show that the misclassified keepalive iswhat arms the timer, so I rebuilt
v3.0.3with one line changed. The cast isneeded because
elem.paddingisuint32andlen()returnsint: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:
S4 = 23S4unsetWith the line in, the
S4tunnel lands on the same 132.0 s as the control, andtaking 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) buildsagainst
v0.2.18. Those two tags carry a byte identicaldevice/send.go, sameSHA256, 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 againstamneziawg-go/v3 v3.0.1.That is a
go.modcheck at those tags rather than an inspection of a publishedAPK, 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.
ContentPaddingAdditionwas notexercised, the pairs differed only in
S4. Each condition was a single tunnelrather 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.
@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
@ygurov commented on GitHub (Aug 5, 2026):
Done in awg-go by
github.com/amnezia-vpn/amneziawg-go@08d68cdae2@ygurov commented on GitHub (Aug 5, 2026):
@Ratio2 @bivlked
Thanks for your contribution! Closing issue as resolved
@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 = 33t2: control,S4unsetIdle 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 pollingawg show <if> latest-handshakesonce 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.3.0.20260731-043.0.20260805S4 = 17For 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:
Ubuntu 24.04.4, kernel 6.8.0-137-generic,
amneziawg-tools v3.0.20260730. The "before" phase ran onamneziawg-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 as1.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.20260805while/sys/module/amneziawg/versionstill read3.0.20260731-04. So I brought the interface down, ranmodprobe -randmodprobe, and checked bothversionandsrcversion(D7B9BC6847E2900B014FE7Bto2CAAA0D41116817D097CF12) before starting the second phase.What this run does not cover:
ContentPaddingAdditionwas not tested, onlyS4. The PR covers both triggers, but I only have data for one of them.amneziawg-gowas not tested here; that change is a separate commit.Thanks for turning this around so quickly.