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
21 changes: 14 additions & 7 deletions src/sourceos_syncd/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,14 +157,15 @@ def build_parser() -> argparse.ArgumentParser:
def add_katello_args(p: argparse.ArgumentParser) -> None:
p.add_argument("--katello-url", default="https://127.0.0.1:8443", help="Foreman+Katello base URL")
p.add_argument("--katello-user", default="admin", help="Katello admin username")
p.add_argument("--katello-password", default=None, help="Katello admin password (or set KATELLO_PASSWORD env)")
p.add_argument("--katello-password", default=None, help="Katello admin password (or set KATELLO_PASSWORD / KATELLO_PASSWORD_FILE env)")
p.add_argument("--org", default="SocioProphet", help="Katello organization name")
p.add_argument("--content-view", default="sourceos-builder-aarch64", help="content view name")
p.add_argument("--lifecycle-env", default="dev", help="lifecycle environment (dev/candidate/stable)")
p.add_argument("--locus", default="local", help="execution locus (local/trusted_private)")
p.add_argument("--flake-ref", default="github:SociOS-Linux/source-os#builder-aarch64", help="NixOS flake ref")
p.add_argument("--current-version", default=None, help="current content view version (skip if up to date)")
p.add_argument("--no-verify-ssl", action="store_true", help="skip TLS verification (local dev only)")
p.add_argument("--signing-public-key", default=None, help="minisign public key (RWS...) to verify nix-cache-info before applying")

sync_plan = sync_sub.add_parser("plan", help="query Katello and emit a ContentSyncPlan (no changes)")
add_katello_args(sync_plan)
Expand Down Expand Up @@ -317,9 +318,11 @@ def main(argv: list[str] | None = None) -> int:

if args.area == "sync" and args.command in ("plan", "apply"):
import os
password = args.katello_password or os.environ.get("KATELLO_PASSWORD", "")
if not password:
sys.stderr.write(pretty_json({"error": "missing password", "message": "pass --katello-password or set KATELLO_PASSWORD"}, pretty=pretty))
from .daemon import _resolve_password
try:
password = args.katello_password or _resolve_password()
except RuntimeError as exc:
sys.stderr.write(pretty_json({"error": "missing password", "message": str(exc)}, pretty=pretty))
return 1
client = KatelloContentClient(
base_url=args.katello_url,
Expand All @@ -333,6 +336,7 @@ def main(argv: list[str] | None = None) -> int:
flake_ref=args.flake_ref,
locus=args.locus,
current_version=args.current_version,
signing_public_key=getattr(args, "signing_public_key", None),
)
plan = syncer.plan(manifest)
if args.command == "plan":
Expand All @@ -359,9 +363,11 @@ def main(argv: list[str] | None = None) -> int:
if getattr(args, "from_env", False):
daemon = daemon_from_env()
else:
password = args.katello_password or os.environ.get("KATELLO_PASSWORD", "")
if not password:
sys.stderr.write(pretty_json({"error": "missing password", "message": "pass --katello-password or set KATELLO_PASSWORD"}, pretty=pretty))
from .daemon import _resolve_password
try:
password = args.katello_password or _resolve_password()
except RuntimeError as exc:
sys.stderr.write(pretty_json({"error": "missing password", "message": str(exc)}, pretty=pretty))
return 1
daemon = SyncDaemon(
katello_url=args.katello_url,
Expand All @@ -375,6 +381,7 @@ def main(argv: list[str] | None = None) -> int:
poll_interval_s=args.poll_interval,
store_root=getattr(args, "store_root", None),
verify_ssl=not args.no_verify_ssl,
signing_public_key=getattr(args, "signing_public_key", None),
)
return daemon.run()

Expand Down
28 changes: 26 additions & 2 deletions src/sourceos_syncd/content_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ class ContentViewSyncer:
The syncer enforces the locus gate: only local locus is permitted for
Phase 0. burst_cloud and attested_fog require explicit policy elevation
(not yet implemented).

