diff --git a/debian/changelog b/debian/changelog index 522dc5c..ef83036 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,22 @@ +keel (0.23.15) unstable; urgency=medium + + * After the upgrade to 0.23.13, wg0.conf had MTU = 1280 and the live + wg0 kept 1420 until a reboot (keel#139): only wg-quick up reads the + line, apply planned nothing and keel diff did not see it. The live + change of the overlay now sets the file's MTU with ip link set, + with no restart. A kept file whose interface has another MTU gets + the same action in keel spec apply, said as drift. keel network mtu + does it for each WireGuard interface that is up, and the package's + postinst runs it, so an upgrade needs no reboot. keel diff reports + network.overlay.wireguard.mtu: the live MTU against the file's. + * A handshake older than 180 s is drift in keel diff and keel mesh + status, not same (keel#140): WireGuard does a new handshake every 2 + minutes while packets flow. The drift says the member does not + answer and is unreachable, and no longer tells the operator to run + keel mesh sync on it. + + -- Marcos Méndez Sat, 10 Oct 2026 10:00:00 +0000 + keel (0.23.13) unstable; urgency=medium * keel assemble refuses to pack a template whose rootfs records a diff --git a/debian/keel.postinst b/debian/keel.postinst new file mode 100644 index 0000000..db692c1 --- /dev/null +++ b/debian/keel.postinst @@ -0,0 +1,15 @@ +#!/bin/sh +# keel's postinst: after an upgrade, each WireGuard interface that is up +# gets the MTU its file says, live, with no restart (keel#139). The file +# keel writes sets MTU = 1280 (keel#119), and only wg-quick up reads it, +# so an interface up before the upgrade kept its old MTU until a reboot. +# A failure here is said and never fails the upgrade. +set -e + +if [ "$1" = "configure" ] && command -v keel >/dev/null 2>&1; then + keel network mtu || echo "keel: the overlay's MTU was not set; keel spec apply --system sets it" >&2 +fi + +#DEBHELPER# + +exit 0 diff --git a/docs/apply.md b/docs/apply.md index b938bf0..b987b06 100644 --- a/docs/apply.md +++ b/docs/apply.md @@ -1431,7 +1431,14 @@ goes through the same window, marker, lock, timers and boot unit, with interface's own prefixes (wg-quick routes it, `wg set` does not), a listen port that is not the file's, an interface that is not up, and a `wg set` that fails. The line says which way ran: `done with wg - set, live` or `done with wg-quick down, then up` (keel#120). A change + set, live` or `done with wg-quick down, then up` (keel#120). + **The MTU** of the file (`MTU = 1280`, keel#119) is read by `wg-quick + up` alone, so an interface up before the line came keeps its old MTU. + The live path sets it with `ip link set dev mtu N`, with no + restart, and a kept file whose interface has another MTU gets the same + action, said as drift (keel#139). The package's postinst runs `keel + network mtu`, which does the same for each WireGuard interface that is + up, so an upgrade needs no reboot. A change that only added peers live is marked so in the window's marker, and keel mesh keeps it with no handshake (mesh.md). A change is dated on the clock a session's start time is read on (the start of a thread, in seconds since the diff --git a/docs/diff.md b/docs/diff.md index 86a50d3..c4e530c 100644 --- a/docs/diff.md +++ b/docs/diff.md @@ -224,7 +224,8 @@ listed after every other section: | `derived.monit` | the Monit file apply renders from the manifests and the spec | `/etc/keel/monit/keel-manifest.conf`, compared whole: absent, or a file that differs (a hand edit, or an overlay turned on or off behind the spec), is drift | | `derived.monit.included` | `monitor.enabled` | whether `/etc/monit/conf.d/keel-manifest.conf` is keel's link to that file | | `derived.firewall` | with `firewall.enabled: true`, the ruleset apply renders; with `false`, nothing | `/etc/keel/firewall/keel-manifest.nft`, compared whole; with `false`, keel's file still there is drift | -| `network.overlay.wireguard.peers..handshake` | a handshake | on the live system, `wg show latest-handshakes`: a peer with none since the interface came up is drift, and so is a peer the interface does not hold; a peer with one is `same`, with its age (keel#117). Not compared under `--root` | +| `network.overlay.wireguard.peers..handshake` | a handshake within 180 s | on the live system, `wg show latest-handshakes`: a peer with none since the interface came up is drift, and so is a peer the interface does not hold, and one whose last handshake is older than 180 s (keel#140: WireGuard does a new handshake every 2 minutes while packets flow, so the member does not answer and is unreachable); a recent one is `same`, with its age (keel#117). Not compared under `--root` | +| `network.overlay.wireguard.mtu` | the MTU the interface's file sets | on the live system, the MTU `ip link show` gives for the interface (keel#139): another one is drift, which `keel network mtu` or `keel spec apply --system` sets live with no restart. Not compared under `--root`, nor for a file with no `MTU` line | | `derived.firewall.loaded` | the digest of that ruleset | on the live system, the digest in the comment of the `inet keel` table the kernel holds (`nft list table inet keel`), so a table flushed or edited by hand is drift; not compared under `--root` | Not compared: `installation`, chosen once and recorded nowhere on the diff --git a/docs/mesh.md b/docs/mesh.md index d3ea43e..f999690 100644 --- a/docs/mesh.md +++ b/docs/mesh.md @@ -591,7 +591,9 @@ yet, both reverted, and each then left the other out for an hour, at different times, so they never met. Now the first keeps the second, and the handshake comes once the second has the first too. Until then `keel mesh status` names the member on a `drift:` line, and `keel diff` -has `network.overlay.wireguard.peers..handshake` as drift. A +has `network.overlay.wireguard.peers..handshake` as drift. A last +handshake older than 180 s is drift too, and both say the member does +not answer: it is unreachable (keel#140). A change that removes a peer, changes `[Interface]`, or bounces the interface still waits for a handshake in its window. One sync runs at a time diff --git a/keel/__init__.py b/keel/__init__.py index a13e005..73036bd 100644 --- a/keel/__init__.py +++ b/keel/__init__.py @@ -12,7 +12,7 @@ # an image's tree read offline): debian/changelog's and pyproject.toml's, # which tests/test_version.py keeps in step. Installed, the metadata # dpkg put beside the package answers (keel#98). -FALLBACK_VERSION = "0.23.13" +FALLBACK_VERSION = "0.23.15" def version() -> str: diff --git a/keel/cli.py b/keel/cli.py index 8c9c8da..172c15d 100644 --- a/keel/cli.py +++ b/keel/cli.py @@ -232,6 +232,14 @@ def build_parser() -> argparse.ArgumentParser: ) add_root_option(revert_parser, "revert the change of") revert_parser.set_defaults(handler=commands.network_revert) + mtu_parser = network_actions.add_parser( + "mtu", + help="set the MTU of each WireGuard interface that is up to the" + " one its file says, live, with no restart (keel#139); what the" + " package runs when it is upgraded", + ) + add_root_option(mtu_parser, "read the WireGuard files of") + mtu_parser.set_defaults(handler=commands.network_mtu) add_wireguard_parser(network_actions) mesh_options = mesh_parser.Options( add_common_options, add_root_option, port_number, window_seconds) diff --git a/keel/commands.py b/keel/commands.py index bb1d550..87c226d 100644 --- a/keel/commands.py +++ b/keel/commands.py @@ -316,6 +316,24 @@ def network_revert(args) -> int: return exits.OK if worked else exits.APPLY_FAILED +def network_mtu(args) -> int: + """Each WireGuard interface up whose MTU is not its file's, set live + (keel#139); what the package's postinst runs, so an upgrade needs no + reboot. Only on the live system""" + root = getattr(args, "root", inspection.ROOT_DEFAULT) + refusal = system.needs_root(root, "network mtu") + if refusal: + error(refusal) + return exits.APPLY_NEEDS_ROOT + from keel.network import mtu + failed = False + for line in mtu.converge(root): + bad = "failed" in line or "cannot be read" in line + failed = failed or bad + (error if bad else print)(line) + return exits.APPLY_FAILED if failed else exits.OK + + def network_wireguard_key(args) -> int: """Print this node's WireGuard public key, making the key pair first diff --git a/keel/diff/handshakes.py b/keel/diff/handshakes.py index dd8bb8c..365ab44 100644 --- a/keel/diff/handshakes.py +++ b/keel/diff/handshakes.py @@ -1,16 +1,23 @@ # Copyright (c) 2026 KeelLinux maintainers -"""Drift of the overlay's peers: a peer with no WireGuard handshake +"""Drift of the overlay: a peer with no recent handshake, and the MTU keel mesh keeps a peer it added live with no handshake (keel#117): the addition cannot cut this node off, and the handshake comes once the other member has this node as a peer too. Until then the peer is drift: `network.overlay.wireguard.peers..handshake`, with the age of the last handshake when there is one. A peer the interface does not -hold is drift too. Read from `wg show latest-handshakes` on -the live system only (never `dump`, which holds the private key); -nothing is compared under another root. +hold is drift too, and so is a last handshake older than STALE +(keel#140): WireGuard does a new handshake every 2 minutes while packets +flow, so a member silent for longer does not answer. Read from `wg show + latest-handshakes` on the live system only (never `dump`, +which holds the private key); nothing is compared under another root. + +`network.overlay.wireguard.mtu` compares the MTU the live interface has +with the one its file sets (keel#139): only `wg-quick up` reads the +line, so an interface up before it came kept its old MTU. """ +import os from collections.abc import Callable from datetime import datetime, timezone @@ -20,8 +27,11 @@ from keel.system.ovstate import overlay_of WANTED = "a handshake" -LATER = ("it comes once that member has this node as a peer too (keel" - " mesh sync on that member)") +# WireGuard's rekey time is 120 s while packets flow; a minute more for +# a lost initiation +STALE = 180 +UNREACHABLE = ("the member does not answer: it is unreachable (down, cut" + " off, or without this node as a peer)") NOT_HELD = ("keel mesh sync on this node applies it again, or keel spec" " apply --system") @@ -70,9 +80,43 @@ def handshake_fields(declared: dict, live: bool, output: Reader | None = None, elif not at.isdigit() or at == "0": fields.append(FieldDiff( name, DRIFT, WANTED, "none", - note=f"no handshake since {iface} came up: {LATER}")) + note=f"no handshake since {iface} came up: {UNREACHABLE}")) else: ago = max(int(now.timestamp()) - int(at), 0) - fields.append(FieldDiff(name, SAME, f"{WANTED} ({ago} s ago)", - f"{WANTED} ({ago} s ago)")) + seen_at = f"{WANTED} ({ago} s ago)" + if ago > STALE: + fields.append(FieldDiff( + name, DRIFT, f"{WANTED} within {STALE} s", seen_at, + note=f"older than {STALE} s: {UNREACHABLE}")) + else: + fields.append(FieldDiff(name, SAME, seen_at, seen_at)) return fields + + +def mtu_fields(declared: dict, root: str, live: bool, + output: Reader | None = None) -> list[FieldDiff]: + """The live interface's MTU against its file's, on the live system, + when the file sets one""" + from keel.network import mtu + overlay = overlay_of(declared) or {} + if not live or not overlay.get("address"): + return [] + iface = wireguard.interface(overlay) + try: + with open(os.path.join(root, wireguard.conf_path(iface))) as fob: + wanted = wireguard.parse(fob.read()).mtu + except OSError: + return [] + if wanted is None: + return [] + now = mtu.live_mtu(iface, output) + name = "network.overlay.wireguard.mtu" + if now is None: + return [FieldDiff(name, UNKNOWN, wanted, None, + f"ip link show dev {iface} gave no MTU: is it up?")] + if now == wanted: + return [FieldDiff(name, SAME, wanted, now)] + return [FieldDiff(name, DRIFT, wanted, now, + note="only wg-quick up reads the file's MTU; keel" + " network mtu, or keel spec apply --system, sets it" + " live with no restart")] diff --git a/keel/diff/observe.py b/keel/diff/observe.py index 75646ae..a18006d 100644 --- a/keel/diff/observe.py +++ b/keel/diff/observe.py @@ -3,7 +3,7 @@ from keel.diff.appliance import appliance_fields, vip_fields from keel.diff.compare import compare -from keel.diff.handshakes import handshake_fields +from keel.diff.handshakes import handshake_fields, mtu_fields from keel.diff.report import Comparison from keel.inspect import ROOT_DEFAULT, inspect_root @@ -18,4 +18,5 @@ def diff_root(declared: dict, root: str = ROOT_DEFAULT) -> Comparison: found = compare(declared, inspect_root(root)) return Comparison(found.root, found.fields + tuple( appliance_fields(declared, root) + vip_fields(declared, root) - + handshake_fields(declared, root == ROOT_DEFAULT))) + + handshake_fields(declared, root == ROOT_DEFAULT) + + mtu_fields(declared, root, root == ROOT_DEFAULT))) diff --git a/keel/mesh/status.py b/keel/mesh/status.py index 3d374d5..4227fb4 100644 --- a/keel/mesh/status.py +++ b/keel/mesh/status.py @@ -16,7 +16,7 @@ from collections.abc import Callable from datetime import datetime -from keel.diff.handshakes import LATER, NOT_HELD +from keel.diff.handshakes import NOT_HELD, STALE, UNREACHABLE from keel.mesh import identity, invites from keel.mesh.token import shown from keel.network import wireguard @@ -44,6 +44,19 @@ def handshake(value: str | None, now: datetime) -> str: return f"handshake {max(ago, 0)} s ago" +def drift_of(value: str | None, now: datetime, iface: str) -> str | None: + """Why a peer is drift: not held, no handshake, or one older than + STALE (keel#140); None for a recent one""" + if value is None: + return f"is not held by {iface}: {NOT_HELD}" + if not value.isdigit() or value == "0": + return f"has no handshake since {iface} came up: {UNREACHABLE}" + ago = int(now.timestamp()) - int(value) + if ago > STALE: + return f"has its last handshake {ago} s ago: {UNREACHABLE}" + return None + + def mesh_identity(root: str) -> str: """Which mesh this node is in: its identity, as every member holds it (keel.mesh.identity)""" @@ -91,10 +104,9 @@ def lines(overlay: dict | None, root: str, now: datetime, if output is not None else "") found.append(f" {key} {', '.join(allowed(peer))} {endpoint}" f"{' ' + state if state else ''}") - if output is not None and not state.startswith("handshake "): - why = (f"has no handshake since {iface} came up: {LATER}" - if live_key in handshakes else - f"is not held by {iface}: {NOT_HELD}") + why = drift_of(handshakes.get(live_key) if live_key else None, + now, iface) if output is not None else None + if why: drift.append(f"drift: peer {key} {why}") found += drift waiting = invites.pending(root, now) diff --git a/keel/network/mtu.py b/keel/network/mtu.py new file mode 100644 index 0000000..e39f41b --- /dev/null +++ b/keel/network/mtu.py @@ -0,0 +1,76 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The overlay's MTU on the live interface (keel#139) + +The file keel writes sets `MTU = 1280` (keel#119), but only `wg-quick +up` reads it: an interface that was up before the line came keeps its +old MTU (1420 on real nodes after the upgrade to 0.23.13), and a change +made live with `wg set` never sets it. `ip link set dev IFACE mtu N` +sets it with no bounce: no peer, no session and no address changes. + +`converge` is what the package's postinst runs (`keel network mtu`), so +an upgrade sets it without a reboot; keel spec apply sets it too +(keel.system.overlay), and keel diff reports a live MTU that is not the +file's (keel.diff.handshakes). +""" + +import glob +import os +from collections.abc import Callable + +from keel.network import wireguard + +Reader = Callable[[tuple[str, ...]], str | None] +Runner = Callable[[tuple[str, ...]], str | None] + + +def mtu_in(text: str) -> int | None: + """`ip link show` output: the number after `mtu`, None without one""" + words = text.split() + for at, word in enumerate(words[:-1]): + if word == "mtu" and words[at + 1].isdigit(): + return int(words[at + 1]) + return None + + +def default_output() -> Reader: + from keel.network import live + return live.output + + +def live_mtu(iface: str, output: Reader | None = None) -> int | None: + """The MTU `iface` has now; None when it is not up or `ip` cannot say""" + text = (output or default_output())(("ip", "link", "show", "dev", iface)) + return None if text is None else mtu_in(text) + + +def set_mtu(iface: str, mtu: int) -> tuple[str, ...]: + return ("ip", "link", "set", "dev", iface, "mtu", str(mtu)) + + +def converge(root: str, output: Reader | None = None, + run: Runner | None = None) -> list[str]: + """Each WireGuard interface up whose MTU is not its file's, set live; + a line for each one set, or that could not be""" + if run is None: + from keel.network import live + run = live.run + found = [] + pattern = os.path.join(root, wireguard.CONF_DIR, "*.conf") + for path in sorted(glob.glob(pattern)): + iface = os.path.basename(path)[:-len(".conf")] + try: + with open(path) as fob: + wanted = wireguard.parse(fob.read()).mtu + except OSError as e: + found.append(f"{iface}: /{wireguard.conf_path(iface)} cannot be" + f" read: {e.strerror or e}") + continue + now = live_mtu(iface, output) + if wanted is None or now is None or now == wanted: + continue + problem = run(set_mtu(iface, wanted)) + found.append(f"{iface}: MTU {now} set to {wanted}, as its file says" + f" (ip link set, no restart)" if problem is None else + f"{iface}: MTU {now} is not its file's {wanted}, and" + f" ip link set failed: {problem}") + return found diff --git a/keel/network/switch.py b/keel/network/switch.py index 0243d8d..ca45ab9 100644 --- a/keel/network/switch.py +++ b/keel/network/switch.py @@ -48,6 +48,8 @@ from keel.inspect.network import slaac_off_in from keel.network import marker, wireguard +from keel.network import mtu as mtu_module +from keel.network.mtu import set_mtu as mtu_cmd WINDOW_UNIT = "keel-network-window" SAFETY_UNIT = "keel-network-window-safety" @@ -194,14 +196,15 @@ def bounce(root: str, iface: str, relative: str, text: str, CHANGED = "changed" -def live_peers(old: str, new: str, iface: str, dump: str) -> ( - list[tuple[str, ...]] | None): +def live_peers(old: str, new: str, iface: str, dump: str, + live_mtu: int | None = None) -> list[tuple[str, ...]] | None: """The `wg set` lines of `live_plan`, or None""" - found = live_plan(old, new, iface, dump) + found = live_plan(old, new, iface, dump, live_mtu) return None if found is None else found[0] -def live_plan(old: str, new: str, iface: str, dump: str) -> ( +def live_plan(old: str, new: str, iface: str, dump: str, + live_mtu: int | None = None) -> ( tuple[list[tuple[str, ...]], bool] | None): """The `wg set` lines that take `iface`, as `wg show IFACE dump` gives it (`dump`), to file `new`, when the files `old` and `new` @@ -221,9 +224,14 @@ def live_plan(old: str, new: str, iface: str, dump: str) -> ( kept. An endpoint the file does not name is the one WireGuard learned, and is kept. - The bounce stays for: a change of [Interface] between the files (its - MTU too, which only wg-quick up sets, keel#119), a - listen port that is not the file's, a line keel does not write, a + The MTU of the new file is set live with `ip link set` when the + interface (`live_mtu`, as `ip link show` gives it) has another one: + an interface up before the MTU line came kept its old MTU, as did + every change made live (keel#139). With no `live_mtu`, a change of + the MTU line is a bounce, as is a file that drops it. + + The bounce stays for: any other change of [Interface] between the + files, a listen port that is not the file's, a line keel does not write, a key named twice, and a change of the addresses of a peer outside every prefix of the interface's addresses, which wg-quick routes and `wg set` does not. @@ -234,7 +242,10 @@ def live_plan(old: str, new: str, iface: str, dump: str) -> ( """ before, after = wireguard.parse(old), wireguard.parse(new) if before.problems or after.problems or before.inline_key or \ - after.inline_key or before.mtu != after.mtu: + after.inline_key: + return None + mtu = mtu_line(before.mtu, after.mtu, live_mtu, iface) + if mtu is False: return None if {k: v for k, v in before.section.items() if k != "peers"} != { k: v for k, v in after.section.items() if k != "peers"}: @@ -257,8 +268,8 @@ def live_plan(old: str, new: str, iface: str, dump: str) -> ( on_link(one, networks) for one in (was.get(key), now.get(key)) if one): return None - commands: list[tuple[str, ...]] = [] - added = True + commands: list[tuple[str, ...]] = [mtu] if mtu else [] + added = not mtu for key in sorted(set(held) | set(now)): if key not in now: commands.append(("wg", "set", iface, "peer", @@ -273,6 +284,17 @@ def live_plan(old: str, new: str, iface: str, dump: str) -> ( return commands, added and bool(commands) +def mtu_line(before: int | None, after: int | None, now: int | None, + iface: str) -> tuple[str, ...] | None | bool: + """The `ip link set` line that gives `iface` the new file's MTU, None + when it has it, False when only the bounce can (keel#139)""" + if after is None: + return None if before is None else False + if now is None: + return None if before == after else False + return None if now == after else mtu_cmd(iface, after) + + def peer_fix(iface: str, want: dict, have: dict | None, old_ips: set[str]) -> tuple[str, ...] | None: """The `wg set` line that gives the peer `have` (None: the interface @@ -323,6 +345,11 @@ def from_dump(text: str | None) -> tuple[int, dict[bytes, dict]] | None: return int(lines[0][2]), peers +def wg_mtu(iface: str) -> int | None: + """The MTU `iface` has now (keel.network.mtu), None when unknown""" + return mtu_module.live_mtu(iface) + + def wg_dump(iface: str) -> str | None: """`wg show IFACE dump`; None when the interface is not up, or wg cannot answer""" @@ -383,7 +410,8 @@ def live_change(root: str, iface: str, relative: str, text: str, return None if not current: return None - found = live_plan(current, text, iface, wg_dump(iface) or "") + found = live_plan(current, text, iface, wg_dump(iface) or "", + wg_mtu(iface)) if found is None: return None commands, added = found diff --git a/keel/system/overlay.py b/keel/system/overlay.py index 08ae013..6323396 100644 --- a/keel/system/overlay.py +++ b/keel/system/overlay.py @@ -24,7 +24,8 @@ up under the window, like a change. """ -from keel.network import marker +from keel.network import marker, wireguard +from keel.network.mtu import set_mtu from keel.network.wireguard import conf_path from keel.system.actions import ( Action, @@ -111,7 +112,9 @@ def kept(state: OverlayState, live: bool) -> tuple[Action, ...]: """The file says what the spec renders; the interface may not A confirmed overlay (its unit enabled) that is down is drift, and is - started again; an overlay that is up may still lack its boot unit. + started again; an overlay that is up may still lack its boot unit, + or have another MTU than its file says, which is set live with no + restart (keel#139). """ note = Note(f"unchanged (/{conf_path(state.iface)} says what the spec" " declares)") @@ -123,10 +126,24 @@ def kept(state: OverlayState, live: bool) -> tuple[Action, ...]: f"drift: {state.iface} is down, though its file says" f" what the spec declares and its change was" f" confirmed; start {unit}"),) + found: tuple[Action, ...] = (note,) + mtu_actions(state) if state.enabled: - return (note,) - return (note, Run(("systemctl", "enable", unit), - f"enable {unit}, so the overlay comes up at boot")) + return found + return found + (Run(("systemctl", "enable", unit), + f"enable {unit}, so the overlay comes up at boot"),) + + +def mtu_actions(state: OverlayState) -> tuple[Action, ...]: + """`ip link set mtu` when the live interface has another MTU than + its file says (keel#139): an interface up before the MTU line came + kept its old one, since only wg-quick up reads the line""" + wanted = wireguard.parse(state.current or "").mtu + if wanted is None or state.live_mtu is None or \ + state.live_mtu == wanted: + return () + return (Run(set_mtu(state.iface, wanted), + f"drift: {state.iface} has MTU {state.live_mtu} and its" + f" file says {wanted}; set it live, with no restart"),) def change(state: OverlayState, live: bool, window: int, diff --git a/keel/system/ovstate.py b/keel/system/ovstate.py index 02313d4..c96fc15 100644 --- a/keel/system/ovstate.py +++ b/keel/system/ovstate.py @@ -7,7 +7,7 @@ from keel.inspect import constants as paths from keel.inspect.tree import Tree from keel.mesh import vip -from keel.network import live, marker, wireguard +from keel.network import live, marker, mtu, wireguard from keel.network.wireguard import MODULE, WANTS from keel.spec.secretstore import secret_file_error @@ -35,6 +35,9 @@ class OverlayState: inline_key: bool = False up: bool | None = None uplink_gateways: tuple[str, ...] = () + # the MTU the interface has now (keel#139), None off the live system + # or when it is not up + live_mtu: int | None = None def overlay_of(doc: dict) -> dict | None: @@ -72,6 +75,7 @@ def observe_overlay(root: str, doc: dict) -> OverlayState | None: inline_key=wireguard.parse(current).inline_key if current else False, up=live.link_up(iface) if is_live else None, uplink_gateways=gateways_of(doc), + live_mtu=mtu.live_mtu(iface) if is_live else None, ) diff --git a/pyproject.toml b/pyproject.toml index 9f0f843..52c0f48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "keel" -version = "0.23.13" +version = "0.23.15" authors = [{name = "KeelLinux maintainers"}] description = "Declarative management of one appliance instance" readme = "README.md" diff --git a/tests/overlay_live_netns.py b/tests/overlay_live_netns.py index 9c847d5..e0a2a50 100644 --- a/tests/overlay_live_netns.py +++ b/tests/overlay_live_netns.py @@ -10,14 +10,18 @@ and gives wg-quick the file under the scratch root (systemd's timers are recorded, not armed): -1. a stray peer set on wg0 by hand, then a change of wg0's file that +1. a stray peer set on wg0 by hand, and its MTU set to 1420 by hand + (an interface up before the file's `MTU = 1280` came, keel#139), + then a change of wg0's file that adds a third peer: no wg-quick, the stray peer is gone (the change is made against what `wg show wg0 dump` holds), the interface is the - same one (its index), and its handshake with wg1 is the one from - before the change; + same one (its index), its handshake with wg1 is the one from + before the change, and its MTU is the file's 1280; 2. the revert of that change (`keel network revert`): the same; 3. a change of [Interface] (the port): wg-quick down, then up, and a - new interface, as before. + new interface, as before; +4. wg0 at 1420 again, then keel.network.mtu.converge (`keel network + mtu`, which the package's postinst runs): back to 1280. The result is one JSON line on standard output, `RESULT {...}`. """ @@ -31,7 +35,7 @@ # keel.network.confirm and keel.inspect import each other: the # package's own order first import keel.commands # noqa: F401, I001 -from keel.network import marker, switch, wireguard +from keel.network import marker, mtu, switch, wireguard CONF = wireguard.conf_path("wg0") THIRD = "9vm/AzCVQQsWt1cU2eyjom4cABkNuodiHL8nDjKohEE=" @@ -101,6 +105,7 @@ def shaken(key: str) -> int: def state(key: str, run: Runner) -> dict: found = {"index": index(), "handshake": handshake(key), + "mtu": mtu.live_mtu("wg0"), "peers": sh("wg", "show", "wg0", "peers").split(), "quick": run.quick()} run.calls.clear() @@ -139,6 +144,10 @@ def main() -> None: added = wireguard.render({**overlay, "peers": overlay["peers"] + [{ "public_key": THIRD, "allowed_ips": ["fd00:1::3/128"]}]}) sh("wg", "set", "wg0", "peer", STRAY, "allowed-ips", "fd00:1::9/128") + # wg0 as an upgrade left it: the file says MTU = 1280, wg0 has 1420 + # (keel#139) + sh("ip", "link", "set", "dev", "wg0", "mtu", "1420") + report["file_mtu"] = wireguard.parse(added).mtu pending = marker.Pending(iface="wg0", path=CONF, window=120, addresses=("fd00:1::1",), kind=marker.OVERLAY) report["change"] = switch.change(root, pending, added, run) @@ -152,6 +161,11 @@ def main() -> None: report["port_change"] = switch.change(root, pending, ported, run) report["ported"] = state(one, run) switch.revert(root, run) + # keel network mtu, which the postinst runs, on wg0 at 1420 again + sh("ip", "link", "set", "dev", "wg0", "mtu", "1420") + report["converge_before"] = mtu.live_mtu("wg0") + report["converge"] = mtu.converge(root) + report["converge_after"] = mtu.live_mtu("wg0") print("RESULT " + json.dumps(report), flush=True) diff --git a/tests/postinst.bats b/tests/postinst.bats new file mode 100644 index 0000000..87814cf --- /dev/null +++ b/tests/postinst.bats @@ -0,0 +1,47 @@ +#!/usr/bin/env bats +# debian/keel.postinst: an upgrade sets the overlay's MTU live (keel#139) +# +# keel is replaced by a script on PATH that records its arguments and +# answers with a chosen exit code, so no test touches an interface. The +# #DEBHELPER# token stays a comment, as in the source package. + +setup() { + POSTINST="$BATS_TEST_DIRNAME/../debian/keel.postinst" + TMP="$(mktemp -d)" + mkdir -p "$TMP/bin" + export PATH="$TMP/bin:$PATH" +} + +teardown() { + rm -rf "$TMP" +} + +fake_keel() { + cat > "$TMP/bin/keel" <> "$TMP/keel.argv" +exit $1 +EOF2 + chmod +x "$TMP/bin/keel" +} + +@test "configure runs keel network mtu" { + fake_keel 0 + run sh "$POSTINST" configure 0.23.13 + [ "$status" -eq 0 ] + [ "$(cat "$TMP/keel.argv")" = "network mtu" ] +} + +@test "a failure is said and never fails the upgrade" { + fake_keel 16 + run sh "$POSTINST" configure 0.23.13 + [ "$status" -eq 0 ] + [[ "$output" == *"keel spec apply --system sets it"* ]] +} + +@test "other actions run nothing" { + fake_keel 0 + run sh "$POSTINST" abort-upgrade + [ "$status" -eq 0 ] + [ ! -e "$TMP/keel.argv" ] +} diff --git a/tests/test_diff_handshakes.py b/tests/test_diff_handshakes.py index 4054194..d2c75a5 100644 --- a/tests/test_diff_handshakes.py +++ b/tests/test_diff_handshakes.py @@ -24,7 +24,8 @@ def wg(argv): assert argv == ("wg", "show", "wg0", "latest-handshakes") - return f"{ONE}\t{int(NOW.timestamp()) - 40}\n{TWO}\t0\nnoise\n" + return (f"{ONE}\t{int(NOW.timestamp()) - 40}\n{TWO}\t0\n" + f"{THREE}\t{int(NOW.timestamp()) - 29057}\nnoise\n") class TestHandshakes(unittest.TestCase): @@ -34,8 +35,21 @@ def test_each_peer_named_with_its_handshake(self): self.assertEqual(found[ONE].status, SAME) self.assertIn("a handshake (40 s ago)", found[ONE].line()) self.assertEqual(found[TWO].status, DRIFT) - self.assertIn("observed none; no handshake since wg0 came up", + self.assertIn("observed none; no handshake since wg0 came up: the" + " member does not answer: it is unreachable", found[TWO].line()) + # keel#140: 8 h old is drift, with its age + self.assertEqual(found[THREE].status, DRIFT) + self.assertIn("declared a handshake within 180 s, observed a" + " handshake (29057 s ago); older than 180 s: the" + " member does not answer", found[THREE].line()) + self.assertNotIn("keel mesh sync on that member", found[TWO].line()) + + def test_a_peer_the_interface_does_not_hold(self): + def wg(argv): + return f"{ONE}\t{int(NOW.timestamp())}\n" + found = {one.field.split(".")[4]: one + for one in handshakes.handshake_fields(SPEC, True, wg, NOW)} self.assertEqual(found[THREE].status, DRIFT) self.assertIn("wg0 does not hold this peer", found[THREE].line()) self.assertEqual( @@ -57,5 +71,49 @@ def test_the_live_reader_is_wg(self): self.assertEqual(len(handshakes.handshake_fields(SPEC, True)), 3) +class TestMtu(unittest.TestCase): + """keel#139: the live MTU against the file's""" + + def setUp(self): + import os + import shutil + import tempfile + self.root = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.root) + os.makedirs(os.path.join(self.root, "etc/wireguard")) + self.conf = os.path.join(self.root, "etc/wireguard/wg0.conf") + + def write(self, text): + with open(self.conf, "w") as fob: + fob.write(text) + + def fields(self, mtu=None, live=True): + def ip(argv): + assert argv == ("ip", "link", "show", "dev", "wg0") + return None if mtu is None else ( + f"9: wg0: mtu {mtu} qdisc" + " noqueue state UNKNOWN\n") + return handshakes.mtu_fields(SPEC, self.root, live, ip) + + def test_a_live_mtu_that_is_not_the_file_s_is_drift(self): + self.write("[Interface]\nMTU = 1280\n") + [found] = self.fields(1420) + self.assertEqual(found.status, DRIFT) + self.assertIn("declared 1280, observed 1420", found.line()) + self.assertIn("keel network mtu", found.line()) + [found] = self.fields(1280) + self.assertEqual(found.status, SAME) + [found] = self.fields(None) + self.assertEqual(found.status, UNKNOWN) + + def test_nothing_without_a_line_a_file_or_the_live_system(self): + self.assertEqual(self.fields(1420), []) + self.write("[Interface]\nListenPort = 51820\n") + self.assertEqual(self.fields(1420), []) + self.write("[Interface]\nMTU = 1280\n") + self.assertEqual(self.fields(1420, live=False), []) + self.assertEqual(handshakes.mtu_fields({}, self.root, True), []) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_hook_bats.py b/tests/test_hook_bats.py index 4d3909e..b8475db 100644 --- a/tests/test_hook_bats.py +++ b/tests/test_hook_bats.py @@ -19,6 +19,8 @@ HERE = dirname(abspath(__file__)) SUITE = join(HERE, "hook.bats") +# the package's postinst (keel#139) +POSTINST_SUITE = join(HERE, "postinst.bats") HOOK = join(dirname(HERE), "firstboot.d", "10keel-system") @@ -39,3 +41,15 @@ def test_bats_reports_every_hook_test_passing(self): ) self.assertEqual(done.returncode, 0, done.stdout + done.stderr) self.assertIn("ok 1 ", done.stdout) + + def test_bats_reports_every_postinst_test_passing(self): + bats = shutil.which("bats") + if bats is None: + if os.environ.get("CI"): + self.fail("bats is not installed in CI: the postinst suite" + " would not run, see .github/workflows/tests.yml") + self.skipTest("bats is not installed") + done = subprocess.run([bats, POSTINST_SUITE], capture_output=True, + text=True, check=False) + self.assertEqual(done.returncode, 0, done.stdout + done.stderr) + self.assertIn("ok 3 ", done.stdout) diff --git a/tests/test_mesh_create_status.py b/tests/test_mesh_create_status.py index d1f3648..817fdbf 100644 --- a/tests/test_mesh_create_status.py +++ b/tests/test_mesh_create_status.py @@ -144,10 +144,11 @@ def test_the_peers_their_handshakes_and_the_invites(self): self.assertEqual(found[4], f" {OTHER} fd00:6b65:1::7/128 no" " endpoint no handshake yet") # keel#117: a peer with no handshake is drift, named + # keel#140: a member that does not answer is unreachable self.assertEqual(found[5], f"drift: peer {OTHER} has no handshake" - " since wg0 came up: it comes once that member has" - " this node as a peer too (keel mesh sync on that" - " member)") + " since wg0 came up: the member does not answer: it" + " is unreachable (down, cut off, or without this" + " node as a peer)") self.assertEqual(found[6], "pending invites: 1") self.assertIn(f" {INVITE} fd00:6b65:1::3/64 TCP 51820 until" " 2026-10-03 13:00:00 UTC pending", found[7]) @@ -156,6 +157,22 @@ def test_the_peers_their_handshakes_and_the_invites(self): status.lines(self.overlay, self.root, NOW, self.wg)[-1]) + def test_a_handshake_older_than_three_minutes_is_drift(self): + """keel#140: an 8 h old handshake was said to be same""" + old = int(NOW.timestamp()) - 29057 + + def wg(argv): + if argv[-1] == "latest-handshakes": + return f"{JOINER}\t{old}\n{OTHER}\t{old + 29000}\n" + return self.wg(argv) + found = status.lines(self.overlay, self.root, NOW, wg) + self.assertIn(f"drift: peer {JOINER} has its last handshake 29057 s" + " ago: the member does not answer: it is unreachable" + " (down, cut off, or without this node as a peer)", + found) + self.assertFalse(any(OTHER in one and one.startswith("drift") + for one in found)) + def test_no_secret_is_asked_for(self): asked = [] status.lines(self.overlay, self.root, NOW, diff --git a/tests/test_network_mtu.py b/tests/test_network_mtu.py new file mode 100644 index 0000000..b669942 --- /dev/null +++ b/tests/test_network_mtu.py @@ -0,0 +1,158 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""The overlay's MTU set live (keel#139) + +After the upgrade to 0.23.13, wg0.conf had `MTU = 1280` and the live wg0 +kept 1420: only `wg-quick up` reads the line, apply planned nothing and +diff said nothing. `ip link set dev wg0 mtu 1280` sets it with no +restart: in the live change of the peers (keel.network.switch), in +apply when the file is kept (keel.system.overlay), and in `keel network +mtu`, which the package's postinst runs. `ip` is replaced at the +boundary; tests/test_network_overlay_live_netns.py gives it the real one. +""" + +import os +import shutil +import tempfile +import unittest +from types import SimpleNamespace +from unittest import mock + +from helpers import spec # noqa: F401 + +import keel.commands # noqa: F401, I001 +from test_network_overlay_live import BEFORE, INTERFACE, dump, peer + +from keel import commands, exits +from keel.network import mtu, switch + +LINK = ("9: wg0: mtu {} qdisc noqueue" + " state UNKNOWN mode DEFAULT group default qlen 1000\n") +WITH = INTERFACE + "MTU = 1280\n" + + +def ip(values): + """`ip link show dev IFACE` for interfaces of these MTUs""" + def answer(argv): + found = values.get(argv[-1]) + return None if found is None else LINK.format(found) + return answer + + +class TestRead(unittest.TestCase): + def test_the_mtu_of_ip_link_show(self): + self.assertEqual(mtu.mtu_in(LINK.format(1420)), 1420) + self.assertIsNone(mtu.mtu_in("9: wg0: qdisc noqueue")) + self.assertIsNone(mtu.mtu_in("mtu")) + self.assertEqual(mtu.live_mtu("wg0", ip({"wg0": 1280})), 1280) + self.assertIsNone(mtu.live_mtu("wg0", ip({}))) + + def test_the_live_reader_is_ip(self): + with mock.patch("keel.network.live.output", + return_value=LINK.format(1420)) as output: + self.assertEqual(mtu.live_mtu("wg0"), 1420) + output.assert_called_once_with(("ip", "link", "show", "dev", "wg0")) + with mock.patch.object(mtu, "live_mtu", return_value=7): + self.assertEqual(switch.wg_mtu("wg0"), 7) + + +class TestLivePlan(unittest.TestCase): + def test_a_live_mtu_that_is_not_the_file_s_is_set_live(self): + after = WITH + peer( + "nb9/izIukqWXM7gnBpe7hki4jKZZChWOW1wEfONn82E=", "fd00:1::2/128", + "[2001:db8::2]:51820") + # the old file had the line too, and wg0 never took it + self.assertEqual(switch.live_plan(after, after, "wg0", dump(after), + 1420), + ([mtu.set_mtu("wg0", 1280)], False)) + # the line comes now, with wg0 up at 1420: no bounce + self.assertEqual(switch.live_plan(BEFORE, after, "wg0", + dump(BEFORE), 1420), + ([("ip", "link", "set", "dev", "wg0", "mtu", + "1280")], False)) + self.assertEqual(switch.live_plan(after, after, "wg0", dump(after), + 1280), ([], False)) + + def test_what_is_still_a_bounce(self): + # the line comes and wg0's MTU cannot be read; the line goes + self.assertIsNone(switch.live_plan(BEFORE, WITH, "wg0", + dump(BEFORE), None)) + self.assertIsNone(switch.live_plan(WITH, BEFORE, "wg0", + dump(WITH), 1280)) + + +class TestConverge(unittest.TestCase): + def setUp(self): + self.root = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.root) + os.makedirs(os.path.join(self.root, "etc/wireguard")) + self.calls = [] + + def write(self, name, text): + with open(os.path.join(self.root, "etc/wireguard", name), "w") as fob: + fob.write(text) + + def run_(self, problem=None): + def run(argv): + self.calls.append(argv) + return problem + return run + + def test_each_interface_up_gets_its_file_s_mtu(self): + self.write("wg0.conf", WITH) + self.write("wg1.conf", WITH) + self.write("wg2.conf", INTERFACE) + self.write("wg3.conf", WITH) + found = mtu.converge(self.root, ip({"wg0": 1420, "wg1": 1280, + "wg2": 1420}), self.run_()) + self.assertEqual(self.calls, [mtu.set_mtu("wg0", 1280)]) + self.assertEqual(found, ["wg0: MTU 1420 set to 1280, as its file" + " says (ip link set, no restart)"]) + + def test_a_failure_and_a_file_that_cannot_be_read_are_said(self): + self.write("wg0.conf", WITH) + found = mtu.converge(self.root, ip({"wg0": 1420}), + self.run_("ip exited 2: busy")) + self.assertEqual(found, ["wg0: MTU 1420 is not its file's 1280, and" + " ip link set failed: ip exited 2: busy"]) + with mock.patch("builtins.open", side_effect=OSError(13, "denied")): + found = mtu.converge(self.root, ip({"wg0": 1420}), self.run_()) + self.assertIn("wg0: /etc/wireguard/wg0.conf cannot be read", found[0]) + + def test_the_live_runner_is_live_run(self): + self.write("wg0.conf", WITH) + with mock.patch("keel.network.live.run", return_value=None) as run: + mtu.converge(self.root, ip({"wg0": 1420})) + run.assert_called_once_with(mtu.set_mtu("wg0", 1280)) + + +class TestCommand(unittest.TestCase): + def args(self): + return SimpleNamespace(root="/tmp/keel-not-live") + + def test_prints_what_it_set_and_fails_on_a_failure(self): + with mock.patch.object(mtu, "converge", return_value=[ + "wg0: MTU 1420 set to 1280"]): + self.assertEqual(commands.network_mtu(self.args()), exits.OK) + with mock.patch.object(mtu, "converge", return_value=[ + "wg0: ... ip link set failed: x", "wg1: MTU 1 set to 2"]), \ + mock.patch.object(commands, "error") as error: + self.assertEqual(commands.network_mtu(self.args()), + exits.APPLY_FAILED) + error.assert_called_once() + + def test_the_live_system_needs_root(self): + with mock.patch("os.geteuid", return_value=1000): + self.assertEqual(commands.network_mtu(SimpleNamespace(root="/")), + exits.APPLY_NEEDS_ROOT) + + def test_the_cli_names_it(self): + from keel import cli + with mock.patch.object(commands, "network_mtu", + return_value=0) as handler: + self.assertEqual(cli.main(["network", "mtu", "--root", + self.args().root]), 0) + handler.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_network_overlay_live_netns.py b/tests/test_network_overlay_live_netns.py index ec77733..f8f178e 100644 --- a/tests/test_network_overlay_live_netns.py +++ b/tests/test_network_overlay_live_netns.py @@ -61,6 +61,19 @@ def test_a_peer_added_keeps_the_interface_and_its_sessions(self): self.assertEqual(added["index"], first["index"]) self.assertEqual(added["handshake"], first["handshake"]) + def test_the_file_s_mtu_is_set_live(self): + """keel#139: wg0 at 1420 under a file of MTU = 1280""" + self.assertEqual(self.found["file_mtu"], 1280) + self.assertEqual(self.found["added"]["mtu"], 1280) + self.assertEqual(self.found["added"]["quick"], []) + + def test_keel_network_mtu_sets_it_with_no_restart(self): + self.assertEqual(self.found["converge_before"], 1420) + self.assertEqual(self.found["converge"], [ + "wg0: MTU 1420 set to 1280, as its file says (ip link set, no" + " restart)"]) + self.assertEqual(self.found["converge_after"], 1280) + def test_its_revert_keeps_them_too(self): first, reverted = self.found["first"], self.found["reverted"] worked, line = self.found["revert"] diff --git a/tests/test_system_overlay.py b/tests/test_system_overlay.py index d354037..d70dba9 100644 --- a/tests/test_system_overlay.py +++ b/tests/test_system_overlay.py @@ -219,6 +219,22 @@ def test_an_overlay_that_is_up_is_left_or_enabled(self): self.assertEqual(found[1].argv, ("systemctl", "enable", "wg-quick@wg0")) + def test_a_live_mtu_that_is_not_the_file_s_is_set_live(self): + """keel#139: the file kept, wg0 still at 1420 after the upgrade""" + found = actions(plan_overlay(state(current=RENDERED, enabled=True, + up=True, live_mtu=1420), + True, ALL, 120)) + self.assertEqual(found[1].argv, ("ip", "link", "set", "dev", "wg0", + "mtu", "1280")) + self.assertIn("drift: wg0 has MTU 1420 and its file says 1280; set" + " it live, with no restart", found[1].describe()) + for kept in (1280, None): + found = actions(plan_overlay(state(current=RENDERED, + enabled=True, up=True, + live_mtu=kept), + True, ALL, 120)) + self.assertEqual([type(one) for one in found], [Note]) + def test_an_overlay_never_confirmed_here_comes_up_under_the_window(self): found = actions(plan_overlay(state(current=RENDERED, up=False), True, ALL, 120))