Skip to content

Commit 8d61996

Browse files
committed
worker: make stale peer cleanup reliable
1 parent 8124f79 commit 8d61996

14 files changed

Lines changed: 1869 additions & 509 deletions

PARKER.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,41 @@ For Parker the following configuration items are relevant in addition to the def
1919
- `sticky_worker_tolerance`
2020
- `broker_signing_key`
2121
- `parker` section
22+
- `cleanup` section
2223
- `netbox` section
2324

2425

2526
## Design considerations
2627

28+
### Offline peer cleanup
29+
30+
The Parker worker defaults to a 300-second stale-handshake timeout and a
31+
600-second initial-handshake grace. The stale timeout accommodates the default
32+
120-second config retry and 180-second WireGuard tunnel timeout. Zero or missing
33+
handshake timestamps mean that a peer has never handshaked; they do not cause
34+
immediate deletion. Accepted queue updates refresh an in-memory, bounded
35+
provisioning tracker, and cleanup serializes only with updates for the same
36+
public key or assigned prefix.
37+
38+
WireGuard does not expose peer creation time. After a worker restart, every
39+
untracked never-handshaked peer therefore receives one grace period from worker
40+
startup. Truly abandoned peers are removed on a later sweep rather than retained
41+
forever. The tracker retains at most 65,536 peer timestamps; if that bound is
42+
reached before entries age out, new provisioning fails explicitly rather than
43+
dropping grace state and risking premature cleanup.
44+
45+
For a stale Parker peer, cleanup removes the selected assigned-prefix route
46+
before removing the WireGuard peer. If another current peer owns the prefix, its
47+
route is preserved. A real route or peer failure is reported and retried on the
48+
next sweep; already-absent state is treated as idempotent.
49+
50+
When an accepted queue update reassigns a peer, the worker locks both the old
51+
and new prefixes, installs the new peer state and route, then removes the old
52+
route. `Range6` must match the configured Parker IPv6 allocation prefix length.
53+
If old-route deletion fails after reassignment, the bounded worker-side tracker
54+
retries it on cleanup sweeps after checking that no current peer owns it. This
55+
prevents cleanup from preserving a route that the peer immediately abandons.
56+
2757
### Concentrator selection
2858