When signing_public_key is set (a minisign public key string starting
with "RWS..."), the plan includes a `minisign -V` step before nix copy.
This ensures the nix-cache-info served by Pulp was signed by the key
embedded in the NixOS image, preventing an unauthenticated Katello from
delivering arbitrary closures.
"""

ALLOWED_LOCI = {"local", "trusted_private"}
Expand All @@ -77,10 +83,12 @@ def __init__(
flake_ref: str = "github:SociOS-Linux/source-os#builder-aarch64",
locus: str = "local",
current_version: str | None = None,
signing_public_key: str | None = None,
) -> None:
self._flake_ref = flake_ref
self._locus = locus
self._current_version = current_version
self._signing_public_key = signing_public_key

def plan(self, manifest: ContentViewManifest) -> ContentSyncPlan:
"""Return a non-mutating ContentSyncPlan. No I/O performed."""
Expand Down Expand Up @@ -115,8 +123,24 @@ def plan(self, manifest: ContentViewManifest) -> ContentSyncPlan:
steps=[],
)

steps = [
f"nix copy --from '{manifest.nix_cache_url}' --no-check-sigs '{self._flake_ref}'",
steps = []

# Verify nix-cache-info signature before pulling any closures.
# The public key is the one baked into the NixOS image — an
# unauthenticated Katello cannot deliver closures without knowing the
# private key held by the build pipeline.
if self._signing_public_key:
cache_info_url = f"{manifest.nix_cache_url}/nix-cache-info"
steps += [
f"curl -fsSL '{cache_info_url}' -o /tmp/sourceos-nix-cache-info",
f"curl -fsSL '{cache_info_url}.minisig' -o /tmp/sourceos-nix-cache-info.minisig",
f"minisign -V -P '{self._signing_public_key}'"
f" -m /tmp/sourceos-nix-cache-info"
f" -x /tmp/sourceos-nix-cache-info.minisig",
]

steps += [
f"nix copy --from '{manifest.nix_cache_url}' '{self._flake_ref}'",
f"nixos-rebuild switch --flake '{self._flake_ref}'",
]

Expand Down
30 changes: 23 additions & 7 deletions src/sourceos_syncd/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def __init__(
poll_interval_s: int = DEFAULT_POLL_INTERVAL_S,
store_root: str | None = None,
verify_ssl: bool = True,
signing_public_key: str | None = None,
) -> None:
self._client = KatelloContentClient(
base_url=katello_url,
Expand All @@ -63,6 +64,7 @@ def __init__(
self._lifecycle_env = lifecycle_env
self._locus = locus
self._flake_ref = flake_ref
self._signing_public_key = signing_public_key
self._poll_interval_s = poll_interval_s
self._store = ReceiptStore(root=store_root or "/var/lib/sourceos-syncd")
self._running = True
Expand Down Expand Up @@ -115,6 +117,7 @@ def _poll_once(self) -> None:
flake_ref=self._flake_ref,
locus=self._locus,
current_version=current_version,
signing_public_key=self._signing_public_key,
)
plan = syncer.plan(manifest)

Expand Down Expand Up @@ -155,25 +158,38 @@ def _interruptible_sleep(self, seconds: int) -> None:
time.sleep(min(5, deadline - time.monotonic()))


def _resolve_password() -> str:
"""Read password from KATELLO_PASSWORD or KATELLO_PASSWORD_FILE.

KATELLO_PASSWORD_FILE wins when both are set. This supports systemd
LoadCredential which writes the secret to a file, not an env var.
"""
pw_file = os.environ.get("KATELLO_PASSWORD_FILE", "")
if pw_file:
try:
return open(pw_file, encoding="utf-8").read().strip()
except OSError as exc:
raise RuntimeError(f"cannot read KATELLO_PASSWORD_FILE {pw_file}: {exc}") from exc
pw = os.environ.get("KATELLO_PASSWORD", "")
if not pw:
raise RuntimeError("KATELLO_PASSWORD or KATELLO_PASSWORD_FILE must be set")
return pw


def daemon_from_env() -> SyncDaemon:
"""Construct a SyncDaemon entirely from environment variables."""
def require(name: str) -> str:
val = os.environ.get(name, "")
if not val:
raise RuntimeError(f"required env var {name} is not set")
return val

return SyncDaemon(
katello_url=os.environ.get("KATELLO_URL", "https://127.0.0.1:8443"),
katello_user=os.environ.get("KATELLO_USER", "admin"),
katello_password=require("KATELLO_PASSWORD"),
katello_password=_resolve_password(),
org=os.environ.get("KATELLO_ORG", "SocioProphet"),
content_view=os.environ.get("KATELLO_CONTENT_VIEW", "sourceos-builder-aarch64"),
lifecycle_env=os.environ.get("KATELLO_LIFECYCLE_ENV", "stable"),
locus=os.environ.get("SOURCEOS_LOCUS", "local"),
flake_ref=os.environ.get(
"SOURCEOS_FLAKE_REF", "github:SociOS-Linux/source-os#builder-aarch64"
),
signing_public_key=os.environ.get("SOURCEOS_SIGNING_PUBLIC_KEY", "") or None,
poll_interval_s=int(os.environ.get("SOURCEOS_POLL_INTERVAL", str(DEFAULT_POLL_INTERVAL_S))),
store_root=os.environ.get("SOURCEOS_STORE_ROOT", "/var/lib/sourceos-syncd"),
verify_ssl=os.environ.get("SOURCEOS_NO_VERIFY_SSL", "").lower() not in ("1", "true", "yes"),
Expand Down
23 changes: 23 additions & 0 deletions tests/test_katello_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,29 @@ def test_plan_allowed_new_version():
assert plan.allowed
assert any("nix copy" in s for s in plan.steps)
assert any("nixos-rebuild" in s for s in plan.steps)
# no signing steps when key not configured
assert not any("minisign" in s for s in plan.steps)


def test_plan_with_signing_key_prepends_verify_steps():
pub_key = "RWSxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
syncer = ContentViewSyncer(locus="local", current_version="0.9", signing_public_key=pub_key)
plan = syncer.plan(make_manifest(version="1.0"))
assert plan.allowed
step_cmds = " ".join(plan.steps)
assert "minisign" in step_cmds
assert "nix-cache-info" in step_cmds
# verify step must come before nix copy
minisign_idx = next(i for i, s in enumerate(plan.steps) if "minisign" in s)
nix_copy_idx = next(i for i, s in enumerate(plan.steps) if "nix copy" in s)
assert minisign_idx < nix_copy_idx


def test_plan_with_signing_key_embeds_public_key():
pub_key = "RWSxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
syncer = ContentViewSyncer(locus="local", signing_public_key=pub_key)
plan = syncer.plan(make_manifest(version="1.0"))
assert any(pub_key in s for s in plan.steps)


def test_plan_noop_same_version():
Expand Down
Loading