feat: update Dockerfiles and configuration for TLS support across multiple modules

Signed-off-by: d9pouces <github@19pouces.net>
This commit is contained in:
d9pouces
2026-09-05 22:23:34 +02:00
parent fc41dfaa07
commit a5a36fc7cd
20 changed files with 441 additions and 132 deletions
+195 -31
View File
@@ -1,15 +1,52 @@
# noinspection SpellCheckingInspection
"""Install root certificates into the system's CA store. """Install root certificates into the system's CA store.
Certificates from the configuration are dropped into the "local" CA Certificates from the configuration are dropped into the "local" CA
directory, then the real `update-ca-certificates` binary is invoked to directory, then the trust store under `/etc/ssl/certs` (bundle + named
rebuild the trust store (bundle + hashed symlinks) exactly like it would on symlinks) is rebuilt in pure Python, mirroring what Debian's
a real Debian/Ubuntu system, instead of reimplementing that logic here. `update-ca-certificates` shell script does: every certificate enabled in
`ca-certificates.conf` (read from `<src>`), plus everything found under the
local certificates directory, is concatenated into a single bundle file and
gets a `<name>.pem` symlink pointing at it.
The OpenSSL subject-hash symlinks (`<hash>.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 `<src>` (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 `<src>` 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 from __future__ import annotations
import logging import logging
import subprocess import posixpath
from pathlib import Path
from id_configurator.context import Context from id_configurator.context import Context
from id_configurator.modules.base import Module, register from id_configurator.modules.base import Module, register
@@ -17,11 +54,85 @@ from id_configurator.modules.base import Module, register
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_LOCAL_CERTS_DIR = "usr/local/share/ca-certificates" _LOCAL_CERTS_DIR = "usr/local/share/ca-certificates"
# noinspection SpellCheckingInspection
_CERTSCONF = "etc/ca-certificates.conf" _CERTSCONF = "etc/ca-certificates.conf"
# noinspection SpellCheckingInspection
_CERTSDIR = "usr/share/ca-certificates" _CERTSDIR = "usr/share/ca-certificates"
# noinspection SpellCheckingInspection
_ETCCERTSDIR = "etc/ssl/certs" _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 `<src>`, 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 `<src>`, or if it can't be resolved back to a path
under `<src>`.
"""
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 @register
class CertificatesModule(Module): class CertificatesModule(Module):
name = "certificates" name = "certificates"
@@ -46,37 +157,90 @@ class CertificatesModule(Module):
) )
logger.info("installed root certificate %s", certificate.name) logger.info("installed root certificate %s", certificate.name)
self._update_ca_certificates_conf(context) self._update_ca_certificates_conf(context)
if self._rhel_trust_store_present(context):
self._update_rhel_trust_store(context, configured_names)
@staticmethod @staticmethod
def _update_ca_certificates_conf(context: Context) -> None: def _rhel_trust_store_present(context: Context) -> bool:
etccertsdir = context.dst_path(_ETCCERTSDIR) 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) localcertsdir = context.dst_path(_LOCAL_CERTS_DIR)
etccertsdir.mkdir(parents=True, exist_ok=True) etccertsdir.mkdir(parents=True, exist_ok=True)
localcertsdir.mkdir(parents=True, exist_ok=True) localcertsdir.mkdir(parents=True, exist_ok=True)
args = [ # Same processing order as update-ca-certificates: ca-certificates.conf
"update-ca-certificates", # entries first, then everything under the local certificate directory.
"--etccertsdir", entries = cls._collect_conf_entries(context) + cls._collect_local_entries(
str(etccertsdir), localcertsdir
"--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)]
logger.debug("running: %s", " ".join(args)) bundle = bytearray()
try: for relative, content in entries:
result = subprocess.run(args, check=True, capture_output=True, text=True) bundle += _ensure_trailing_newline(content)
except FileNotFoundError as exc: pem_path = etccertsdir / _pem_name(relative)
raise RuntimeError( pem_path.unlink(missing_ok=True)
"update-ca-certificates is not available; the ca-certificates package must be installed " pem_path.symlink_to(f"/{relative}")
"in the environment running id-configurator"
) from exc context.write_file(f"{etccertsdir_relative}/{_CERT_BUNDLE}", bytes(bundle))
except subprocess.CalledProcessError as exc: logger.info("rebuilt %s from %d certificate(s)", _CERT_BUNDLE, len(entries))
raise RuntimeError(f"update-ca-certificates failed:\n{exc.stderr}") from exc
if result.stderr: if etccertsdir_relative == _ETCCERTSDIR:
logger.debug("%s", result.stderr.strip()) 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))
+8 -4
View File
@@ -43,8 +43,8 @@ from pathlib import Path
import jks import jks
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.primitives.serialization import ( from cryptography.hazmat.primitives.serialization import (
BestAvailableEncryption,
Encoding, Encoding,
NoEncryption,
pkcs12, pkcs12,
) )
@@ -87,9 +87,13 @@ def _add_to_pkcs12(
entries = list(loaded.additional_certs) entries = list(loaded.additional_certs)
for named, cert in zip(certificates, certs, strict=True): for named, cert in zip(certificates, certs, strict=True):
entries.append(pkcs12.PKCS12Certificate(cert, named.name.encode("utf-8"))) entries.append(pkcs12.PKCS12Certificate(cert, named.name.encode("utf-8")))
return pkcs12.serialize_java_truststore( # Trust-only entries must stay unencrypted, matching how the JDK's own
entries, BestAvailableEncryption(_STORE_PASSWORD) # 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( def _add_to_jks(
+5 -2
View File
@@ -2,13 +2,16 @@ uuid: 3d2f6a44-8b19-4c2b-9e7a-1f5c8a2d6b90
organisation: InterDiode organisation: InterDiode
description: Apk test configuration description: Apk test configuration
configurations: configurations:
certificates: [] certificates:
- name: Test CA
content: |
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: [] keys: []
apk: apk:
repositories: repositories:
- url: http://host.docker.internal:{{ 'APK_REPO_PORT' | env }}/ - url: https://host.docker.internal:{{ 'APK_REPO_PORT' | env }}/
key: | key: |
{{ 'dockerfiles/repos/040d0af5f97a0a44.rsa.pub' | file | indent(8, first=true) }} {{ 'dockerfiles/repos/040d0af5f97a0a44.rsa.pub' | file | indent(8, first=true) }}
yum: yum:
+5 -2
View File
@@ -2,7 +2,10 @@ uuid: 6a1d4e2c-9f3b-4a5d-8c7e-2b1f9a6d4c3e
organisation: InterDiode organisation: InterDiode
description: Java test configuration description: Java test configuration
configurations: configurations:
certificates: [] certificates:
- name: Test CA
content: |
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: [] keys: []
@@ -16,4 +19,4 @@ configurations:
java: java:
repositories: repositories:
- name: central - name: central
url: http://host.docker.internal:{{ 'JAVA_REPO_PORT' | env }}/ url: https://host.docker.internal:{{ 'JAVA_REPO_PORT' | env }}/
+5 -2
View File
@@ -2,7 +2,10 @@ uuid: 8f6a0e2a-5b7a-4d2c-9d1a-3c2b6b9c4b7e
organisation: InterDiode organisation: InterDiode
description: Yum test configuration description: Yum test configuration
configurations: configurations:
certificates: [] certificates:
- name: Test CA
content: |
{{ 'certs/test-ca.crt.pem' | file | indent(6, first=true) }}
apt: apt:
sources: [] sources: []
keys: [] keys: []
@@ -11,7 +14,7 @@ configurations:
yum: yum:
repositories: repositories:
- name: demo - name: demo
index-url: http://host.docker.internal:{{ 'YUM_REPO_PORT' | env }}/ index-url: https://host.docker.internal:{{ 'YUM_REPO_PORT' | env }}/
keys: [] keys: []
python: python:
repositories: [] repositories: []
+1 -1
View File
@@ -1,5 +1,5 @@
FROM alpine:3.20 AS detection 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=detection / /src/
COPY --from=generated-config Apk.yaml /config.yaml COPY --from=generated-config Apk.yaml /config.yaml
RUN id-configurator apply --config /config.yaml --src /src --dst /dst RUN id-configurator apply --config /config.yaml --src /src --dst /dst
+1 -2
View File
@@ -1,10 +1,9 @@
FROM debian:trixie-slim AS detection 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=detection / /src/
COPY --from=generated-config Apt.yaml /config.yaml COPY --from=generated-config Apt.yaml /config.yaml
RUN id-configurator apply --config /config.yaml --src /src --dst /dst RUN id-configurator apply --config /config.yaml --src /src --dst /dst
FROM debian:trixie-slim AS runtime FROM debian:trixie-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates
COPY --from=configuration /dst/ / COPY --from=configuration /dst/ /
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends idconfiguratortestpkg \ && apt-get install -y --no-install-recommends idconfiguratortestpkg \
+1 -1
View File
@@ -1,5 +1,5 @@
FROM maven:3.9-eclipse-temurin-21 AS detection 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=detection / /src/
COPY --from=generated-config Java.yaml /config.yaml COPY --from=generated-config Java.yaml /config.yaml
RUN id-configurator apply --config /config.yaml --src /src --dst /dst RUN id-configurator apply --config /config.yaml --src /src --dst /dst
+1 -1
View File
@@ -1,5 +1,5 @@
FROM python:3.13-slim AS detection 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=detection / /src/
COPY --from=generated-config Python.yaml /config.yaml COPY --from=generated-config Python.yaml /config.yaml
RUN id-configurator apply --config /config.yaml --src /src --dst /dst RUN id-configurator apply --config /config.yaml --src /src --dst /dst
+1 -1
View File
@@ -1,5 +1,5 @@
FROM rockylinux:9 AS detection 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=detection / /src/
COPY --from=generated-config Yum.yaml /config.yaml COPY --from=generated-config Yum.yaml /config.yaml
RUN id-configurator apply --config /config.yaml --src /src --dst /dst RUN id-configurator apply --config /config.yaml --src /src --dst /dst
+5 -2
View File
@@ -1,6 +1,7 @@
# Minimal Alpine (apk) repository with a signed index, serving one hand-built # 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` # .apk package over TLS, using the same Test CA as the pypi-repo/apt-repo/
# module integration test (tests/configs/Apk.yaml.tmpl, # 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 # tests/dockerfiles/Apk.Dockerfile). The package is signed with the static
# key pair 040d0af5f97a0a44.rsa / 040d0af5f97a0a44.rsa.pub, whose public # key pair 040d0af5f97a0a44.rsa / 040d0af5f97a0a44.rsa.pub, whose public
# half is what the `apk` module configuration is expected to install into # 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 FROM nginx:alpine
COPY --from=packagebuild /srv /srv 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 COPY apk-repo.nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8443 EXPOSE 8443
+4 -1
View File
@@ -1,7 +1,10 @@
server { server {
listen 8443 default_server; listen 8443 ssl default_server;
server_name _; server_name _;
ssl_certificate /etc/nginx/certs/repo-server.crt.pem;
ssl_certificate_key /etc/nginx/certs/repo-server.key.pem;
root /srv; root /srv;
autoindex off; autoindex off;
} }
+6 -3
View File
@@ -1,6 +1,7 @@
# Minimal Maven-compatible repository serving one hand-built jar over plain # Minimal Maven-compatible repository serving one hand-built jar over TLS,
# HTTP, used by tox-docker to exercise the `java` module integration test # using the same Test CA as the pypi-repo/apt-repo/yum-repo/apk-repo
# (tests/configs/Java.yaml.tmpl, tests/dockerfiles/Java.Dockerfile). # 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 # Built by tox-docker, whose build context is this directory (not the repo
# root) - every COPY source below must live under tests/dockerfiles/repos/. # root) - every COPY source below must live under tests/dockerfiles/repos/.
@@ -10,5 +11,7 @@ RUN python3 /build_test_jar.py
FROM nginx:alpine FROM nginx:alpine
COPY --from=jarbuild /srv /srv 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 COPY java-repo.nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8443 EXPOSE 8443
+4 -1
View File
@@ -1,7 +1,10 @@
server { server {
listen 8443 default_server; listen 8443 ssl default_server;
server_name _; server_name _;
ssl_certificate /etc/nginx/certs/repo-server.crt.pem;
ssl_certificate_key /etc/nginx/certs/repo-server.key.pem;
root /srv; root /srv;
autoindex off; autoindex off;
} }
+6 -4
View File
@@ -1,7 +1,7 @@
# Minimal YUM/DNF repository serving one hand-built RPM package over plain # Minimal YUM/DNF repository serving one hand-built RPM package over TLS,
# HTTP (gpgcheck is off in the test config, so no TLS/signing is needed), # using the same Test CA as the pypi-repo/apt-repo fixtures, used by
# used by tox-docker to exercise the `yum` module integration test # tox-docker to exercise the `yum` module integration test
# (tests/configs/Yum.yaml.tmpl, tests/dockerfiles/Yum.Dockerfile). # (tests/configs/Yum.yaml.j2, tests/dockerfiles/Yum.Dockerfile).
# #
# Built by tox-docker, whose build context is this directory (not the repo # Built by tox-docker, whose build context is this directory (not the repo
# root) - every COPY source below must live under tests/dockerfiles/repos/. # root) - every COPY source below must live under tests/dockerfiles/repos/.
@@ -16,5 +16,7 @@ RUN createrepo_c /srv
FROM nginx:alpine FROM nginx:alpine
COPY --from=packagebuild /srv /srv 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 COPY yum-repo.nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8443 EXPOSE 8443
+4 -1
View File
@@ -1,7 +1,10 @@
server { server {
listen 8443 default_server; listen 8443 ssl default_server;
server_name _; server_name _;
ssl_certificate /etc/nginx/certs/repo-server.crt.pem;
ssl_certificate_key /etc/nginx/certs/repo-server.key.pem;
root /srv; root /srv;
autoindex off; autoindex off;
} }
+180 -59
View File
@@ -1,11 +1,10 @@
import subprocess
from unittest.mock import MagicMock
import pytest
from id_configurator.config import Configuration from id_configurator.config import Configuration
from id_configurator.context import Context 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" 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 assert CertificatesModule().is_relevant(context) is False
def _mock_success(monkeypatch, stderr: str = "") -> MagicMock: def test_apply_writes_certificate_and_rebuilds_bundle(tmp_path) -> None:
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)
dst = tmp_path / "dst" dst = tmp_path / "dst"
context = Context(config=make_config(True), dst=dst, src=None) context = Context(config=make_config(True), dst=dst, src=None)
CertificatesModule().apply(context) CertificatesModule().apply(context)
written = dst / "usr/local/share/ca-certificates/new-root-ca.crt" local_cert = dst / "usr/local/share/ca-certificates/new-root-ca.crt"
assert written.read_bytes() == b"fake-cert-bytes" assert local_cert.read_bytes() == b"fake-cert-bytes"
mock_run.assert_called_once()
args = mock_run.call_args.args[0] bundle = dst / "etc/ssl/certs/ca-certificates.crt"
assert args[0] == "update-ca-certificates" assert bundle.read_bytes() == b"fake-cert-bytes\n"
assert "--certsconf" not in args
assert "--certsdir" not in args 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( def test_apply_merges_conf_listed_and_local_certificates(tmp_path) -> None:
tmp_path, monkeypatch
) -> None:
mock_run = _mock_success(monkeypatch, stderr="some warning")
src = tmp_path / "src" src = tmp_path / "src"
local_certs_dir = src / "usr/local/share/ca-certificates" local_certs_dir = src / "usr/local/share/ca-certificates"
local_certs_dir.mkdir(parents=True) 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( (local_certs_dir / "new-root-ca.crt").write_bytes(
b"stale-bytes-should-be-overwritten" b"stale-bytes-should-be-overwritten"
) )
(src / "etc/ca-certificates.conf").parent.mkdir(parents=True) system_certs_dir = src / "usr/share/ca-certificates"
(src / "etc/ca-certificates.conf").write_text("existing.crt\n", encoding="utf-8") system_certs_dir.mkdir(parents=True)
(src / "usr/share/ca-certificates").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" dst = tmp_path / "dst"
context = Context(config=make_config(True), dst=dst, src=src) 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 / "existing.crt").read_bytes() == b"existing-bytes"
assert (local_dst / "new-root-ca.crt").read_bytes() == b"fake-cert-bytes" assert (local_dst / "new-root-ca.crt").read_bytes() == b"fake-cert-bytes"
args = mock_run.call_args.args[0] bundle = dst / "etc/ssl/certs/ca-certificates.crt"
assert "--certsconf" in args # ca-certificates.conf entries first, then local certificates sorted by name.
assert str(src / "etc/ca-certificates.conf") in args assert bundle.read_bytes() == (
assert "--certsdir" in args b"system-ca-bytes\nexisting-bytes\nfake-cert-bytes\n"
assert str(src / "usr/share/ca-certificates") in args )
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( def test_apply_warns_and_skips_missing_conf_entry(tmp_path, caplog) -> None:
tmp_path, monkeypatch 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: ) -> None:
monkeypatch.setattr( src = tmp_path / "src"
"id_configurator.modules.certificates.subprocess.run", (src / "etc/ssl").mkdir(parents=True)
MagicMock(side_effect=FileNotFoundError()), # Escapes past <src> itself via enough ".." components.
) (src / "etc/ssl/certs").symlink_to("../../../../../outside")
context = Context(config=make_config(True), dst=tmp_path / "dst", src=None) context = Context(config=make_config(True), dst=tmp_path / "dst", src=src)
with pytest.raises(RuntimeError, match="update-ca-certificates is not available"): assert _resolve_target_dir(context, "etc/ssl/certs") == "etc/ssl/certs"
CertificatesModule().apply(context)
def test_update_ca_certificates_raises_on_failure(tmp_path, monkeypatch) -> None: def test_apply_writes_bundle_to_rhel_style_symlinked_etccertsdir(tmp_path) -> None:
error = subprocess.CalledProcessError( src = tmp_path / "src"
returncode=1, cmd=["update-ca-certificates"], stderr="boom" (src / "etc/pki/tls/certs").mkdir(parents=True)
) (src / "etc/ssl").mkdir(parents=True)
monkeypatch.setattr( (src / "etc/ssl/certs").symlink_to("/etc/pki/tls/certs")
"id_configurator.modules.certificates.subprocess.run",
MagicMock(side_effect=error),
)
context = Context(config=make_config(True), dst=tmp_path / "dst", src=None)
with pytest.raises(RuntimeError, match="update-ca-certificates failed"): dst = tmp_path / "dst"
CertificatesModule().apply(context) 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()
+2 -10
View File
@@ -1,5 +1,4 @@
import json import json
import subprocess
from unittest.mock import MagicMock from unittest.mock import MagicMock
from typer.testing import CliRunner 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 assert "skipping module python" in caplog.text
def test_apply_runs_relevant_module(tmp_path, monkeypatch) -> None: def test_apply_runs_relevant_module(tmp_path) -> None:
mock_run = MagicMock(
return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout="", stderr=""
)
)
monkeypatch.setattr("id_configurator.modules.certificates.subprocess.run", mock_run)
config_path = write_config(tmp_path, CONFIG_WITH_CERT) config_path = write_config(tmp_path, CONFIG_WITH_CERT)
dst = tmp_path / "dst" dst = tmp_path / "dst"
result = runner.invoke( result = runner.invoke(
@@ -82,7 +74,7 @@ def test_apply_runs_relevant_module(tmp_path, monkeypatch) -> None:
assert result.exit_code == 0 assert result.exit_code == 0
assert (dst / "usr/local/share/ca-certificates/my-ca.crt").read_bytes() == b"fake" 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: def test_main_default_name_calls_app(monkeypatch) -> None:
+4 -1
View File
@@ -122,8 +122,11 @@ def test_apply_adds_certificate_to_pkcs12_and_jks_keystores(tmp_path) -> None:
JavaModule().apply(context) 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( 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} pkcs12_aliases = {entry.friendly_name for entry in pkcs12_result.additional_certs}
assert pkcs12_aliases == {b"already-trusted-ca", b"interdiode-root-ca"} assert pkcs12_aliases == {b"already-trusted-ca", b"interdiode-root-ca"}
+3 -3
View File
@@ -106,7 +106,7 @@ commands =
dockerfile = {tox_root}/tests/dockerfiles/repos/yum-repo.Dockerfile dockerfile = {tox_root}/tests/dockerfiles/repos/yum-repo.Dockerfile
expose = expose =
YUM_REPO_PORT=8443/tcp 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_interval = 1
healthcheck_retries = 30 healthcheck_retries = 30
healthcheck_timeout = 2 healthcheck_timeout = 2
@@ -128,7 +128,7 @@ commands =
dockerfile = {tox_root}/tests/dockerfiles/repos/apk-repo.Dockerfile dockerfile = {tox_root}/tests/dockerfiles/repos/apk-repo.Dockerfile
expose = expose =
APK_REPO_PORT=8443/tcp 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_interval = 1
healthcheck_retries = 30 healthcheck_retries = 30
healthcheck_timeout = 2 healthcheck_timeout = 2
@@ -159,7 +159,7 @@ commands =
dockerfile = {tox_root}/tests/dockerfiles/repos/java-repo.Dockerfile dockerfile = {tox_root}/tests/dockerfiles/repos/java-repo.Dockerfile
expose = expose =
JAVA_REPO_PORT=8443/tcp 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_interval = 1
healthcheck_retries = 30 healthcheck_retries = 30
healthcheck_timeout = 2 healthcheck_timeout = 2