From a5a36fc7cd1a0eb7a963ea314bccaee08af47228 Mon Sep 17 00:00:00 2001 From: d9pouces Date: Sat, 5 Sep 2026 22:23:34 +0200 Subject: [PATCH] feat: update Dockerfiles and configuration for TLS support across multiple modules Signed-off-by: d9pouces --- id_configurator/modules/certificates.py | 226 +++++++++++++++--- id_configurator/modules/java.py | 12 +- tests/configs/Apk.yaml.j2 | 7 +- tests/configs/Java.yaml.j2 | 7 +- tests/configs/Yum.yaml.j2 | 7 +- tests/dockerfiles/Apk.Dockerfile | 2 +- tests/dockerfiles/Apt.Dockerfile | 3 +- tests/dockerfiles/Java.Dockerfile | 2 +- tests/dockerfiles/Python.Dockerfile | 2 +- tests/dockerfiles/Yum.Dockerfile | 2 +- tests/dockerfiles/repos/apk-repo.Dockerfile | 7 +- tests/dockerfiles/repos/apk-repo.nginx.conf | 5 +- tests/dockerfiles/repos/java-repo.Dockerfile | 9 +- tests/dockerfiles/repos/java-repo.nginx.conf | 5 +- tests/dockerfiles/repos/yum-repo.Dockerfile | 10 +- tests/dockerfiles/repos/yum-repo.nginx.conf | 5 +- tests/test_certificates.py | 239 ++++++++++++++----- tests/test_cli.py | 12 +- tests/test_java.py | 5 +- tox.ini | 6 +- 20 files changed, 441 insertions(+), 132 deletions(-) diff --git a/id_configurator/modules/certificates.py b/id_configurator/modules/certificates.py index de80f36..8a31f3f 100644 --- a/id_configurator/modules/certificates.py +++ b/id_configurator/modules/certificates.py @@ -1,15 +1,52 @@ +# noinspection SpellCheckingInspection """Install root certificates into the system's CA store. Certificates from the configuration are dropped into the "local" CA -directory, then the real `update-ca-certificates` binary is invoked to -rebuild the trust store (bundle + hashed symlinks) exactly like it would on -a real Debian/Ubuntu system, instead of reimplementing that logic here. +directory, then the trust store under `/etc/ssl/certs` (bundle + named +symlinks) is rebuilt in pure Python, mirroring what Debian's +`update-ca-certificates` shell script does: every certificate enabled in +`ca-certificates.conf` (read from ``), plus everything found under the +local certificates directory, is concatenated into a single bundle file and +gets a `.pem` symlink pointing at it. + +The OpenSSL subject-hash symlinks (`.0`) that `update-ca-certificates` +also creates, for CApath-style directory lookups, are intentionally not +reproduced here: none of this tool's own consumers (pip, apt, dnf, apk, the +JVM, ...) do CApath lookups, they all read the single bundle file. + +If `/etc/ssl/certs` is itself a symlink in `` (as on RHEL, where it +points to `/etc/pki/tls/certs`), the bundle and symlinks are written to +whatever directory it resolves to instead. The write lands where the +deployed system will actually look, without ever writing a plain directory +where that system expects a symlink. In that case `/usr/lib/ssl/cert.pem` +(see below) is not written either, since it isn't OpenSSL's default CAfile +on such systems in the first place. + +Otherwise, `/usr/lib/ssl/cert.pem` - OpenSSL's own compiled-in default +CAfile on Debian/Ubuntu, and what apt's own https method reads absent any +explicit configuration - is symlinked to the rebuilt bundle too. This is +normally created by the ca-certificates package's postinst script; doing it +here as well means the bundle is trusted even on a base image that never +had that package installed. + +When `` also has a RHEL/Fedora-style trust store (`/etc/pki/ca-trust`), +the configured certificates are additionally dropped into +`/etc/pki/ca-trust/source/anchors` and merged into +`/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem`, the single file that +`/etc/pki/tls/certs/ca-bundle.crt` and OpenSSL's own default CAfile +(`/etc/pki/tls/cert.pem`) both resolve to on such systems - which is what +dnf/curl/openssl-based tools there actually read. This intentionally does +not reproduce the rest of `update-ca-trust extract`'s output (the OpenSSL +"trust" bundle with per-certificate usage flags, the email/objsign bundles, +the extracted Java keystore, ...); only the plain PEM trust bundle is +rebuilt. """ from __future__ import annotations import logging -import subprocess +import posixpath +from pathlib import Path from id_configurator.context import Context from id_configurator.modules.base import Module, register @@ -17,11 +54,85 @@ from id_configurator.modules.base import Module, register logger = logging.getLogger(__name__) _LOCAL_CERTS_DIR = "usr/local/share/ca-certificates" +# noinspection SpellCheckingInspection _CERTSCONF = "etc/ca-certificates.conf" +# noinspection SpellCheckingInspection _CERTSDIR = "usr/share/ca-certificates" +# noinspection SpellCheckingInspection _ETCCERTSDIR = "etc/ssl/certs" +_CERT_BUNDLE = "ca-certificates.crt" + +_RHEL_ANCHORS_DIR = "etc/pki/ca-trust/source/anchors" +_RHEL_EXTRACTED_BUNDLE = "etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" + +# OpenSSL's own compiled-in default CAfile on Debian/Ubuntu (OPENSSLDIR is +# /usr/lib/ssl there); the ca-certificates package's postinst symlinks this +# to the bundle, which is what lets OpenSSL-linked tools (including apt's +# own https method) find it without any explicit CAfile configuration. That +# symlink only exists once the package has actually been installed, so it is +# created here too, making the bundle usable even on a base image that +# never had ca-certificates installed at all. +_OPENSSL_DEFAULT_CERT_FILE = "usr/lib/ssl/cert.pem" +def _pem_name(relative_crt: str) -> str: + # noinspection GrazieInspection + """Mirror update-ca-certificates' `.pem` symlink naming for a `*.crt` path.""" + stem = Path(relative_crt).name.removesuffix(".crt") + stem = stem.replace(" ", "_").replace("(", "=").replace(")", "=").replace(",", "_") + return f"{stem}.pem" + + +def _ensure_trailing_newline(content: bytes) -> bytes: + return content if content.endswith(b"\n") else content + b"\n" + + +def _resolve_target_dir(context: Context, relative: str) -> str: + """Resolve `relative` against ``, following it if it is a symlink. + + Some systems replace a well-known directory with a symlink to another + location (e.g. RHEL's `/etc/ssl/certs` -> `/etc/pki/tls/certs`). Writing + to the literal path in that case would create a plain directory where + the deployed system expects a symlink - which BuildKit refuses to + overlay a directory onto in the first place - while leaving the actual, + symlinked-to location untouched. Returns `relative` unchanged if it + isn't a symlink in ``, or if it can't be resolved back to a path + under ``. + """ + if context.src is None: + return relative + # Not context.src_path(): it resolves symlinks away before we can even + # check for one. + source = context.src / relative + if not source.is_symlink(): + return relative + target = source.readlink() + if target.is_absolute(): + return posixpath.normpath(target.as_posix()).lstrip("/") + resolved = (source.parent / target).resolve() + try: + return resolved.relative_to(context.src.resolve()).as_posix() + except ValueError: + return relative + + +# noinspection SpellCheckingInspection +def _parse_certsconf(certsconf: Path) -> list[str]: + """Return the paths (relative to CERTSDIR) enabled in ca-certificates.conf. + + Blank lines and comments (`#`) are ignored; lines prefixed with `!` + are explicitly disabled, exactly like update-ca-certificates. + """ + enabled = [] + for line in certsconf.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or line.startswith("!"): + continue + enabled.append(line) + return enabled + + +# noinspection SpellCheckingInspection @register class CertificatesModule(Module): name = "certificates" @@ -46,37 +157,90 @@ class CertificatesModule(Module): ) logger.info("installed root certificate %s", certificate.name) self._update_ca_certificates_conf(context) + if self._rhel_trust_store_present(context): + self._update_rhel_trust_store(context, configured_names) @staticmethod - def _update_ca_certificates_conf(context: Context) -> None: - etccertsdir = context.dst_path(_ETCCERTSDIR) + def _rhel_trust_store_present(context: Context) -> bool: + anchors = context.src_path(_RHEL_ANCHORS_DIR) + return anchors is not None and anchors.is_dir() + + @staticmethod + def _collect_conf_entries(context: Context) -> list[tuple[str, bytes]]: + certsconf = context.src_path(_CERTSCONF) + certsdir = context.src_path(_CERTSDIR) + if certsconf is None or not certsconf.is_file() or certsdir is None: + return [] + entries = [] + for relative in _parse_certsconf(certsconf): + source = certsdir / relative + if not source.is_file(): + logger.warning("%s not found, but listed in %s", source, certsconf) + continue + entries.append((f"{_CERTSDIR}/{relative}", source.read_bytes())) + return entries + + @staticmethod + def _collect_local_entries(localcertsdir: Path) -> list[tuple[str, bytes]]: + entries = [] + for source in sorted(localcertsdir.rglob("*.crt")): + relative = source.relative_to(localcertsdir).as_posix() + entries.append((f"{_LOCAL_CERTS_DIR}/{relative}", source.read_bytes())) + return entries + + @classmethod + def _update_ca_certificates_conf(cls, context: Context) -> None: + etccertsdir_relative = _resolve_target_dir(context, _ETCCERTSDIR) + etccertsdir = context.dst_path(etccertsdir_relative) localcertsdir = context.dst_path(_LOCAL_CERTS_DIR) etccertsdir.mkdir(parents=True, exist_ok=True) localcertsdir.mkdir(parents=True, exist_ok=True) - args = [ - "update-ca-certificates", - "--etccertsdir", - str(etccertsdir), - "--localcertsdir", - str(localcertsdir), - ] - certsconf = context.src_path(_CERTSCONF) - if certsconf is not None and certsconf.is_file(): - args += ["--certsconf", str(certsconf)] - certsdir = context.src_path(_CERTSDIR) - if certsdir is not None and certsdir.is_dir(): - args += ["--certsdir", str(certsdir)] + # Same processing order as update-ca-certificates: ca-certificates.conf + # entries first, then everything under the local certificate directory. + entries = cls._collect_conf_entries(context) + cls._collect_local_entries( + localcertsdir + ) - logger.debug("running: %s", " ".join(args)) - try: - result = subprocess.run(args, check=True, capture_output=True, text=True) - except FileNotFoundError as exc: - raise RuntimeError( - "update-ca-certificates is not available; the ca-certificates package must be installed " - "in the environment running id-configurator" - ) from exc - except subprocess.CalledProcessError as exc: - raise RuntimeError(f"update-ca-certificates failed:\n{exc.stderr}") from exc - if result.stderr: - logger.debug("%s", result.stderr.strip()) + bundle = bytearray() + for relative, content in entries: + bundle += _ensure_trailing_newline(content) + pem_path = etccertsdir / _pem_name(relative) + pem_path.unlink(missing_ok=True) + pem_path.symlink_to(f"/{relative}") + + context.write_file(f"{etccertsdir_relative}/{_CERT_BUNDLE}", bytes(bundle)) + logger.info("rebuilt %s from %d certificate(s)", _CERT_BUNDLE, len(entries)) + + if etccertsdir_relative == _ETCCERTSDIR: + cert_file = context.dst_path(_OPENSSL_DEFAULT_CERT_FILE) + cert_file.parent.mkdir(parents=True, exist_ok=True) + cert_file.unlink(missing_ok=True) + cert_file.symlink_to(f"/{etccertsdir_relative}/{_CERT_BUNDLE}") + + @staticmethod + def _update_rhel_trust_store(context: Context, configured_names: set[str]) -> None: + for existing in context.iter_src_dir(_RHEL_ANCHORS_DIR): + if existing.stem not in configured_names: + context.write_file( + f"{_RHEL_ANCHORS_DIR}/{existing.name}", existing.read_bytes() + ) + for certificate in context.config.configurations.certificates: + context.write_file( + f"{_RHEL_ANCHORS_DIR}/{certificate.name}.crt", + certificate.content.encode(), + ) + + bundle = bytearray() + existing_bundle = context.read_src_file(_RHEL_EXTRACTED_BUNDLE) + if existing_bundle is not None: + bundle += _ensure_trailing_newline(existing_bundle) + + # Every write above went through context.write_file(), which only ever + # creates regular files, so every entry here is a certificate to add. + anchors = sorted(context.dst_path(_RHEL_ANCHORS_DIR).iterdir()) + for anchor in anchors: + bundle += _ensure_trailing_newline(anchor.read_bytes()) + + context.write_file(_RHEL_EXTRACTED_BUNDLE, bytes(bundle)) + logger.info("rebuilt RHEL/Fedora trust store from %d anchor(s)", len(anchors)) diff --git a/id_configurator/modules/java.py b/id_configurator/modules/java.py index a3b948a..4b30c26 100644 --- a/id_configurator/modules/java.py +++ b/id_configurator/modules/java.py @@ -43,8 +43,8 @@ from pathlib import Path import jks from cryptography import x509 from cryptography.hazmat.primitives.serialization import ( - BestAvailableEncryption, Encoding, + NoEncryption, pkcs12, ) @@ -87,9 +87,13 @@ def _add_to_pkcs12( entries = list(loaded.additional_certs) for named, cert in zip(certificates, certs, strict=True): entries.append(pkcs12.PKCS12Certificate(cert, named.name.encode("utf-8"))) - return pkcs12.serialize_java_truststore( - entries, BestAvailableEncryption(_STORE_PASSWORD) - ) + # Trust-only entries must stay unencrypted, matching how the JDK's own + # cacerts is built: JVM code that never sets javax.net.ssl.trustStore* + # (e.g. Maven) loads the default truststore with a null password, which + # can only skip the integrity check, not decrypt encrypted entries. + # Encrypting them here (e.g. with BestAvailableEncryption) silently + # yields zero usable trust anchors for every such caller. + return pkcs12.serialize_java_truststore(entries, NoEncryption()) def _add_to_jks( diff --git a/tests/configs/Apk.yaml.j2 b/tests/configs/Apk.yaml.j2 index 91a5a3a..eb8c7f7 100644 --- a/tests/configs/Apk.yaml.j2 +++ b/tests/configs/Apk.yaml.j2 @@ -2,13 +2,16 @@ uuid: 3d2f6a44-8b19-4c2b-9e7a-1f5c8a2d6b90 organisation: InterDiode description: Apk test configuration configurations: - certificates: [] + certificates: + - name: Test CA + content: | +{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} apt: sources: [] keys: [] apk: repositories: - - url: http://host.docker.internal:{{ 'APK_REPO_PORT' | env }}/ + - url: https://host.docker.internal:{{ 'APK_REPO_PORT' | env }}/ key: | {{ 'dockerfiles/repos/040d0af5f97a0a44.rsa.pub' | file | indent(8, first=true) }} yum: diff --git a/tests/configs/Java.yaml.j2 b/tests/configs/Java.yaml.j2 index 626806d..d0e278d 100644 --- a/tests/configs/Java.yaml.j2 +++ b/tests/configs/Java.yaml.j2 @@ -2,7 +2,10 @@ uuid: 6a1d4e2c-9f3b-4a5d-8c7e-2b1f9a6d4c3e organisation: InterDiode description: Java test configuration configurations: - certificates: [] + certificates: + - name: Test CA + content: | +{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} apt: sources: [] keys: [] @@ -16,4 +19,4 @@ configurations: java: repositories: - name: central - url: http://host.docker.internal:{{ 'JAVA_REPO_PORT' | env }}/ + url: https://host.docker.internal:{{ 'JAVA_REPO_PORT' | env }}/ diff --git a/tests/configs/Yum.yaml.j2 b/tests/configs/Yum.yaml.j2 index de49fa0..ebf88e2 100644 --- a/tests/configs/Yum.yaml.j2 +++ b/tests/configs/Yum.yaml.j2 @@ -2,7 +2,10 @@ uuid: 8f6a0e2a-5b7a-4d2c-9d1a-3c2b6b9c4b7e organisation: InterDiode description: Yum test configuration configurations: - certificates: [] + certificates: + - name: Test CA + content: | +{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }} apt: sources: [] keys: [] @@ -11,7 +14,7 @@ configurations: yum: repositories: - name: demo - index-url: http://host.docker.internal:{{ 'YUM_REPO_PORT' | env }}/ + index-url: https://host.docker.internal:{{ 'YUM_REPO_PORT' | env }}/ keys: [] python: repositories: [] diff --git a/tests/dockerfiles/Apk.Dockerfile b/tests/dockerfiles/Apk.Dockerfile index d2c4cfd..fcee413 100644 --- a/tests/dockerfiles/Apk.Dockerfile +++ b/tests/dockerfiles/Apk.Dockerfile @@ -1,5 +1,5 @@ FROM alpine:3.20 AS detection -FROM interdiode/id_configurator:latest AS configuration +FROM interdiode/id-configurator:latest AS configuration COPY --from=detection / /src/ COPY --from=generated-config Apk.yaml /config.yaml RUN id-configurator apply --config /config.yaml --src /src --dst /dst diff --git a/tests/dockerfiles/Apt.Dockerfile b/tests/dockerfiles/Apt.Dockerfile index d74a057..77b3430 100644 --- a/tests/dockerfiles/Apt.Dockerfile +++ b/tests/dockerfiles/Apt.Dockerfile @@ -1,10 +1,9 @@ FROM debian:trixie-slim AS detection -FROM interdiode/id_configurator:latest AS configuration +FROM interdiode/id-configurator:latest AS configuration COPY --from=detection / /src/ COPY --from=generated-config Apt.yaml /config.yaml RUN id-configurator apply --config /config.yaml --src /src --dst /dst FROM debian:trixie-slim AS runtime -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates COPY --from=configuration /dst/ / RUN apt-get update \ && apt-get install -y --no-install-recommends idconfiguratortestpkg \ diff --git a/tests/dockerfiles/Java.Dockerfile b/tests/dockerfiles/Java.Dockerfile index fa90129..58aa3c8 100644 --- a/tests/dockerfiles/Java.Dockerfile +++ b/tests/dockerfiles/Java.Dockerfile @@ -1,5 +1,5 @@ FROM maven:3.9-eclipse-temurin-21 AS detection -FROM interdiode/id_configurator:latest AS configuration +FROM interdiode/id-configurator:latest AS configuration COPY --from=detection / /src/ COPY --from=generated-config Java.yaml /config.yaml RUN id-configurator apply --config /config.yaml --src /src --dst /dst diff --git a/tests/dockerfiles/Python.Dockerfile b/tests/dockerfiles/Python.Dockerfile index dee7606..e7d065e 100644 --- a/tests/dockerfiles/Python.Dockerfile +++ b/tests/dockerfiles/Python.Dockerfile @@ -1,5 +1,5 @@ FROM python:3.13-slim AS detection -FROM interdiode/id_configurator:latest AS configuration +FROM interdiode/id-configurator:latest AS configuration COPY --from=detection / /src/ COPY --from=generated-config Python.yaml /config.yaml RUN id-configurator apply --config /config.yaml --src /src --dst /dst diff --git a/tests/dockerfiles/Yum.Dockerfile b/tests/dockerfiles/Yum.Dockerfile index 27d7926..5d8d750 100644 --- a/tests/dockerfiles/Yum.Dockerfile +++ b/tests/dockerfiles/Yum.Dockerfile @@ -1,5 +1,5 @@ FROM rockylinux:9 AS detection -FROM interdiode/id_configurator:latest AS configuration +FROM interdiode/id-configurator:latest AS configuration COPY --from=detection / /src/ COPY --from=generated-config Yum.yaml /config.yaml RUN id-configurator apply --config /config.yaml --src /src --dst /dst diff --git a/tests/dockerfiles/repos/apk-repo.Dockerfile b/tests/dockerfiles/repos/apk-repo.Dockerfile index 4c0c283..9bddd16 100644 --- a/tests/dockerfiles/repos/apk-repo.Dockerfile +++ b/tests/dockerfiles/repos/apk-repo.Dockerfile @@ -1,6 +1,7 @@ # Minimal Alpine (apk) repository with a signed index, serving one hand-built -# .apk package over plain HTTP, used by tox-docker to exercise the `apk` -# module integration test (tests/configs/Apk.yaml.tmpl, +# .apk package over TLS, using the same Test CA as the pypi-repo/apt-repo/ +# yum-repo fixtures, used by tox-docker to exercise the `apk` module +# integration test (tests/configs/Apk.yaml.j2, # tests/dockerfiles/Apk.Dockerfile). The package is signed with the static # key pair 040d0af5f97a0a44.rsa / 040d0af5f97a0a44.rsa.pub, whose public # half is what the `apk` module configuration is expected to install into @@ -39,5 +40,7 @@ RUN arch_dir=$(ls /home/builder/packages/builder) \ FROM nginx:alpine COPY --from=packagebuild /srv /srv +COPY repo-server.crt.pem /etc/nginx/certs/repo-server.crt.pem +COPY repo-server.key.pem /etc/nginx/certs/repo-server.key.pem COPY apk-repo.nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 8443 diff --git a/tests/dockerfiles/repos/apk-repo.nginx.conf b/tests/dockerfiles/repos/apk-repo.nginx.conf index 60ef320..aafed09 100644 --- a/tests/dockerfiles/repos/apk-repo.nginx.conf +++ b/tests/dockerfiles/repos/apk-repo.nginx.conf @@ -1,7 +1,10 @@ server { - listen 8443 default_server; + listen 8443 ssl default_server; server_name _; + ssl_certificate /etc/nginx/certs/repo-server.crt.pem; + ssl_certificate_key /etc/nginx/certs/repo-server.key.pem; + root /srv; autoindex off; } diff --git a/tests/dockerfiles/repos/java-repo.Dockerfile b/tests/dockerfiles/repos/java-repo.Dockerfile index 18af623..8db6e87 100644 --- a/tests/dockerfiles/repos/java-repo.Dockerfile +++ b/tests/dockerfiles/repos/java-repo.Dockerfile @@ -1,6 +1,7 @@ -# Minimal Maven-compatible repository serving one hand-built jar over plain -# HTTP, used by tox-docker to exercise the `java` module integration test -# (tests/configs/Java.yaml.tmpl, tests/dockerfiles/Java.Dockerfile). +# Minimal Maven-compatible repository serving one hand-built jar over TLS, +# using the same Test CA as the pypi-repo/apt-repo/yum-repo/apk-repo +# fixtures, used by tox-docker to exercise the `java` module integration +# test (tests/configs/Java.yaml.j2, tests/dockerfiles/Java.Dockerfile). # # Built by tox-docker, whose build context is this directory (not the repo # root) - every COPY source below must live under tests/dockerfiles/repos/. @@ -10,5 +11,7 @@ RUN python3 /build_test_jar.py FROM nginx:alpine COPY --from=jarbuild /srv /srv +COPY repo-server.crt.pem /etc/nginx/certs/repo-server.crt.pem +COPY repo-server.key.pem /etc/nginx/certs/repo-server.key.pem COPY java-repo.nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 8443 diff --git a/tests/dockerfiles/repos/java-repo.nginx.conf b/tests/dockerfiles/repos/java-repo.nginx.conf index 60ef320..aafed09 100644 --- a/tests/dockerfiles/repos/java-repo.nginx.conf +++ b/tests/dockerfiles/repos/java-repo.nginx.conf @@ -1,7 +1,10 @@ server { - listen 8443 default_server; + listen 8443 ssl default_server; server_name _; + ssl_certificate /etc/nginx/certs/repo-server.crt.pem; + ssl_certificate_key /etc/nginx/certs/repo-server.key.pem; + root /srv; autoindex off; } diff --git a/tests/dockerfiles/repos/yum-repo.Dockerfile b/tests/dockerfiles/repos/yum-repo.Dockerfile index c496ffd..4d9150b 100644 --- a/tests/dockerfiles/repos/yum-repo.Dockerfile +++ b/tests/dockerfiles/repos/yum-repo.Dockerfile @@ -1,7 +1,7 @@ -# Minimal YUM/DNF repository serving one hand-built RPM package over plain -# HTTP (gpgcheck is off in the test config, so no TLS/signing is needed), -# used by tox-docker to exercise the `yum` module integration test -# (tests/configs/Yum.yaml.tmpl, tests/dockerfiles/Yum.Dockerfile). +# Minimal YUM/DNF repository serving one hand-built RPM package over TLS, +# using the same Test CA as the pypi-repo/apt-repo fixtures, used by +# tox-docker to exercise the `yum` module integration test +# (tests/configs/Yum.yaml.j2, tests/dockerfiles/Yum.Dockerfile). # # Built by tox-docker, whose build context is this directory (not the repo # root) - every COPY source below must live under tests/dockerfiles/repos/. @@ -16,5 +16,7 @@ RUN createrepo_c /srv FROM nginx:alpine COPY --from=packagebuild /srv /srv +COPY repo-server.crt.pem /etc/nginx/certs/repo-server.crt.pem +COPY repo-server.key.pem /etc/nginx/certs/repo-server.key.pem COPY yum-repo.nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 8443 diff --git a/tests/dockerfiles/repos/yum-repo.nginx.conf b/tests/dockerfiles/repos/yum-repo.nginx.conf index 60ef320..aafed09 100644 --- a/tests/dockerfiles/repos/yum-repo.nginx.conf +++ b/tests/dockerfiles/repos/yum-repo.nginx.conf @@ -1,7 +1,10 @@ server { - listen 8443 default_server; + listen 8443 ssl default_server; server_name _; + ssl_certificate /etc/nginx/certs/repo-server.crt.pem; + ssl_certificate_key /etc/nginx/certs/repo-server.key.pem; + root /srv; autoindex off; } diff --git a/tests/test_certificates.py b/tests/test_certificates.py index 096cbec..0708f64 100644 --- a/tests/test_certificates.py +++ b/tests/test_certificates.py @@ -1,11 +1,10 @@ -import subprocess -from unittest.mock import MagicMock - -import pytest - from id_configurator.config import Configuration from id_configurator.context import Context -from id_configurator.modules.certificates import CertificatesModule +from id_configurator.modules.certificates import ( + CertificatesModule, + _pem_name, + _resolve_target_dir, +) CERT_CONTENT = "fake-cert-bytes" @@ -32,38 +31,30 @@ def test_is_relevant_false_when_no_certificates(tmp_path) -> None: assert CertificatesModule().is_relevant(context) is False -def _mock_success(monkeypatch, stderr: str = "") -> MagicMock: - mock_run = MagicMock( - return_value=subprocess.CompletedProcess( - args=[], returncode=0, stdout="", stderr=stderr - ) - ) - monkeypatch.setattr("id_configurator.modules.certificates.subprocess.run", mock_run) - return mock_run - - -def test_apply_writes_certificate_and_calls_update_ca_certificates( - tmp_path, monkeypatch -) -> None: - mock_run = _mock_success(monkeypatch) +def test_apply_writes_certificate_and_rebuilds_bundle(tmp_path) -> None: dst = tmp_path / "dst" context = Context(config=make_config(True), dst=dst, src=None) CertificatesModule().apply(context) - written = dst / "usr/local/share/ca-certificates/new-root-ca.crt" - assert written.read_bytes() == b"fake-cert-bytes" - mock_run.assert_called_once() - args = mock_run.call_args.args[0] - assert args[0] == "update-ca-certificates" - assert "--certsconf" not in args - assert "--certsdir" not in args + local_cert = dst / "usr/local/share/ca-certificates/new-root-ca.crt" + assert local_cert.read_bytes() == b"fake-cert-bytes" + + bundle = dst / "etc/ssl/certs/ca-certificates.crt" + assert bundle.read_bytes() == b"fake-cert-bytes\n" + + pem = dst / "etc/ssl/certs/new-root-ca.pem" + assert pem.is_symlink() + assert ( + pem.readlink().as_posix() == "/usr/local/share/ca-certificates/new-root-ca.crt" + ) + + openssl_default = dst / "usr/lib/ssl/cert.pem" + assert openssl_default.is_symlink() + assert openssl_default.readlink().as_posix() == "/etc/ssl/certs/ca-certificates.crt" -def test_apply_preserves_existing_local_certificates_from_src( - tmp_path, monkeypatch -) -> None: - mock_run = _mock_success(monkeypatch, stderr="some warning") +def test_apply_merges_conf_listed_and_local_certificates(tmp_path) -> None: src = tmp_path / "src" local_certs_dir = src / "usr/local/share/ca-certificates" local_certs_dir.mkdir(parents=True) @@ -71,9 +62,14 @@ def test_apply_preserves_existing_local_certificates_from_src( (local_certs_dir / "new-root-ca.crt").write_bytes( b"stale-bytes-should-be-overwritten" ) - (src / "etc/ca-certificates.conf").parent.mkdir(parents=True) - (src / "etc/ca-certificates.conf").write_text("existing.crt\n", encoding="utf-8") - (src / "usr/share/ca-certificates").mkdir(parents=True) + system_certs_dir = src / "usr/share/ca-certificates" + system_certs_dir.mkdir(parents=True) + # No trailing newline, to exercise the newline-normalization on append. + (system_certs_dir / "system-ca.crt").write_bytes(b"system-ca-bytes") + (src / "etc/ca-certificates.conf").parent.mkdir(parents=True, exist_ok=True) + (src / "etc/ca-certificates.conf").write_text( + "# a comment\n\n!disabled-ca.crt\nsystem-ca.crt\n", encoding="utf-8" + ) dst = tmp_path / "dst" context = Context(config=make_config(True), dst=dst, src=src) @@ -84,35 +80,160 @@ def test_apply_preserves_existing_local_certificates_from_src( assert (local_dst / "existing.crt").read_bytes() == b"existing-bytes" assert (local_dst / "new-root-ca.crt").read_bytes() == b"fake-cert-bytes" - args = mock_run.call_args.args[0] - assert "--certsconf" in args - assert str(src / "etc/ca-certificates.conf") in args - assert "--certsdir" in args - assert str(src / "usr/share/ca-certificates") in args + bundle = dst / "etc/ssl/certs/ca-certificates.crt" + # ca-certificates.conf entries first, then local certificates sorted by name. + assert bundle.read_bytes() == ( + b"system-ca-bytes\nexisting-bytes\nfake-cert-bytes\n" + ) + + etc_certs = dst / "etc/ssl/certs" + assert ( + etc_certs / "system-ca.pem" + ).readlink().as_posix() == "/usr/share/ca-certificates/system-ca.crt" + assert ( + etc_certs / "existing.pem" + ).readlink().as_posix() == "/usr/local/share/ca-certificates/existing.crt" + assert ( + etc_certs / "new-root-ca.pem" + ).readlink().as_posix() == "/usr/local/share/ca-certificates/new-root-ca.crt" + # The disabled entry must not produce a symlink or appear in the bundle. + assert not (etc_certs / "disabled-ca.pem").exists() -def test_update_ca_certificates_raises_when_binary_missing( - tmp_path, monkeypatch +def test_apply_warns_and_skips_missing_conf_entry(tmp_path, caplog) -> None: + src = tmp_path / "src" + (src / "usr/share/ca-certificates").mkdir(parents=True) + (src / "etc/ca-certificates.conf").parent.mkdir(parents=True, exist_ok=True) + (src / "etc/ca-certificates.conf").write_text("missing.crt\n", encoding="utf-8") + + dst = tmp_path / "dst" + context = Context(config=make_config(True), dst=dst, src=src) + + with caplog.at_level("WARNING"): + CertificatesModule().apply(context) + + assert "missing.crt" in caplog.text + bundle = dst / "etc/ssl/certs/ca-certificates.crt" + assert bundle.read_bytes() == b"fake-cert-bytes\n" + + +def test_pem_name_sanitizes_special_characters() -> None: + assert _pem_name("usr/share/ca-certificates/New Root, CA.crt") == "New_Root__CA.pem" + assert _pem_name("Vendor (Test).crt") == "Vendor_=Test=.pem" + + +def test_apply_skips_rhel_trust_store_when_absent(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(True), dst=dst, src=None) + + CertificatesModule().apply(context) + + assert not (dst / "etc/pki").exists() + + +def test_apply_updates_rhel_trust_store_when_present(tmp_path) -> None: + src = tmp_path / "src" + anchors_dir = src / "etc/pki/ca-trust/source/anchors" + anchors_dir.mkdir(parents=True) + (anchors_dir / "existing-anchor.pem").write_bytes(b"existing-anchor-bytes") + (anchors_dir / "new-root-ca.crt").write_bytes(b"stale-should-be-overwritten") + extracted_bundle = src / "etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" + extracted_bundle.parent.mkdir(parents=True) + # No trailing newline, to exercise the newline-normalization on append. + extracted_bundle.write_bytes(b"vendor-bundle-bytes") + + dst = tmp_path / "dst" + context = Context(config=make_config(True), dst=dst, src=src) + + CertificatesModule().apply(context) + + anchors_dst = dst / "etc/pki/ca-trust/source/anchors" + assert ( + anchors_dst / "existing-anchor.pem" + ).read_bytes() == b"existing-anchor-bytes" + assert (anchors_dst / "new-root-ca.crt").read_bytes() == b"fake-cert-bytes" + + bundle_dst = dst / "etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" + assert bundle_dst.read_bytes() == ( + b"vendor-bundle-bytes\nexisting-anchor-bytes\nfake-cert-bytes\n" + ) + + +def test_apply_rebuilds_rhel_trust_store_without_preexisting_bundle(tmp_path) -> None: + src = tmp_path / "src" + (src / "etc/pki/ca-trust/source/anchors").mkdir(parents=True) + + dst = tmp_path / "dst" + context = Context(config=make_config(True), dst=dst, src=src) + + CertificatesModule().apply(context) + + bundle_dst = dst / "etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" + assert bundle_dst.read_bytes() == b"fake-cert-bytes\n" + + +def test_resolve_target_dir_returns_unchanged_when_not_a_symlink(tmp_path) -> None: + src = tmp_path / "src" + (src / "etc/ssl/certs").mkdir(parents=True) + context = Context(config=make_config(True), dst=tmp_path / "dst", src=src) + + assert _resolve_target_dir(context, "etc/ssl/certs") == "etc/ssl/certs" + + +def test_resolve_target_dir_returns_unchanged_without_src(tmp_path) -> None: + context = Context(config=make_config(True), dst=tmp_path / "dst", src=None) + + assert _resolve_target_dir(context, "etc/ssl/certs") == "etc/ssl/certs" + + +def test_resolve_target_dir_follows_absolute_symlink(tmp_path) -> None: + src = tmp_path / "src" + (src / "etc/pki/tls/certs").mkdir(parents=True) + (src / "etc/ssl").mkdir(parents=True) + (src / "etc/ssl/certs").symlink_to("/etc/pki/tls/certs") + context = Context(config=make_config(True), dst=tmp_path / "dst", src=src) + + assert _resolve_target_dir(context, "etc/ssl/certs") == "etc/pki/tls/certs" + + +def test_resolve_target_dir_follows_relative_symlink(tmp_path) -> None: + src = tmp_path / "src" + (src / "etc/pki/tls/certs").mkdir(parents=True) + (src / "etc/ssl").mkdir(parents=True) + (src / "etc/ssl/certs").symlink_to("../pki/tls/certs") + context = Context(config=make_config(True), dst=tmp_path / "dst", src=src) + + assert _resolve_target_dir(context, "etc/ssl/certs") == "etc/pki/tls/certs" + + +def test_resolve_target_dir_falls_back_when_relative_symlink_escapes_src( + tmp_path, ) -> None: - monkeypatch.setattr( - "id_configurator.modules.certificates.subprocess.run", - MagicMock(side_effect=FileNotFoundError()), - ) - context = Context(config=make_config(True), dst=tmp_path / "dst", src=None) + src = tmp_path / "src" + (src / "etc/ssl").mkdir(parents=True) + # Escapes past itself via enough ".." components. + (src / "etc/ssl/certs").symlink_to("../../../../../outside") + context = Context(config=make_config(True), dst=tmp_path / "dst", src=src) - with pytest.raises(RuntimeError, match="update-ca-certificates is not available"): - CertificatesModule().apply(context) + assert _resolve_target_dir(context, "etc/ssl/certs") == "etc/ssl/certs" -def test_update_ca_certificates_raises_on_failure(tmp_path, monkeypatch) -> None: - error = subprocess.CalledProcessError( - returncode=1, cmd=["update-ca-certificates"], stderr="boom" - ) - monkeypatch.setattr( - "id_configurator.modules.certificates.subprocess.run", - MagicMock(side_effect=error), - ) - context = Context(config=make_config(True), dst=tmp_path / "dst", src=None) +def test_apply_writes_bundle_to_rhel_style_symlinked_etccertsdir(tmp_path) -> None: + src = tmp_path / "src" + (src / "etc/pki/tls/certs").mkdir(parents=True) + (src / "etc/ssl").mkdir(parents=True) + (src / "etc/ssl/certs").symlink_to("/etc/pki/tls/certs") - with pytest.raises(RuntimeError, match="update-ca-certificates failed"): - CertificatesModule().apply(context) + dst = tmp_path / "dst" + context = Context(config=make_config(True), dst=dst, src=src) + + CertificatesModule().apply(context) + + assert not (dst / "etc/ssl/certs").exists() + bundle = dst / "etc/pki/tls/certs/ca-certificates.crt" + assert bundle.read_bytes() == b"fake-cert-bytes\n" + pem = dst / "etc/pki/tls/certs/new-root-ca.pem" + assert pem.is_symlink() + # /usr/lib/ssl/cert.pem is Debian/Ubuntu-specific: not OpenSSL's default + # CAfile on this (RHEL-style) system, so it must not be created. + assert not (dst / "usr/lib/ssl/cert.pem").exists() diff --git a/tests/test_cli.py b/tests/test_cli.py index 4784503..9d928db 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,4 @@ import json -import subprocess from unittest.mock import MagicMock from typer.testing import CliRunner @@ -66,14 +65,7 @@ def test_apply_skips_irrelevant_modules(tmp_path, caplog) -> None: assert "skipping module python" in caplog.text -def test_apply_runs_relevant_module(tmp_path, monkeypatch) -> None: - mock_run = MagicMock( - return_value=subprocess.CompletedProcess( - args=[], returncode=0, stdout="", stderr="" - ) - ) - monkeypatch.setattr("id_configurator.modules.certificates.subprocess.run", mock_run) - +def test_apply_runs_relevant_module(tmp_path) -> None: config_path = write_config(tmp_path, CONFIG_WITH_CERT) dst = tmp_path / "dst" result = runner.invoke( @@ -82,7 +74,7 @@ def test_apply_runs_relevant_module(tmp_path, monkeypatch) -> None: assert result.exit_code == 0 assert (dst / "usr/local/share/ca-certificates/my-ca.crt").read_bytes() == b"fake" - mock_run.assert_called_once() + assert (dst / "etc/ssl/certs/ca-certificates.crt").read_bytes() == b"fake\n" def test_main_default_name_calls_app(monkeypatch) -> None: diff --git a/tests/test_java.py b/tests/test_java.py index 74923b2..931534f 100644 --- a/tests/test_java.py +++ b/tests/test_java.py @@ -122,8 +122,11 @@ def test_apply_adds_certificate_to_pkcs12_and_jks_keystores(tmp_path) -> None: JavaModule().apply(context) + # The written truststore's trust-only entries are unencrypted (matching + # the JDK's own cacerts), so they load with no password at all - the + # password a caller would use for a source-of-truth JDK-style store. pkcs12_result = pkcs12.load_pkcs12( - (dst / "usr/lib/jvm/java-21/lib/security/cacerts").read_bytes(), b"changeit" + (dst / "usr/lib/jvm/java-21/lib/security/cacerts").read_bytes(), None ) pkcs12_aliases = {entry.friendly_name for entry in pkcs12_result.additional_certs} assert pkcs12_aliases == {b"already-trusted-ca", b"interdiode-root-ca"} diff --git a/tox.ini b/tox.ini index 6e6071b..1877b99 100644 --- a/tox.ini +++ b/tox.ini @@ -106,7 +106,7 @@ commands = dockerfile = {tox_root}/tests/dockerfiles/repos/yum-repo.Dockerfile expose = YUM_REPO_PORT=8443/tcp -healthcheck_cmd = wget -qO- http://127.0.0.1:8443/repodata/repomd.xml || exit 1 +healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/repodata/repomd.xml || exit 1 healthcheck_interval = 1 healthcheck_retries = 30 healthcheck_timeout = 2 @@ -128,7 +128,7 @@ commands = dockerfile = {tox_root}/tests/dockerfiles/repos/apk-repo.Dockerfile expose = APK_REPO_PORT=8443/tcp -healthcheck_cmd = wget -qO- http://127.0.0.1:8443/APKINDEX.tar.gz || exit 1 +healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/APKINDEX.tar.gz || exit 1 healthcheck_interval = 1 healthcheck_retries = 30 healthcheck_timeout = 2 @@ -159,7 +159,7 @@ commands = dockerfile = {tox_root}/tests/dockerfiles/repos/java-repo.Dockerfile expose = JAVA_REPO_PORT=8443/tcp -healthcheck_cmd = wget -qO- http://127.0.0.1:8443/org/interdiode/idconfiguratortestpkg/0.0.1/idconfiguratortestpkg-0.0.1.pom || exit 1 +healthcheck_cmd = wget --no-check-certificate -qO- https://127.0.0.1:8443/org/interdiode/idconfiguratortestpkg/0.0.1/idconfiguratortestpkg-0.0.1.pom || exit 1 healthcheck_interval = 1 healthcheck_retries = 30 healthcheck_timeout = 2