fix(release): support validated per-run SSH address family

This commit is contained in:
2026-09-08 02:50:59 +02:00
parent 6bcb75f577
commit be57a1823a
3 changed files with 125 additions and 0 deletions
+39
View File
@@ -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 fails closed there; clone the registered origins into a private workspace
before releasing. 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 The runtime itself is part of the authority boundary. Durable run creation
verifies the meta checkout, release/check tooling, repository registry, Python verifies the meta checkout, release/check tooling, repository registry, Python
environment, loaded `govoplan_core` and cryptography packages, and Git/SSH environment, loaded `govoplan_core` and cryptography packages, and Git/SSH
+75
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import shlex
import subprocess import subprocess
import sys import sys
import unittest import unittest
@@ -17,6 +18,80 @@ from govoplan_release import git_state # noqa: E402
class ReleaseGitStateTests(unittest.TestCase): 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: def test_manifest_version_does_not_confuse_interface_versions(self) -> None:
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
@@ -197,6 +197,13 @@ def sanitized_git_environment(
"""Keep only deliberate process/auth inputs and neutralize Git redirection.""" """Keep only deliberate process/auth inputs and neutralize Git redirection."""
environment = os.environ if source is None else source 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 = { result = {
key: environment[key] key: environment[key]
for key in ( for key in (
@@ -221,6 +228,10 @@ def sanitized_git_environment(
"PATH": "/usr/bin:/bin", "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 return result