2959
Parker nodes call the config endpoint every `retry_interval` (default: 120s), regardless of whether they currently have connectivity or not.

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- [Backend worker](#backend-worker)
1212
- [Installation](#installation)
1313
- [Configuration](#configuration)
14+
- [Worker peer cleanup](#worker-peer-cleanup)
1415
- [Development](#development)
1516
- [Build using Bazel](#build-using-bazel)
1617
- [Updating PIP dependencies for Bazel](#updating-pip-dependencies-for-bazel)
@@ -152,6 +153,29 @@ For further information, please see this [presentation on the architecture](http
152153
The `wgkex` configuration file defaults to `/etc/wgkex.yaml` ([Sample configuration file](wgkex.yaml.example)), however
153154
can also be overwritten by setting the environment variable `WGKEX_CONFIG_FILE`.
154155

156+
### Worker peer cleanup
157+
158+
Workers periodically remove offline WireGuard peers. A peer with a non-zero
159+
last-handshake timestamp is stale when its age is greater than or equal to the
160+
mode-specific timeout. A peer that has never handshaked is retained for
161+
`cleanup.initial_handshake_grace` after its most recent accepted queue update.
162+
After a worker restart, the kernel does not expose peer creation time, so all
163+
untracked never-handshaked peers receive one grace period starting at worker
164+
startup; abandoned peers are removed by the first later sweep.
165+
166+
Cleanup scans do not block MQTT ingestion. Queue updates and cleanup serialize
167+
mutations for the same public key or Parker prefix. Parker deletion removes the
168+
assigned prefix route before the WireGuard peer, unless another current peer
169+
owns that prefix. Legacy deletion removes the FDB entry, then route, then peer.
170+
Already-absent dependencies are idempotent; other partial failures are reported
171+
and retried while the peer remains discoverable. Parker prefix reassignment
172+
locks both old and new prefixes and removes the old route after installing the
173+
new peer state and route; failed old-route deletion is retained and retried on
174+
later cleanup sweeps after an ownership check.
175+
176+
See `wgkex.yaml.example` for the cleanup interval, Parker and legacy stale
177+
timeouts, and initial-handshake grace defaults.
178+
155179
## Development
156180

157181
### Build using [Bazel](https://bazel.build)

wgkex.yaml.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ workers:
4242
sticky_worker_tolerance: 15
4343
# [worker] The external hostname of this worker
4444
externalName: gw04.ext.ffmuc.net
45+
# [worker] Offline peer cleanup timing, in seconds. All values must be positive.
46+
cleanup:
47+
# How often each WireGuard interface is scanned.
48+
interval: 3600
49+
# A Parker peer with an earlier last handshake is stale. The default allows
50+
# multiple 120s config retries and the 180s WireGuard tunnel timeout.
51+
parker_stale_timeout: 300
52+
# Preserve the historical three-hour timeout for legacy peers.
53+
legacy_stale_timeout: 10800
54+
# Never-handshaked peers are retained this long after provisioning or worker
55+
# startup, then treated as abandoned.
56+
initial_handshake_grace: 600
4557
# [broker, worker]
4658
parker:
4759
# Enable Parker support. The broker will still handle non-Parker in the same instance, the worker however will disable non-Parker support in this case.

wgkex/config/config.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import dataclasses
44
import logging
5+
import math
56
import os
67
import sys
78
from enum import Enum
@@ -18,6 +19,19 @@ class ConfigFileNotFoundError(Error):
1819
"""File could not be found on disk."""
1920

2021

22+
def _positive_duration(value: Any, name: str) -> float:
23+
"""Parse a finite, positive duration in seconds."""
24+
if isinstance(value, bool):
25+
raise ValueError(f"{name} must be a positive duration in seconds")
26+
try:
27+
duration = float(value)
28+
except (TypeError, ValueError) as error:
29+
raise ValueError(f"{name} must be a positive duration in seconds") from error
30+
if not math.isfinite(duration) or duration <= 0:
31+
raise ValueError(f"{name} must be a positive duration in seconds")
32+
return duration
33+
34+
2135
WG_CONFIG_OS_ENV = "WGKEX_CONFIG_FILE"
2236
WG_CONFIG_DEFAULT_LOCATION = "/etc/wgkex.yaml"
2337

@@ -173,6 +187,42 @@ def from_dict(cls, mqtt_cfg: Dict[str, str]) -> "MQTT":
173187
)
174188

175189

190+
@dataclasses.dataclass(frozen=True)
191+
class Cleanup:
192+
"""Worker peer cleanup timing in seconds."""
193+
194+
interval: float = 3600
195+
parker_stale_timeout: float = 300
196+
legacy_stale_timeout: float = 10800
197+
initial_handshake_grace: float = 600
198+
199+
@classmethod
200+
def from_dict(cls, cleanup_cfg: Dict[str, Any]) -> "Cleanup":
201+
if not isinstance(cleanup_cfg, dict):
202+
raise ValueError("cleanup must be a mapping")
203+
return cls(
204+
interval=_positive_duration(
205+
cleanup_cfg.get("interval", cls.interval), "cleanup.interval"
206+
),
207+
parker_stale_timeout=_positive_duration(
208+
cleanup_cfg.get("parker_stale_timeout", cls.parker_stale_timeout),
209+
"cleanup.parker_stale_timeout",
210+
),
211+
legacy_stale_timeout=_positive_duration(
212+
cleanup_cfg.get("legacy_stale_timeout", cls.legacy_stale_timeout),
213+
"cleanup.legacy_stale_timeout",
214+
),
215+
initial_handshake_grace=_positive_duration(
216+
cleanup_cfg.get("initial_handshake_grace", cls.initial_handshake_grace),
217+
"cleanup.initial_handshake_grace",
218+
),
219+
)
220+
221+
def stale_timeout(self, parker: bool) -> float:
222+
"""Return the stale timeout for the selected worker mode."""
223+
return self.parker_stale_timeout if parker else self.legacy_stale_timeout
224+
225+
176226
@dataclasses.dataclass
177227
class Parker:
178228
"""A representation of the 'parker' key in Configuration file.
@@ -389,6 +439,7 @@ class Config:
389439
domain_prefixes: List[str]
390440
workers: Workers
391441
parker: Parker
442+
cleanup: Cleanup
392443
external_name: Optional[str]
393444
mqtt: MQTT
394445
broker_listen: BrokerListen
@@ -410,6 +461,7 @@ def from_dict(cls, cfg: Dict[str, Any]) -> "Config":
410461
cfg.get("workers", {}), cfg.get("sticky_worker_tolerance", 10)
411462
)
412463
parker = Parker.from_dict(cfg.get("parker", {}))
464+
cleanup = Cleanup.from_dict(cfg.get("cleanup", {}))
413465
broker_signing_key = cfg.get("broker_signing_key", None)
414466

415467
netbox_cfg = None
@@ -428,6 +480,7 @@ def from_dict(cls, cfg: Dict[str, Any]) -> "Config":
428480
workers=workers_cfg,
429481
external_name=cfg.get("externalName"),
430482
parker=parker,
483+
cleanup=cleanup,
431484
netbox=netbox_cfg,
432485
broker_signing_key=broker_signing_key,
433486
)

wgkex/config/config_test.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,43 @@ def test_worker_explicit_null_values_use_defaults(self):
182182
workers = config.Workers.from_dict({"worker": {"weight": 0}}, 10)
183183
self.assertEqual(workers.get("worker").weight, 0)
184184

185+
def test_cleanup_defaults_and_mode_timeouts(self):
186+
cleanup = config.Cleanup.from_dict({})
187+
self.assertEqual(cleanup.interval, 3600)
188+
self.assertEqual(cleanup.stale_timeout(True), 300)
189+
self.assertEqual(cleanup.stale_timeout(False), 10800)
190+
self.assertEqual(cleanup.initial_handshake_grace, 600)
191+
192+
cfg = _config_dict()
193+
cfg["cleanup"] = {
194+
"interval": 30,
195+
"parker_stale_timeout": 600,
196+
"legacy_stale_timeout": 7200,
197+
"initial_handshake_grace": 900,
198+
}
199+
parsed = config.Config.from_dict(cfg)
200+
self.assertEqual(parsed.cleanup.interval, 30)
201+
self.assertEqual(parsed.cleanup.stale_timeout(True), 600)
202+
self.assertEqual(parsed.cleanup.stale_timeout(False), 7200)
203+
self.assertEqual(parsed.cleanup.initial_handshake_grace, 900)
204+
205+
def test_cleanup_durations_are_positive_and_finite(self):
206+
fields = (
207+
"interval",
208+
"parker_stale_timeout",
209+
"legacy_stale_timeout",
210+
"initial_handshake_grace",
211+
)
212+
for field in fields:
213+
for value in (0, -1, True, "invalid", float("inf"), None):
214+
with (
215+
self.subTest(field=field, value=value),
216+
self.assertRaisesRegex(ValueError, field),
217+
):
218+
config.Cleanup.from_dict({field: value})
219+
with self.assertRaisesRegex(ValueError, "mapping"):
220+
config.Cleanup.from_dict([]) # type: ignore
221+
185222
def test_parker_prefix_structure_validation(self):
186223
invalid_configs = [
187224
{"enabled": True, "464xlat": True, "ipam": "json"},

wgkex/worker/BUILD

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@ load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test")
22
load("@pip//:requirements.bzl", "requirement")
33

44

5+
py_library(
6+
name = "peer_state",
7+
srcs = ["peer_state.py"],
8+
visibility = ["//visibility:public"],
9+
)
10+
11+
py_test(
12+
name = "peer_state_test",
13+
srcs = ["peer_state_test.py"],
14+
deps = [":peer_state"],
15+
size = "small",
16+
)
17+
18+
519
py_library(
620
name = "netlink",
721
srcs = ["netlink.py"],
@@ -13,6 +27,7 @@ py_library(
1327
"//wgkex/common:utils",
1428
"//wgkex/common:logger",
1529
"//wgkex/config:config",
30+
":peer_state",
1631
],
1732
)
1833

@@ -42,6 +57,7 @@ py_library(
4257
"//wgkex/config:config",
4358
":msg_queue",
4459
":netlink",
60+
":peer_state",
4561
],
4662
)
4763

@@ -62,6 +78,7 @@ py_binary(
6278
deps = [
6379
":mqtt",
6480
":msg_queue",
81+
":peer_state",
6582
"//wgkex/config:config",
6683
"//wgkex/common:logger",
6784
],
@@ -85,6 +102,7 @@ py_library(
85102
deps = [
86103
"//wgkex/common:logger",
87104
":netlink",
105+
":peer_state",
88106
],
89107
)
90108

@@ -93,6 +111,8 @@ py_test(
93111
srcs = ["msg_queue_test.py"],
94112
deps = [
95113
":msg_queue",
114+
":netlink",
115+
":peer_state",
96116
requirement("mock"),
97117
],
98118
size="small",

0 commit comments

Comments
 (0)