How WireGuard Portal almost killed my phone battery

My old phone drained its battery fast anyway. It was an old phone with an old battery, so I didn't expect anything else. When I got a new phone, it was a relief to have proper battery life again.

Then one morning I woke up with quite a chunk less battery than expected. Going through the phone's energy monitor, I noticed something unusual: about 25% of the energy usage had gone to the radio. I had never noticed the radio being particularly high before.

The day before, I had installed WireGuard Portal in my Kubernetes cluster and configured WireGuard on my phone. That seemed suspicious, but there was an obvious problem with that theory:

WireGuard wasn't connected.

The WireGuard tunnel wasn't active. The WireGuard client wasn't even running. So how could WireGuard possibly be keeping my phone's Wi-Fi radio busy?

Something was still talking

Checking the activity of the WireGuard container, I noticed traffic every minute. Coincidence seemed a little far-fetched, but how? I had stopped the WireGuard tunnel and the WireGuard client.

Looking at the traffic with `tcpdump` gave me the answer: it was a ping. WireGuard Portal periodically pings WireGuard clients to update their online/offline status. By default, it was doing this every 60 seconds.

The next question was considerably more interesting: how does a ping to an inactive WireGuard client end up reaching my phone?

How does a packet travel from WireGuard to my phone?

WireGuard Portal doesn't ping the phone's public IP address. It pings the phone's WireGuard IP address, so the ICMP packet is routed through the WireGuard interface.

WireGuard knows which peer owns that address, encrypts the packet, encapsulates it in UDP, and sends that encrypted packet through the node's normal network interface towards the peer's last known endpoint.

Conceptually:

WireGuard Portal
      |
      | ICMP ping to phone's WireGuard IP
      v
     wg0
      |
      | WireGuard encrypts the packet
      v
Encrypted WireGuard UDP packet
      |
      v
Normal network interface of the node
      |
      v
Internet
      |
      v
Phone's last known public endpoint
      |
      v
NAT
      |
      v
Phone

This distinction is important. WireGuard itself isn't periodically checking whether my phone is alive. WireGuard Portal generates the ping. The kernel routes that ping through `wg0`, and WireGuard does its job: it encrypts the packet and transports it towards the peer.

WireGuard also remembers the last authenticated endpoint of a peer. Stopping WireGuard on the phone doesn't immediately make the server forget where that phone was. So WireGuard Portal could continue giving WireGuard packets to send towards the phone's last known endpoint even though WireGuard was no longer active on the phone.

NAT should eventually stop this

The WireGuard client originally established communication by sending a UDP packet to the WireGuard server. My phone is behind NAT, so that outbound traffic creates NAT/conntrack state which allows the corresponding UDP traffic from the WireGuard server to travel back to the phone.

That state isn't permanent. After a period without traffic, the UDP conntrack state expires. Once that happens, packets sent by the server towards that old mapping can no longer reach the phone.

On my router, the relevant UDP conntrack timeout was 180 seconds. So after WireGuard stopped communicating, I expected the path to disappear after three minutes.

Except WireGuard Portal was sending its ping every 60 seconds.

That was the problem.

WireGuard stops
      |
   60 seconds
      |
Portal ping
      |
   60 seconds
      |
Portal ping
      |
   60 seconds
      |
Portal ping
      |
     ...

The NAT/conntrack state never got its 180 seconds of inactivity. Portal kept generating another packet before that could happen. The packet travelled through `wg0`, WireGuard encrypted it, and the resulting UDP traffic was sent towards the phone's remembered endpoint.

The WireGuard client on the phone wasn't active, so there was nothing useful to do with that WireGuard traffic. But the network traffic could still reach the phone, and that meant waking its Wi-Fi radio every single minute.

The WireGuard client was off, but WireGuard Portal on the server was effectively keeping the path to it alive.

The fix: 200 seconds

I still wanted WireGuard Portal to monitor the clients, so simply disabling the ping wasn't ideal. Instead, I changed its interval from 60 to 200 seconds:

- name: WG_PORTAL_STATISTICS_PING_CHECK_INTERVAL
  value: "200s"

That is deliberately longer than the 180-second conntrack timeout on my router.

Now, when WireGuard isn't active on the phone:

WireGuard stops
      |
  180 seconds
      |
      v
NAT/conntrack state expires
      |
   20 seconds
      |
      v
Portal sends its next ping
      |
      v
WireGuard encrypts it and sends UDP
towards the last known endpoint
      |
      v
Old NAT mapping no longer exists
      |
      X
Packet doesn't reach the phone

An important detail here is that WireGuard Portal hasn't stopped pinging. It still generates a ping every 200 seconds, and WireGuard still encrypts that packet and attempts to send it towards the peer's last known endpoint.

The difference is simply that the NAT mapping has had enough time to expire before the next ping arrives.

After 180 seconds the conntrack pinholes are closed, it's a dead end directly at the normal network interface of the node of the WireGuard Portal container.

WireGuard Portal can keep trying every 200 seconds, but those attempts can no longer keep the old path to the phone alive.

One tiny ping

There wasn't a huge amount of traffic involved. There wasn't a runaway process downloading gigabytes in the background, and WireGuard itself wasn't periodically probing my phone. It was just WireGuard Portal trying to determine whether a client was online.

One tiny ping every minute.

But that ping travelled through WireGuard, which turned it into encrypted UDP traffic towards the phone's remembered endpoint. The 60-second interval was short enough to keep the network state alive, even though WireGuard itself had been switched off on the phone.

The result was rather counterintuitive: turning off WireGuard on the phone wasn't enough to stop WireGuard traffic from reaching the phone. The server was keeping the path alive.

Changing the interval from 60 seconds to 200 seconds gave the NAT/conntrack state enough time to expire. WireGuard Portal can still perform its checks, but once the phone is gone, the network finally gets enough silence to forget about it.

A tiny configuration change, but quite a difference for a phone that I'd rather have sleeping at night.