From be57a1823a3c69b4f976800f05c148b2dd095bc3 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 8 Sep 2026 02:50:59 +0200 Subject: [PATCH] fix(release): support validated per-run SSH address family --- docs/operations/RELEASE_CONSOLE.md | 39 +++++++++++ tests/test_release_git_state.py | 75 +++++++++++++++++++++ tools/release/govoplan_release/git_state.py | 11 +++ 3 files changed, 125 insertions(+) diff --git a/docs/operations/RELEASE_CONSOLE.md b/docs/operations/RELEASE_CONSOLE.md index 61bd4b8..dbc167b 100644 --- a/docs/operations/RELEASE_CONSOLE.md +++ b/docs/operations/RELEASE_CONSOLE.md @@ -58,6 +58,45 @@ checkouts remain usable for read-only planning, but every durable executor fails closed there; clone the registered origins into a private workspace before releasing. +For a host with a confirmed IPv6 connection timeout, set +`GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY=inet` only for the release-tool invocation. +When unset, the original SSH command is preserved, including the trusted +operator's per-host `AddressFamily` configuration (normally `any`). Explicit +values accepted by the shared source/tag Git helper are exactly `any`, `inet` +(IPv4 only), and `inet6` (IPv6 only). Empty, misspelled, whitespace-padded, or +injected values fail before Git starts. The selector only adds the corresponding +fixed SSH `AddressFamily` option: it does not change DNS, host-key verification, +the registered remote, authentication, `BatchMode=yes`, or `ConnectTimeout=8`. +Arbitrary `GIT_SSH_COMMAND` overrides remain ignored. For example, start a +single local console invocation with: + +```sh +GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY=inet \ + ./.venv/bin/python tools/release/release-console.py +``` + +The same process-scoped setting applies to canonical source/tag readbacks and +registry-candidate source verification. Under Flatpak, pass it explicitly to +the host invocation with `flatpak-spawn --host /usr/bin/env +GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY=inet ...`. It is not a global SSH setting +and does not affect the website publisher's separate transport sanitizer, npm, +or HTTP downloads. An IPv4-only setting cannot reach IPv6-only hosts; omit it +or use `any` when the diagnosed restriction no longer applies. + +Deutsch: Bei einem bestätigten IPv6-Verbindungs-Timeout kann für genau einen +Release-Werkzeugaufruf `GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY=inet` gesetzt werden. +Ohne diese Variable bleibt der bisherige SSH-Befehl einschließlich der +vertrauenswürdigen Host-Konfiguration unverändert (normalerweise `any`). +Explizit zulässig sind ausschließlich `any`, `inet` (nur IPv4) und `inet6` +(nur IPv6). Andere oder leere Werte werden vor dem Git-Aufruf abgewiesen. +DNS, Hostschlüsselprüfung, registrierte Quelladresse, Authentifizierung und +Zeitlimit bleiben unverändert; frei vorgegebene SSH-Befehle bleiben gesperrt. +Unter Flatpak die Variable ausdrücklich an den Host-Aufruf übergeben. Die +Auswahl gilt für den gemeinsamen Git-Helfer der Quell-/Tag-Prüfungen, nicht +für den separaten Website-Publisher, npm oder HTTP-Downloads. Sie ändert keine +globale Konfiguration. Nach Behebung des Netzwerkproblems die Variable +weglassen oder auf `any` setzen; IPv4-only erreicht keine IPv6-only-Ziele. + The runtime itself is part of the authority boundary. Durable run creation verifies the meta checkout, release/check tooling, repository registry, Python environment, loaded `govoplan_core` and cryptography packages, and Git/SSH diff --git a/tests/test_release_git_state.py b/tests/test_release_git_state.py index 20cdbfd..f84f2fd 100644 --- a/tests/test_release_git_state.py +++ b/tests/test_release_git_state.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +import shlex import subprocess import sys import unittest @@ -17,6 +18,80 @@ from govoplan_release import git_state # noqa: E402 class ReleaseGitStateTests(unittest.TestCase): + def test_unset_ssh_address_family_preserves_original_command_and_operator_config(self) -> None: + environment = git_state.sanitized_git_environment({}) + + self.assertEqual( + [ + "/usr/bin/ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", + ], + shlex.split(environment["GIT_SSH_COMMAND"]), + ) + self.assertNotIn("GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY", environment) + self.assertEqual(environment, git_state.sanitized_git_environment(environment)) + + def test_ssh_address_family_accepts_only_fixed_choices_and_survives_resanitizing(self) -> None: + for family in ("any", "inet", "inet6"): + with self.subTest(family=family): + environment = git_state.sanitized_git_environment({ + "GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY": family, + "GIT_SSH_COMMAND": "/attacker/ssh -o StrictHostKeyChecking=no", + "GIT_SSH": "/attacker/ssh", + "PATH": "/attacker/bin", + }) + + self.assertEqual( + [ + "/usr/bin/ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", + "-o", f"AddressFamily={family}", + ], + shlex.split(environment["GIT_SSH_COMMAND"]), + ) + self.assertNotIn("GIT_SSH", environment) + self.assertEqual("/usr/bin:/bin", environment["PATH"]) + self.assertEqual(environment, git_state.sanitized_git_environment(environment)) + + def test_invalid_ssh_address_family_is_rejected_before_git_runs(self) -> None: + for invalid in ( + "", "INET", "ipv4", " inet", "inet ", "inet\n", + "inet; touch /not-executed", "inet -o StrictHostKeyChecking=no", + "$(not-executed)", + ): + with ( + self.subTest(value=invalid), + patch.dict("os.environ", {"GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY": invalid}), + patch.object(git_state.subprocess, "run") as run, + ): + with self.assertRaisesRegex( + ValueError, "GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY must be any, inet, or inet6", + ): + git_state.git(Path("/workspace/govoplan-core"), "status", "--porcelain") + run.assert_not_called() + + def test_source_provenance_readback_keeps_family_but_discards_ssh_command_override(self) -> None: + from govoplan_release.source_provenance import inspect_remote_tag + + completed = subprocess.CompletedProcess( + [], 0, f"{'a' * 40}\trefs/tags/v1.2.3\n{'b' * 40}\trefs/tags/v1.2.3^{{}}\n", "", + ) + with ( + patch.dict("os.environ", { + "GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY": "inet", + "GIT_SSH_COMMAND": "/attacker/ssh -o StrictHostKeyChecking=no", + }), + patch("govoplan_release.repository_tag.subprocess.run", return_value=completed) as run, + ): + result = inspect_remote_tag( + path=Path("/workspace/govoplan-core"), remote="origin", + remote_url="git@git.add-ideas.de:GovOPlaN/govoplan-core.git", tag="v1.2.3", + ) + + self.assertEqual("b" * 40, result.commit) + self.assertEqual( + "/usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=8 -o AddressFamily=inet", + run.call_args.kwargs["env"]["GIT_SSH_COMMAND"], + ) + def test_manifest_version_does_not_confuse_interface_versions(self) -> None: from tempfile import TemporaryDirectory diff --git a/tools/release/govoplan_release/git_state.py b/tools/release/govoplan_release/git_state.py index 0428dbe..960f004 100644 --- a/tools/release/govoplan_release/git_state.py +++ b/tools/release/govoplan_release/git_state.py @@ -197,6 +197,13 @@ def sanitized_git_environment( """Keep only deliberate process/auth inputs and neutralize Git redirection.""" environment = os.environ if source is None else source + address_family = environment.get("GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY") + if "GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY" in environment and address_family not in ( + "any", "inet", "inet6", + ): + raise ValueError( + "GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY must be any, inet, or inet6" + ) result = { key: environment[key] for key in ( @@ -221,6 +228,10 @@ def sanitized_git_environment( "PATH": "/usr/bin:/bin", } ) + if address_family is not None: + result["GIT_SSH_COMMAND"] += f" -o AddressFamily={address_family}" + # Preserve only an explicit, validated choice across re-sanitization. + result["GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY"] = address_family return result