Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -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 <[email protected]> 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
Expand Down
15 changes: 15 additions & 0 deletions debian/keel.postinst
Original file line number Diff line number Diff line change
@@ -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
9 changes: 8 additions & 1 deletion docs/apply.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <interface> 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
Expand Down
3 changes: 2 additions & 1 deletion docs/diff.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<key>.handshake` | a handshake | on the live system, `wg show <interface> 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.<key>.handshake` | a handshake within 180 s | on the live system, `wg show <interface> 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
Expand Down
4 changes: 3 additions & 1 deletion docs/mesh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<key>.handshake` as drift. A
has `network.overlay.wireguard.peers.<key>.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
Expand Down
2 changes: 1 addition & 1 deletion keel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions keel/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions keel/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
62 changes: 53 additions & 9 deletions keel/diff/handshakes.py
Original file line number Diff line number Diff line change
@@ -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.<key>.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 <interface> 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
<interface> 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

Expand All @@ -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")
Expand Down Expand Up @@ -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")]
5 changes: 3 additions & 2 deletions keel/diff/observe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)))
22 changes: 17 additions & 5 deletions keel/mesh/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)"""
Expand Down Expand Up @@ -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)
Expand Down
76 changes: 76 additions & 0 deletions keel/network/mtu.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading