From 5b14333456d8e69e22ce69fd2b58678233c06b49 Mon Sep 17 00:00:00 2001 From: navigator Date: Sat, 3 Oct 2026 12:18:50 +0000 Subject: [PATCH 1/4] fix: the overlay screen keeps one place in the Instance menu It moved from Instance > Advanced to the Instance menu itself once the spec had an overlay, and Advanced went away with it: on keel-web-2 the maintainer took the screen for gone (2026-10-03). Its place now follows the installation mode alone: behind Advanced in a simple installation, configured or not; in the Instance menu in the cloud modes, where no other entry is behind Advanced, so no empty Advanced is shown. --- docs/Instance.rst | 6 ++--- keelfirstboot.py | 3 ++- keelmenu.py | 16 ++++++-------- tests/test_instance_menu.py | 29 ++++++++++++++++++++++++ tests/test_keelmenu.py | 44 ++++++++++++++++++++++--------------- 5 files changed, 67 insertions(+), 31 deletions(-) diff --git a/docs/Instance.rst b/docs/Instance.rst index 59b1d79..fc9b7ee 100644 --- a/docs/Instance.rst +++ b/docs/Instance.rst @@ -42,9 +42,9 @@ spec. | | and the first boot asks them no role | +-------------------+--------------------------------------------------------+ | Overlay network | the ``wireguard`` overlay is in the chain. In the menu | -| | in ``cloud_simple`` and ``cloud_advanced``, or once | -| | the spec enables or configures the overlay; otherwise | -| | behind **Advanced** | +| | in ``cloud_simple`` and ``cloud_advanced``; otherwise | +| | behind **Advanced**, configured or not, so it never | +| | moves once an address or a peer is applied | +-------------------+--------------------------------------------------------+ | Keel Cloud | only once Keel Cloud exists: ``/etc/keel/cloud- | | | endpoint`` holds the service's endpoint. Hidden by | diff --git a/keelfirstboot.py b/keelfirstboot.py index d3e8f86..60e0cee 100644 --- a/keelfirstboot.py +++ b/keelfirstboot.py @@ -488,7 +488,8 @@ def finish(console, path: str, role: str) -> None: def later(console, role: str, overlay_done: bool) -> None: lines = [LATER_TEXT] if not overlay_done: - # behind Advanced in a simple installation, until it is in use + # behind Advanced in a simple installation, in the cloud modes + # in the Instance menu itself lines.append(f" {keelmenu.overlay_where()}: this node's address" " and peers") lines.append(f" {MODE_WHERE[role]}") diff --git a/keelmenu.py b/keelmenu.py index 1d31712..25927b1 100644 --- a/keelmenu.py +++ b/keelmenu.py @@ -23,8 +23,11 @@ PostgreSQL it notes that and changes nothing, so their Database mode offers Standalone only rather than roles nothing would make. - Instance/Overlay_network.py: the wireguard overlay in the chain. It is - in the Instance menu in the cloud modes, or once this node uses the - overlay; in a simple installation it is behind Advanced. + in the Instance menu in the cloud modes, where joining nodes is the + point; in a simple installation it is behind Advanced, configured or + not. Its place follows the installation mode alone, so it does not + move when the first address or peer is applied (it did, and Advanced + went with it: the maintainer took it for gone, 2026-10-03). - Instance/Keel_Cloud.py: hidden until Keel Cloud exists, which is when CLOUD_ENDPOINT holds the endpoint of the service: one https URL with a host (a bracketed IPv6 literal allowed), in a regular file root owns @@ -46,7 +49,6 @@ from urllib.parse import urlsplit import keelcli -import wgcli log = logging.getLogger("keelmenu") @@ -63,7 +65,6 @@ # Those whose primary and replica keel applies, not only validates REPLICATING_ENGINES = frozenset({"mariadb"}) WIREGUARD = "wireguard" -ENABLED = "enabled" OK = "ok" SHOW = "show" @@ -93,7 +94,6 @@ class Machine: chain: Chain | None mode: str - uses_overlay: bool server_engine: str = "" has_server: bool = False @@ -138,10 +138,8 @@ def machine() -> Machine: name = _section(document, "appliance").get("name") chain = chain_of(name) if name else None mode = str(_section(document, "installation").get("mode") or "") - uses = (_section(document, "overlays").get(WIREGUARD) == ENABLED - or bool(wgcli.overlay_of(document))) server = keelcli.server_of(document) - return Machine(chain, mode, uses, str(server.get("engine") or ""), + return Machine(chain, mode, str(server.get("engine") or ""), bool(server)) @@ -213,7 +211,7 @@ def _replication(found: Machine) -> str: def _overlay(found: Machine) -> str: if found.chain is not None and WIREGUARD not in found.chain.overlays: return HIDE - if found.mode in CLOUD_MODES or found.uses_overlay: + if found.mode in CLOUD_MODES: return SHOW return ADVANCED diff --git a/tests/test_instance_menu.py b/tests/test_instance_menu.py index 3f3129a..2163d4d 100644 --- a/tests/test_instance_menu.py +++ b/tests/test_instance_menu.py @@ -347,6 +347,35 @@ def test_behind_advanced_in_a_simple_installation_is_the_overlay( assert tags(console, 1) == ["Overlay network"] + @pytest.mark.parametrize("appliance", ["web", "core"]) + def test_a_configured_overlay_stays_behind_advanced(self, instance, + appliance): + # the same two menus before and after the first peer, so the + # screen is where the operator found it (keel-web-2, 2026-10-03) + overlay = {"overlay": {"wireguard": { + "address": "fd00:6b65:1::1/64", "peers": [{ + "public_key": "0niNkgzhpbKmTSrWCzukb6jaYogKZkGhW+xWlh52Mh8=", + "allowed_ips": ["fd00:6b65:1::2/128"]}]}}} + menu, console = instance( + appliance, menus=[("ok", "Advanced"), ("ok", "Overlay network")], + overlays={"wireguard": "enabled"}, network=overlay) + + assert menu.run() == str(INSTANCE_DIR / "Overlay_network.py") + + assert sorted(tags(console)) == sorted(PLAIN + ["Advanced"]) + assert tags(console, 1) == ["Overlay network"] + + @pytest.mark.parametrize("mode", ["cloud_simple", "cloud_advanced"]) + def test_a_cloud_mode_with_an_overlay_has_no_empty_advanced( + self, instance, mode + ): + menu, console = instance("web", mode, + overlays={"wireguard": "enabled"}) + + menu.run() + + assert sorted(tags(console)) == sorted(PLAIN + ["Overlay network"]) + def test_back_from_advanced_reopens_the_instance_menu(self, instance): menu, console = instance( "core", menus=[("ok", "Advanced"), ("cancel", "")]) diff --git a/tests/test_keelmenu.py b/tests/test_keelmenu.py index 348623c..53c7e82 100644 --- a/tests/test_keelmenu.py +++ b/tests/test_keelmenu.py @@ -93,7 +93,6 @@ def test_the_spec_names_the_appliance_and_the_mode(self, chains, spec): assert found.chain.overlays == frozenset(WEB) assert found.mode == "cloud_simple" - assert found.uses_overlay is False def test_a_spec_that_names_no_appliance_leaves_the_chain_unknown( self, chains, spec @@ -128,21 +127,11 @@ def test_sections_that_are_not_mappings_count_as_absent( assert found.chain is None assert found.mode == "" - def test_an_enabled_wireguard_overlay_is_in_use(self, chains, spec): - spec("web", overlays={"wireguard": "enabled"}) - assert keelmenu.machine().uses_overlay is True - def test_a_declared_overlay_address_is_in_use(self, chains, spec): - spec("web", network={"overlay": {"wireguard": { - "address": "fd00:6b65:1::1/64"}}}) - - assert keelmenu.machine().uses_overlay is True - - -def machine(name, mode="simple", uses_overlay=False, chains=None): +def machine(name, mode="simple", chains=None): chain = keelmenu.chain_of(name) if name else None - return keelmenu.Machine(chain, mode, uses_overlay) + return keelmenu.Machine(chain, mode) class TestDatabaseMode: @@ -175,7 +164,7 @@ def test_the_engines_are_one_set(self): def test_a_declared_server_shows_it_whatever_the_chain(self, chains): # a description that holds database.server is a server to # configure, even where the manifests name no engine - found = keelmenu.Machine(keelmenu.chain_of("web"), "simple", False, + found = keelmenu.Machine(keelmenu.chain_of("web"), "simple", server_engine="mariadb", has_server=True) assert keelmenu.place(DATABASE, found) == keelmenu.SHOW @@ -200,7 +189,7 @@ def test_an_engine_keel_does_not_replicate_has_no_cloud( assert keelmenu.place(REPLICATION, machine("db")) == keelmenu.HIDE def test_a_declared_mariadb_server_replicates(self, chains): - found = keelmenu.Machine(keelmenu.chain_of("web"), "simple", False, + found = keelmenu.Machine(keelmenu.chain_of("web"), "simple", server_engine="mariadb", has_server=True) assert keelmenu.place(REPLICATION, found) == keelmenu.SHOW @@ -229,10 +218,29 @@ def test_behind_advanced_in_a_simple_installation(self, chains, mode): assert keelmenu.place(OVERLAY, machine("web", mode)) == ( keelmenu.ADVANCED) - def test_in_the_menu_once_this_node_uses_the_overlay(self, chains): - found = machine("web", "simple", uses_overlay=True) + @pytest.mark.parametrize("configured", [ + {"overlays": {"wireguard": "enabled"}}, + {"network": {"overlay": {"wireguard": { + "address": "fd00:6b65:1::1/64"}}}}, + ]) + def test_it_stays_behind_advanced_once_configured(self, chains, spec, + configured): + # It moved to the Instance menu once the spec had an overlay, and + # Advanced went away with it: on keel-web-2 the maintainer took + # it for gone (2026-10-03). One place for the life of the node. + spec("web", **configured) + + assert keelmenu.place(OVERLAY, keelmenu.machine()) == ( + keelmenu.ADVANCED) + assert keelmenu.overlay_where() == "Advanced > Overlay network" + + @pytest.mark.parametrize("mode", ["cloud_simple", "cloud_advanced"]) + def test_a_cloud_mode_keeps_it_in_the_menu_once_configured( + self, chains, spec, mode + ): + spec("web", mode, overlays={"wireguard": "enabled"}) - assert keelmenu.place(OVERLAY, found) == keelmenu.SHOW + assert keelmenu.place(OVERLAY, keelmenu.machine()) == keelmenu.SHOW def test_hidden_where_the_chain_carries_no_wireguard(self, chains): chains["bare"] = resolved([overlay("installer")]) From 2c9afc76e439d78fe41cd7eaecda48c84c0b2950 Mon Sep 17 00:00:00 2001 From: navigator Date: Sat, 3 Oct 2026 12:20:28 +0000 Subject: [PATCH 2/4] fix: the overlay screen refuses this node as its own peer On keel-web-2 the maintainer entered the node's own public key, shown at the top of the same screen, as a peer (2026-10-03). Add peer now refuses this node's own public key and a peer mesh address equal to one of this node's, and asks before adding a peer outside this node's overlay prefix (fdbc:cc61:347f::1/64 against fd11:a58a:88ef::2): a peer is routed as one host, so it still works, but it is most often a node of another set or a typo. A refusal or a No goes back to the form with what was typed. --- keelfirstboot.py | 2 +- tests/test_first_boot.py | 7 +- tests/test_overlay_screen.py | 130 +++++++++++++++++++++++++++++++++++ wgcli.py | 69 +++++++++++++++++++ wgscreen.py | 53 ++++++++++---- 5 files changed, 244 insertions(+), 17 deletions(-) diff --git a/keelfirstboot.py b/keelfirstboot.py index 60e0cee..5a414ba 100644 --- a/keelfirstboot.py +++ b/keelfirstboot.py @@ -450,7 +450,7 @@ def overlay(console, path: str, role: str) -> str | None: return None if choice == CONTINUE: return key - wgscreen.ACTIONS[choice](console, path, document, wireguard) + wgscreen.ACTIONS[choice](console, path, document, wireguard, key) def overlay_choices(role: str, wireguard: dict) -> list[tuple[str, str]]: diff --git a/tests/test_first_boot.py b/tests/test_first_boot.py index 3a61c83..2f44128 100644 --- a/tests/test_first_boot.py +++ b/tests/test_first_boot.py @@ -697,12 +697,15 @@ def actions(self, monkeypatch, spec): """The overlay screen's own actions, each writing what it would""" ran = [] - def set_address(console, path, document, wireguard): + def set_address(console, path, document, wireguard, key): + # this node's own key, which Add peer refuses as a peer + assert key == THIS_KEY.strip() ran.append("address") spec({"version": 1, "network": {"overlay": { "wireguard": OVERLAY}}}) - def add_peer(console, path, document, wireguard): + def add_peer(console, path, document, wireguard, key): + assert key == THIS_KEY.strip() ran.append("peer") spec({"version": 1, "network": {"overlay": {"wireguard": { **OVERLAY, "peers": [PEER]}}}}) diff --git a/tests/test_overlay_screen.py b/tests/test_overlay_screen.py index 5692ca0..e493817 100644 --- a/tests/test_overlay_screen.py +++ b/tests/test_overlay_screen.py @@ -327,6 +327,71 @@ def test_apply_moves_the_overlay_and_never_the_uplink(self): assert wgcli.APPLY[:3] == ["spec", "apply", "--system-only"] +class TestNotItself: + """keel-web-2, 2026-10-03: the maintainer typed the node's own public + key, shown at the top of the same screen, as a peer""" + + NODE = {"address": "fdbc:cc61:347f::1/64"} + + def test_its_own_key_is_refused(self): + problem = wgcli.peer_problem(THIS_KEY, self.NODE, f" {THIS_KEY} ", + "fdbc:cc61:347f::2") + assert "this node's own public key" in problem + assert "OTHER node" in problem + + @pytest.mark.parametrize("typed", [ + "fdbc:cc61:347f::1", "fdbc:cc61:347f::1/64", + " FDBC:CC61:347F:0:0:0:0:1/128 "]) + def test_its_own_address_is_refused(self, typed): + problem = wgcli.peer_problem(THIS_KEY, self.NODE, PEER_KEY, typed) + assert "fdbc:cc61:347f::1 is this node's own mesh address" in problem + + def test_its_own_ipv4_address_is_refused(self): + node = {**self.NODE, "ipv4_address": "10.66.0.1/24"} + problem = wgcli.peer_problem(THIS_KEY, node, PEER_KEY, "10.66.0.1") + assert "10.66.0.1 is this node's own mesh address" in problem + + @pytest.mark.parametrize("node, typed", [ + (NODE, "fdbc:cc61:347f::2"), + (NODE, "node2"), + ({}, "fdbc:cc61:347f::1"), + ({"address": "not an address"}, "fdbc:cc61:347f::1"), + ]) + def test_another_node_or_what_keel_judges_passes(self, node, typed): + # what is not an address is keel's to refuse, with its own words + assert wgcli.peer_problem(THIS_KEY, node, PEER_KEY, typed) == "" + + def test_a_peer_outside_the_prefix_is_asked_about(self): + question = wgcli.outside_prefix(self.NODE, "fd11:a58a:88ef::2") + assert "fd11:a58a:88ef::2 is outside this node's mesh prefix" in ( + question) + assert "fdbc:cc61:347f::/64" in question + assert "still works" in question + assert question.endswith("Add it anyway?") + + @pytest.mark.parametrize("node, typed", [ + (NODE, "fdbc:cc61:347f::2"), + (NODE, "fdbc:cc61:347f::2/64"), + ({**NODE, "ipv4_address": "10.66.0.1/24"}, "10.66.0.2"), + (NODE, "node2"), + ({}, "fd11:a58a:88ef::2"), + ({"address": "junk"}, "fd11:a58a:88ef::2"), + ]) + def test_inside_the_prefix_or_unknown_asks_nothing(self, node, typed): + assert wgcli.outside_prefix(node, typed) == "" + + def test_the_texts_fit_an_80_column_console(self): + import keelbanner + + for text in ( + wgcli.peer_problem(THIS_KEY, self.NODE, THIS_KEY, ""), + wgcli.peer_problem(THIS_KEY, self.NODE, PEER_KEY, + "fdbc:cc61:347f::1"), + wgcli.outside_prefix(self.NODE, "fd11:a58a:88ef:1234::2"), + ): + assert keelbanner.text_rows(text, 72) + 5 <= 20 + + class TestScreen: def test_the_entry_hands_its_console_to_the_screen(self, keel, spec): spec(BASE) @@ -562,6 +627,71 @@ def test_removing_a_peer_asks_first(self, keel, spec): assert read(path)["network"]["overlay"]["wireguard"]["peers"] == [] assert commands(keel).count("spec apply --system-only") == 1 + def test_its_own_key_is_refused_and_the_form_kept(self, keel, spec): + path = spec(wgcli.with_overlay(BASE, OVERLAY)) + before = path.read_text() + typed = [THIS_KEY, "fd00:6b65:1::3", "[2001:db8::30]:51820", "25"] + console = FakeConsole( + menus=[("ok", wgscreen.ADD), ("cancel", "")], + forms=[("ok", typed), ("cancel", [])], + ) + + wgscreen.run(console) + + assert path.read_text() == before + assert "spec validate" not in " ".join(commands(keel)) + texts = [call[2] for call in console.calls if call[0] == "msgbox"] + assert "this node's own public key" in texts[0] + forms = [call for call in console.calls if call[0] == "form"] + assert [field[3] for field in forms[1][2]] == typed + + def test_its_own_address_is_refused(self, keel, spec): + path = spec(wgcli.with_overlay(BASE, OVERLAY)) + before = path.read_text() + console = FakeConsole( + menus=[("ok", wgscreen.ADD), ("cancel", "")], + forms=[("ok", [OTHER_KEY, "fd00:6b65:1::1", "", ""]), + ("cancel", [])], + ) + + wgscreen.run(console) + + assert path.read_text() == before + texts = [call[2] for call in console.calls if call[0] == "msgbox"] + assert "this node's own mesh address" in texts[0] + + def test_outside_the_prefix_no_goes_back_to_the_form(self, keel, spec): + path = spec(wgcli.with_overlay(BASE, OVERLAY)) + before = path.read_text() + console = FakeConsole( + menus=[("ok", wgscreen.ADD), ("cancel", "")], + forms=[("ok", [OTHER_KEY, "fd11:a58a:88ef::2", "", ""]), + ("cancel", [])], + yesno=["cancel"], + ) + + wgscreen.run(console) + + assert path.read_text() == before + asked = [call[1] for call in console.calls if call[0] == "yesno"] + assert "outside this node's mesh prefix" in asked[0] + forms = [call for call in console.calls if call[0] == "form"] + assert forms[1][2][1][3] == "fd11:a58a:88ef::2" + + def test_outside_the_prefix_yes_adds_it(self, keel, spec): + path = spec(wgcli.with_overlay(BASE, OVERLAY)) + console = FakeConsole( + menus=[("ok", wgscreen.ADD), ("cancel", "")], + forms=[("ok", [OTHER_KEY, "fd11:a58a:88ef::2", "", ""])], + yesno=["ok"], + ) + + wgscreen.run(console) + + peers = read(path)["network"]["overlay"]["wireguard"]["peers"] + assert peers[-1] == {"public_key": OTHER_KEY, + "allowed_ips": ["fd11:a58a:88ef::2/128"]} + FIRST_WITH_OVERLAY = (0, 11) diff --git a/wgcli.py b/wgcli.py index bda258b..34923d4 100644 --- a/wgcli.py +++ b/wgcli.py @@ -112,6 +112,75 @@ def host_prefix(address: str) -> str: return f"{value}/{value.max_prefixlen}" +OWN_KEY = ( + "That is this node's own public key, the one at the top of the" + " overlay screen. A node is never its own peer: enter the OTHER" + " node's key, as that node's own Overlay network screen shows it." +) +OWN_ADDRESS = ( + "{address} is this node's own mesh address. Enter the OTHER node's" + " mesh address, as that node's own screen shows it (::2, ::3 on" + " this node's /64)." +) +OUTSIDE = ( + "{peer} is outside this node's mesh prefix, {network} (this node is" + " {address}).\n\nThe nodes of a set normally share one /64: the" + " first node's, the others taking ::2, ::3 on it. A peer is routed" + " as one host (/128), so this still works.\n\nAdd it anyway?" +) +# the fields of the overlay that hold this node's own addresses +OWN_FIELDS = ("address", "ipv4_address") + + +def interface_of(text: str): + """An address with or without its prefix, or None when it is not one""" + try: + return ipaddress.ip_interface(str(text).strip()) + except ValueError: + return None + + +def own_interfaces(wireguard: dict) -> list: + """This node's overlay addresses, each with its prefix""" + found = (interface_of(wireguard.get(name) or "") for name in OWN_FIELDS) + return [one for one in found if one is not None] + + +def peer_problem(own_key: str, wireguard: dict, key: str, + address: str) -> str: + """Why a peer is this node itself, or "" when it is not + + Only what is this node's own is refused here; anything else that is + wrong (a key that is not one, an address that does not parse) is + keel's to refuse, in its own words, when the description is staged. + """ + if key.strip() and key.strip() == own_key.strip(): + return OWN_KEY + peer = interface_of(address) + for own in own_interfaces(wireguard): + if peer is not None and peer.ip == own.ip: + return OWN_ADDRESS.format(address=own.ip) + return "" + + +def outside_prefix(wireguard: dict, address: str) -> str: + """The question to ask when a peer is outside this node's prefix + + "" when it is inside one of them, or when either side is not an + address yet. keel routes a peer by /128 (host_prefix), so a peer on + another prefix still works; it is asked about because it is most + often a node of another set, or a typo. + """ + peer = interface_of(address) + own = own_interfaces(wireguard) + if peer is None or not own: + return "" + if any(peer.ip in one.network for one in own): + return "" + return OUTSIDE.format(peer=peer.ip, network=own[0].network, + address=own[0]) + + def peer_entry(public_key: str, endpoint: str, address: str, keepalive: str) -> dict: """One peer as the description holds it; blank fields are left out""" diff --git a/wgscreen.py b/wgscreen.py index 6f6e001..b2b7f4d 100644 --- a/wgscreen.py +++ b/wgscreen.py @@ -32,6 +32,8 @@ "UDP port: the UDP port the other nodes reach this one on (blank:" " 51820)." ) +PEER_FIELDS = ("Its public key", "Its mesh address", "Its endpoint", + "Keepalive (seconds)") PEER_TEXT = ( "Another node this one accepts on the mesh, as that node's own" " screen shows it.\n\n" @@ -59,7 +61,7 @@ def run(console) -> None: ) if code != OK: return - ACTIONS[choice](console, path, document, wireguard) + ACTIONS[choice](console, path, document, wireguard, key) def choices(wireguard: dict) -> list[tuple[str, str]]: @@ -101,7 +103,8 @@ def ask(console, text: str, fields: list) -> list | None: return values if code == OK else None -def set_address(console, path: str, document: dict, wireguard: dict): +def set_address(console, path: str, document: dict, wireguard: dict, + key: str): address = str(wireguard.get("address") or suggested(console)) port = str(wireguard.get("listen_port") or "") values = ask(console, ADDRESS_TEXT, [ @@ -114,22 +117,44 @@ def set_address(console, path: str, document: dict, wireguard: dict): document, wgcli.with_address(wireguard, *values))) -def add_peer(console, path: str, document: dict, wireguard: dict): - values = ask(console, PEER_TEXT, [ - ("Its public key", "", 20, 46), - ("Its mesh address", "", 20, 46), - ("Its endpoint", "", 20, 46), - ("Keepalive (seconds)", wgcli.DEFAULT_KEEPALIVE, 20, 46), - ]) - if values is None: - return - key, address, endpoint, keepalive = values - peer = wgcli.peer_entry(key, endpoint, address, keepalive) +def add_peer(console, path: str, document: dict, wireguard: dict, + key: str): + """Another node; never this one, and asked about off its prefix + + `key` is this node's own public key. A refusal or a No goes back to + the form with what was typed, so one wrong field is all that is + typed again. + """ + values = ["", "", "", wgcli.DEFAULT_KEEPALIVE] + while True: + values = ask(console, PEER_TEXT, [ + (label, value, 20, 46) for label, value in zip(PEER_FIELDS, + values) + ]) + if values is None: + return + if accepted(console, wireguard, key, values): + break + public, address, endpoint, keepalive = values + peer = wgcli.peer_entry(public, endpoint, address, keepalive) apply(console, path, wgcli.with_overlay( document, wgcli.with_peer(wireguard, peer))) -def remove_peer(console, path: str, document: dict, wireguard: dict): +def accepted(console, wireguard: dict, key: str, values: list) -> bool: + """Whether the peer typed is another node, the operator's word taken + for one outside this node's prefix""" + public, address = values[0], values[1] + problem = wgcli.peer_problem(key, wireguard, public, address) + if problem: + console.msgbox(wgcli.TITLE, problem, autosize=True) + return False + question = wgcli.outside_prefix(wireguard, address) + return not question or console.yesno(question, autosize=True) == OK + + +def remove_peer(console, path: str, document: dict, wireguard: dict, + key: str): code, key = console.menu( wgcli.TITLE, "Stop accepting which node?", wgcli.peer_choices(wireguard), From 9cc0a515080fc081610b7a92e67c05471b43ad93 Mon Sep 17 00:00:00 2001 From: navigator Date: Sat, 3 Oct 2026 12:24:23 +0000 Subject: [PATCH 3/4] fix: the overlay screen says by when to confirm a waiting change Both nodes' overlay changes reverted two minutes after apply, as nobody confirmed them (2026-10-03): the operator answered No to "Confirm from here?", as its text told an SSH session to, and nothing said by when to confirm after that. When a change still waits after apply (No, or a confirmation keel refused), the screen now gives the time it reverts at, in UTC, from keel's marker, and the command, keel network confirm. Opened while a change waits, the first screen says so and offers Confirm now first, which runs keel network confirm; keel, not the screen, decides whether this session may (console, or an SSH session opened after the change). The question says Yes is safe to try; dialog's default stays Yes. --- docs/Instance.rst | 27 +++- tests/test_instance_menu.py | 1 + tests/test_overlay_screen.py | 247 +++++++++++++++++++++++++++++++++++ wgcli.py | 66 ++++++++-- wgscreen.py | 86 ++++++++++-- 5 files changed, 405 insertions(+), 22 deletions(-) diff --git a/docs/Instance.rst b/docs/Instance.rst index fc9b7ee..d58c39c 100644 --- a/docs/Instance.rst +++ b/docs/Instance.rst @@ -153,7 +153,20 @@ Add peer address (routed as one host, ``/128``), its endpoint (``host:port``, an IPv6 address in brackets, ``[2001:db8::20]:51820``; blank when that node reaches this one) and a keepalive in seconds (25 by default, blank for - none). A peer with a key already there replaces it. + none). A peer with a key already there replaces it. This node's own + public key, and one of this node's own mesh addresses, are refused; an + address outside this node's prefix is asked about (a peer is routed as + one host, so it still works). Either way the form comes back with what + was typed. + +Confirm now + First, while a network change waits for its confirmation; the screen's + first line then says the time it reverts at, in UTC. It runs ``keel + network confirm`` from this session and shows keel's verdict: keel + accepts it from the machine's console (or a process attached from a + container's host) and from an SSH session opened after the change over + the new configuration, and refuses it from a session opened before. The + screen does not judge the session itself. Remove peer Pick the peer by its key; the screen asks before removing it. @@ -177,10 +190,14 @@ change waits after apply: one this run brought up, even if a later step failed; one that failed and could not be rolled back either, which keel leaves to its revert timer; or one an earlier run left, which keel names when it refuses another. It needs keel 0.11.0 or later, the first with -``--skip-uplink``, which the package recommends. It then offers to confirm from here: keel accepts that from the -machine's own console, and refuses it from an SSH session opened before -the change, in which case the change reverts unless a new session -confirms it. +``--skip-uplink``, which the package recommends. It then offers to +confirm from here, Yes by default: keel accepts that from the machine's +own console, and refuses it, changing nothing, from an SSH session +opened before the change. When the change still waits after that (No, +or a refusal), the screen gives the time it reverts at, in UTC, read +from keel's marker (``/var/lib/keel/network/pending.json``: the window +starts when the interface comes up), and the command, ``keel network +confirm``, to run from a new session. Headless equivalent: edit ``network.overlay.wireguard`` in the description, ``keel spec apply --system-only --skip-uplink``, then ``keel diff --git a/tests/test_instance_menu.py b/tests/test_instance_menu.py index 2163d4d..bc715bf 100644 --- a/tests/test_instance_menu.py +++ b/tests/test_instance_menu.py @@ -461,6 +461,7 @@ def test_the_overlay_screens_menus(self): "endpoint": "[2001:db8::20]:51820"}]} menus = [ wgscreen.choices(wireguard), + wgscreen.choices(wireguard, waiting=True), keelfirstboot.overlay_choices("primary", wireguard), keelfirstboot.overlay_choices("replica", {}), keelfirstboot.ROLE_CHOICES, diff --git a/tests/test_overlay_screen.py b/tests/test_overlay_screen.py index e493817..d8374fb 100644 --- a/tests/test_overlay_screen.py +++ b/tests/test_overlay_screen.py @@ -11,6 +11,9 @@ import os import shutil import subprocess +import sys +import types +from datetime import UTC, datetime, timedelta from pathlib import Path import pytest @@ -42,10 +45,56 @@ ) +NOW = datetime(2026, 10, 3, 14, 30, 35, tzinfo=UTC) +REFUSED = ( + "Error: refused: this SSH session was open before the change, so it" + " does not show the new network works; open a new one to the new" + " address and confirm from there\n" +) + + def make_result(argv, code=0, stdout="", stderr=""): return keelcli.Result(("keel", *argv), code, stdout, stderr) +@pytest.fixture(autouse=True) +def marker(monkeypatch, tmp_path): + """keel.network.marker over a change a test puts in `pending` + + Nothing waits until a test says so, so no test reads the marker of + the machine it runs on. `pending` is what marker.read returns (None + for a marker that cannot be read), `clock` what marker.clock does. + """ + state = {"exists": False, "pending": None, "clock": None, "roots": []} + module = types.ModuleType("keel.network.marker") + + def exists(root): + state["roots"].append(root) + return state["exists"] + + module.exists = exists + module.read = lambda root: state["pending"] + module.clock = lambda kind: state["clock"] + network = types.ModuleType("keel.network") + network.marker = module + package = types.ModuleType("keel") + package.network = network + monkeypatch.setitem(sys.modules, "keel", package) + monkeypatch.setitem(sys.modules, "keel.network", network) + monkeypatch.setitem(sys.modules, "keel.network.marker", module) + state["utc_now"] = wgscreen.utc_now + monkeypatch.setattr(wgscreen, "utc_now", lambda: NOW) + + def waits(window=120, changed_at=1000.0, clock=1030.0, kind="overlay"): + state["exists"] = True + state["pending"] = types.SimpleNamespace( + window=window, changed_at=changed_at, kind=kind) + state["clock"] = clock + + state["waits"] = waits + return state + + @pytest.fixture def keel(monkeypatch): """Replace keelcli.call; `calls` records argv, `answers` steer it by @@ -244,6 +293,56 @@ def test_a_pending_change_says_how_to_keep_it(self): assert "keel network confirm" in text assert "the spec was applied" in text + def test_the_question_makes_yes_safe_to_try(self): + # The maintainer answered No over SSH, as the question told him + # to, and both nodes reverted two minutes later (2026-10-03). + # dialog's yesno defaults to Yes; the text no longer steers to No + question = wgcli.CONFIRM_QUESTION + assert "Yes is safe to try" in question + assert "Answer No" not in question + assert "time it reverts at" in question + + def test_the_deadline_is_a_clock_time_in_utc(self): + found = wgcli.Waiting(NOW + timedelta(seconds=90), 90) + text = wgcli.unconfirmed_text(found) + assert text.startswith("NOT CONFIRMED YET") + assert "reverts at 14:32:05 UTC (90 s from now)" in text + assert "\n\n keel network confirm\n\n" in text + assert "NEW session" in text + assert wgcli.CONFIRM_NOW in text + + def test_an_unknown_deadline_is_not_made_up(self): + text = wgcli.unconfirmed_text(wgcli.Waiting(None, None)) + assert "reverts as its window ends" in text + assert "UTC" not in text + + def test_the_first_screen_says_a_change_waits(self): + found = wgcli.Waiting(NOW + timedelta(seconds=90), 90) + lines = wgcli.overlay_text(THIS_KEY, OVERLAY, found).splitlines() + assert lines[0] == wgcli.waiting_line(found) + assert "14:32:05 UTC" in lines[0] + assert wgcli.THIS_NODE not in lines + unknown = wgcli.waiting_line(wgcli.Waiting(None, None)) + assert "UTC" not in unknown + + def test_the_waiting_texts_fit_an_80x24_console(self): + import keelbanner + + many = {**OVERLAY, "peers": [ + {"public_key": key, "allowed_ips": [f"fd00:6b65:1::{n}/128"]} + for n, key in enumerate([PEER_KEY, OTHER_KEY, THIS_KEY, + PEER_KEY[::-1]], 2)]} + for found in (wgcli.Waiting(NOW, 0), wgcli.Waiting(None, None)): + assert len(wgcli.waiting_line(found)) <= 72 + lines = wgcli.overlay_text(THIS_KEY, many, found).splitlines() + assert max(len(line) for line in lines) <= 72 + # four choices under it, Confirm now among them + assert len(lines) <= 20 - 5 - 6 + rows = keelbanner.text_rows(wgcli.unconfirmed_text(found), 72) + assert rows + keelbanner.BOX_CHROME <= 20 + rows = keelbanner.text_rows(wgcli.CONFIRM_QUESTION, 72) + assert rows + keelbanner.BOX_CHROME <= 20 + def test_nothing_pending_says_nothing_of_confirming(self): failed = APPLIED.replace(": done\n", ": failed: wg-quick exited 1;" " reverted to the previous file\n") @@ -693,6 +792,154 @@ def test_outside_the_prefix_yes_adds_it(self, keel, spec): "allowed_ips": ["fd11:a58a:88ef::2/128"]} +class TestWaiting: + """What the screen knows of a change that waits: keel's marker""" + + def test_nothing_waits(self, marker): + assert wgscreen.waiting() is None + assert marker["roots"] == ["/"] + + def test_without_keel_nothing_is_known(self, marker, monkeypatch): + monkeypatch.setitem(sys.modules, "keel.network", None) + marker["waits"]() + + assert wgscreen.waiting() is None + + def test_the_deadline_from_the_window_and_keel_s_clock(self, marker): + # up at 1000 s on keel's clock, 1030 now: 90 s of 120 are left + marker["waits"]() + + assert wgscreen.waiting() == wgcli.Waiting( + NOW + timedelta(seconds=90), 90) + + def test_a_window_already_over_is_now(self, marker): + marker["waits"](clock=1500.0) + + assert wgscreen.waiting() == wgcli.Waiting(NOW, 0) + + @pytest.mark.parametrize("unknown", ["marker", "changed_at", "clock"]) + def test_a_change_that_cannot_be_dated_still_waits(self, marker, + unknown): + marker["waits"](changed_at=None if unknown == "changed_at" + else 1000.0, + clock=None if unknown == "clock" else 1030.0) + if unknown == "marker": + marker["pending"] = None + + assert wgscreen.waiting() == wgcli.Waiting(None, None) + + def test_the_clock_is_utc(self, marker): + assert marker["utc_now"]().tzinfo == UTC + + +class TestConfirmWindow: + """keel-web-1 and keel-web-2, 2026-10-03: both changes reverted two + minutes after apply (journal: removed /etc/wireguard/wg0.conf, which + the change had created), as nobody confirmed them""" + + def add(self, keel, spec, answer, confirm=None): + spec(wgcli.with_overlay(BASE, OVERLAY)) + keel["answers"]["spec apply"] = [(0, APPLIED)] + if confirm is not None: + keel["answers"]["network confirm"] = [confirm] + console = FakeConsole( + menus=[("ok", wgscreen.ADD), ("cancel", "")], + forms=[("ok", [OTHER_KEY, "fd00:6b65:1::3", "", ""])], + yesno=[answer], + ) + wgscreen.run(console) + return [call[2] for call in console.calls if call[0] == "msgbox"] + + def test_no_says_the_deadline_and_the_command(self, keel, spec, marker): + marker["waits"]() + + texts = self.add(keel, spec, "cancel") + + assert "network confirm" not in commands(keel) + assert "NOT CONFIRMED YET" in texts[-1] + assert "reverts at 14:32:05 UTC" in texts[-1] + assert "keel network confirm" in texts[-1] + + def test_a_refused_confirmation_says_it_too(self, keel, spec, marker): + marker["waits"]() + + texts = self.add(keel, spec, "ok", (21, "", REFUSED)) + + assert "was open before the change" in texts[-2] + assert "reverts at 14:32:05 UTC" in texts[-1] + + def test_a_confirmed_change_says_nothing_more(self, keel, spec, marker): + marker["waits"]() + + texts = self.add(keel, spec, "ok", (0, "confirmed from the console" + " /dev/tty1\n")) + + assert "confirmed: the network change stays" in texts[-1] + assert not any("NOT CONFIRMED" in text for text in texts) + + def test_a_change_no_longer_waiting_is_not_said_to(self, keel, spec): + # confirmed from another session meanwhile: the marker is gone + texts = self.add(keel, spec, "cancel") + + assert not any("NOT CONFIRMED" in text for text in texts) + + def test_back_on_the_screen_a_waiting_change_comes_first( + self, keel, spec, marker + ): + spec(wgcli.with_overlay(BASE, OVERLAY)) + marker["waits"]() + console = FakeConsole(menus=[("cancel", "")]) + + wgscreen.run(console) + + _, _, text, choices = console.calls[0] + assert text.splitlines()[0] == wgcli.waiting_line( + wgcli.Waiting(NOW + timedelta(seconds=90), 90)) + assert [tag for tag, _ in choices] == [ + wgscreen.CONFIRM_NOW, wgscreen.ADDRESS, wgscreen.ADD, + wgscreen.REMOVE] + + def test_confirm_now_runs_keel_and_says_its_verdict(self, keel, spec, + marker): + spec(wgcli.with_overlay(BASE, OVERLAY)) + marker["waits"]() + keel["answers"]["network confirm"] = [(21, "", REFUSED)] + console = FakeConsole(menus=[("ok", wgscreen.CONFIRM_NOW), + ("cancel", "")]) + + wgscreen.run(console) + + assert commands(keel)[-1] == "network confirm" + texts = [call[2] for call in console.calls if call[0] == "msgbox"] + assert "was open before the change" in texts[0] + assert "not confirmed" in texts[0] + assert "reverts at 14:32:05 UTC" in texts[1] + + def test_confirm_now_without_keel(self, keel, spec, marker): + spec(wgcli.with_overlay(BASE, OVERLAY)) + marker["waits"]() + keel["answers"]["network confirm"] = [ + keelcli.KeelNotInstalled(keelcli.NOT_INSTALLED)] + console = FakeConsole(menus=[("ok", wgscreen.CONFIRM_NOW), + ("cancel", "")]) + + wgscreen.run(console) + + texts = [call[2] for call in console.calls if call[0] == "msgbox"] + assert texts[0] == keelcli.NOT_INSTALLED + assert "NOT CONFIRMED YET" in texts[1] + + def test_nothing_waiting_offers_no_confirm_now(self, keel, spec): + spec(wgcli.with_overlay(BASE, OVERLAY)) + console = FakeConsole(menus=[("cancel", "")]) + + wgscreen.run(console) + + assert wgscreen.CONFIRM_NOW not in [ + tag for tag, _ in console.calls[0][3]] + assert wgcli.THIS_NODE in console.calls[0][2] + + FIRST_WITH_OVERLAY = (0, 11) diff --git a/wgcli.py b/wgcli.py index 34923d4..a0d33a4 100644 --- a/wgcli.py +++ b/wgcli.py @@ -13,6 +13,8 @@ """ import ipaddress +from dataclasses import dataclass +from datetime import datetime import keelcli @@ -55,12 +57,56 @@ " usual address, then run: keel network confirm. A session that was" " open before the change cannot confirm it." ) +# dialog's yesno defaults to Yes, and Yes is what to answer: it told an +# SSH operator to answer No, and nothing said by when to confirm after +# that, so both nodes reverted (keel-web-1 and -2, 2026-10-03) CONFIRM_QUESTION = ( - "Confirm the change from here?\n\nkeel accepts it from the machine's" - " own console. From an SSH session opened before the change it refuses," - " and the change reverts unless a new session confirms it.\n\n" - "Answer No to confirm from a new session instead." + "Confirm the change from here?\n\nYes is safe to try: keel accepts" + " it from the machine's own console or from an SSH session opened" + " after the change, and refuses it, changing nothing, from a session" + " opened before.\n\nWhile it still waits, the next screen gives the" + " time it reverts at and how to confirm it." ) +CONFIRM_NOW = "Confirm now" +CONFIRM_NOW_ITEM = "keel network confirm (console, or a NEW session)" +CLOCK = "%H:%M:%S UTC" +UNCONFIRMED = ( + "NOT CONFIRMED YET: the change reverts {when} unless it is confirmed." + "\n\nFrom a NEW session (ssh to this node: over the overlay from the" + " other node, or over its usual address), run:\n\n" + " keel network confirm\n\n" + "Or open this screen again before then and choose " + CONFIRM_NOW + + ": keel takes it from the console, or from an SSH session opened" + " after the change." +) +WAITING_LINE = "A NETWORK CHANGE WAITS FOR CONFIRMATION: it reverts {when}." + + +@dataclass(frozen=True) +class Waiting: + """A network change waiting for its confirmation: when it reverts, + and the seconds left; both None when keel's marker cannot date it""" + + at: datetime | None + left: int | None + + +def reverts(found: Waiting, seconds: bool = False) -> str: + """When the change reverts, as a clock time when it is known""" + if found.at is None: + return "as its window ends" + when = f"at {found.at.strftime(CLOCK)}" + return f"{when} ({found.left} s from now)" if seconds else when + + +def unconfirmed_text(found: Waiting) -> str: + """After apply, or a confirmation keel refused: by when, and how""" + return UNCONFIRMED.format(when=reverts(found, seconds=True)) + + +def waiting_line(found: Waiting) -> str: + """The first line of the first screen while a change waits""" + return WAITING_LINE.format(when=reverts(found)) def overlay_of(document: dict) -> dict: @@ -248,15 +294,19 @@ def peer_choices(wireguard: dict) -> list[tuple[str, str]]: for peer in peers(wireguard)] -def overlay_text(public_key: str, wireguard: dict) -> str: +def overlay_text(public_key: str, wireguard: dict, + waiting: Waiting | None = None) -> str: """The first screen: this node, then its peers, then what it is for One line a peer, and no more than LISTED of them when there are more, so that the menu under the text keeps its rows on an 80x24 - console; Remove peer lists every one. + console; Remove peer lists every one. While a network change waits, + its line comes first, in place of what the screen is for, which + keeps the rows for the fourth choice, Confirm now. """ port = wireguard.get("listen_port") or DEFAULT_PORT - lines = [ + first = [waiting_line(waiting), ""] if waiting else [] + lines = first + [ f"This node's public key: {public_key}", "Mesh address (not your LAN):" f" {wireguard.get('address') or NO_ADDRESS} UDP port: {port}", @@ -273,7 +323,7 @@ def overlay_text(public_key: str, wireguard: dict) -> str: if len(shown) < len(found): lines.append(f" and {len(found) - len(shown)} more: Remove peer" " lists every one") - return "\n".join(lines + ["", THIS_NODE]) + return "\n".join(lines if waiting else lines + ["", THIS_NODE]) def is_pending(result: keelcli.Result) -> bool: diff --git a/wgscreen.py b/wgscreen.py index b2b7f4d..05a1672 100644 --- a/wgscreen.py +++ b/wgscreen.py @@ -10,17 +10,23 @@ with ``--skip-uplink``, so the uplink never moves from here). keel brings the overlay up under the confirmation window of decision 0018; whenever a network change then waits, the screen says how to confirm, -and offers to confirm from here, which keel accepts from a console only. +offers to confirm from here (keel decides whether this session may), +and while it still waits gives the time it reverts at. Opened again +while a change waits, its first line says so and Confirm now comes first. ``console`` is passed in, so each function is tested with a scripted fake and no dialog opens. """ +from datetime import UTC, datetime, timedelta + import dbscreen import keelcli import wgcli OK = "ok" +ROOT = "/" +CONFIRM_NOW = wgcli.CONFIRM_NOW ADDRESS = "Address" ADD = "Add peer" REMOVE = "Remove peer" @@ -55,18 +61,22 @@ def run(console) -> None: console.msgbox(wgcli.TITLE, problem) return wireguard = wgcli.overlay_of(document) + found = waiting() code, choice = console.menu( - wgcli.TITLE, wgcli.overlay_text(key, wireguard), - choices(wireguard), + wgcli.TITLE, wgcli.overlay_text(key, wireguard, found), + choices(wireguard, waiting=found is not None), ) if code != OK: return ACTIONS[choice](console, path, document, wireguard, key) -def choices(wireguard: dict) -> list[tuple[str, str]]: - """No peer before an address; no removal without a peer""" - found = [(ADDRESS, "this node's mesh address and UDP port")] +def choices(wireguard: dict, + waiting: bool = False) -> list[tuple[str, str]]: + """No peer before an address; no removal without a peer; Confirm now + first, and so highlighted, while a network change waits""" + first = [(CONFIRM_NOW, wgcli.CONFIRM_NOW_ITEM)] if waiting else [] + found = first + [(ADDRESS, "this node's mesh address and UDP port")] if not wireguard.get("address"): return found found.append((ADD, "accept another node: its key, address, endpoint")) @@ -210,12 +220,70 @@ def apply(console, path: str, document: dict) -> None: console.msgbox(wgcli.TITLE, wgcli.applied_text(result), autosize=True) if not wgcli.is_pending(result): return - if console.yesno(wgcli.CONFIRM_QUESTION, autosize=True) != OK: - return + if console.yesno(wgcli.CONFIRM_QUESTION, autosize=True) == OK: + confirm(console) + else: + remind(console) + + +def confirm(console) -> None: + """`keel network confirm` from this process, and keel's verdict + + keel, not this screen, decides whether this session may confirm + (keel.network.session and .confirm): the machine's console, or a + process attached from a container's host, may; an SSH session only + when it was opened after the change, over the new configuration. + Whatever keel refused, the change still waits, so the screen then + says by when to confirm it. + """ confirmed = dbscreen.call(console, wgcli.TITLE, wgcli.CONFIRM) if confirmed is not None: console.msgbox(wgcli.TITLE, wgcli.command_text("confirm", confirmed), autosize=True) + if confirmed is None or confirmed.code != keelcli.OK: + remind(console) + + +def remind(console) -> None: + """The time a change that still waits reverts at, and the command""" + found = waiting() + if found is not None: + console.msgbox(wgcli.TITLE, wgcli.unconfirmed_text(found), + autosize=True) + + +def confirm_now(console, path: str, document: dict, wireguard: dict, + key: str): + confirm(console) + + +def utc_now() -> datetime: + return datetime.now(UTC) + + +def waiting(root: str = ROOT) -> wgcli.Waiting | None: + """The network change that waits for its confirmation, else None + + Read from keel's own marker (keel.network.marker, the file `keel + network confirm` and the revert timer read): the window timer is + armed when the interface comes up (`changed_at`, on the clock keel + dates the change with) and runs `window` seconds. A marker keel + cannot read, or a change it could not date, still waits, with no + time; without keel nothing is known to wait. + """ + try: + from keel.network import marker + except ImportError: + return None + if not marker.exists(root): + return None + pending = marker.read(root) + now = marker.clock(pending.kind) if pending is not None else None + if pending is None or pending.changed_at is None or now is None: + return wgcli.Waiting(None, None) + left = max(0, round(pending.window - (now - pending.changed_at))) + return wgcli.Waiting(utc_now() + timedelta(seconds=left), left) -ACTIONS = {ADDRESS: set_address, ADD: add_peer, REMOVE: remove_peer} +ACTIONS = {ADDRESS: set_address, ADD: add_peer, REMOVE: remove_peer, + CONFIRM_NOW: confirm_now} From db3146cc7a87557dc47fb97b67141429169563c5 Mon Sep 17 00:00:00 2001 From: navigator Date: Sat, 3 Oct 2026 12:24:23 +0000 Subject: [PATCH 4/4] chore: changelog for 2.2.3+keel15 --- debian/changelog | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/debian/changelog b/debian/changelog index 1452fdb..1cfaec4 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,27 @@ +confconsole (2.2.3+keel15) trixie; urgency=low + + * The Overlay network screen keeps one place in the Instance menu. It + moved from Instance > Advanced to the Instance menu itself once the + spec had an overlay, and Advanced went with it, so the maintainer + took it for gone (keel-web-2, 2026-10-03). Its place now follows the + installation mode alone: behind Advanced in a simple installation, + configured or not; in the Instance menu in the cloud modes, with no + empty Advanced. + * Add peer refuses this node's own public key, shown at the top of the + same screen, and this node's own mesh address, and asks before adding + a peer outside this node's overlay prefix (it still works, routed as + one host). The form comes back with what was typed. + * A network change waiting for its confirmation is no longer left to + revert unnoticed (both nodes, 2026-10-03). After apply, when the + operator does not confirm from here or keel refuses it, the screen + gives the time the change reverts at, in UTC, and the command, keel + network confirm. Opening the screen while a change waits says so on + its first line and offers Confirm now first, which runs keel network + confirm; keel decides whether this session may. The question before + it says Yes is safe to try instead of steering an SSH session to No. + + -- Marcos Méndez Sat, 03 Oct 2026 15:00:00 +0000 + confconsole (2.2.3+keel14) trixie; urgency=low * The Let's Encrypt screen is driven by the instance description. Its