mirror of
https://github.com/d9pouces/id_configurator.git
synced 2026-11-09 01:47:21 +01:00
feat: update Dockerfiles and configuration for TLS support across multiple modules
Signed-off-by: d9pouces <github@19pouces.net>
This commit is contained in:
@@ -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 `<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
|
||||
|
||||
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 `<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
|
||||
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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}/
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+181
-60
@@ -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 <src> itself via enough ".." components.
|
||||
(src / "etc/ssl/certs").symlink_to("../../../../../outside")
|
||||
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_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")
|
||||
|
||||
dst = tmp_path / "dst"
|
||||
context = Context(config=make_config(True), dst=dst, src=src)
|
||||
|
||||
with pytest.raises(RuntimeError, match="update-ca-certificates is not available"):
|
||||
CertificatesModule().apply(context)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
with pytest.raises(RuntimeError, match="update-ca-certificates failed"):
|
||||
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
@@ -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:
|
||||
|
||||
+4
-1
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user