From db4ff3cfd4933040b0bccdaa90768bb3fe6e7f8e Mon Sep 17 00:00:00 2001 From: d9pouces Date: Sun, 20 Sep 2026 20:10:52 +0200 Subject: [PATCH] feat: add support for certificate configuration in npm and php modules, update repository URLs in configuration files Signed-off-by: d9pouces --- id_configurator/modules/npm.py | 36 ++++++++++++++++++-- id_configurator/modules/php.py | 28 +++++++++++++-- tests/configs/Apk.yaml.j2 | 1 + tests/configs/Apt.yaml.j2 | 1 + tests/configs/Yum.yaml.j2 | 1 + tests/test_npm.py | 62 ++++++++++++++++++++++++++++++++++ tests/test_php.py | 39 +++++++++++++++++++++ 7 files changed, 164 insertions(+), 4 deletions(-) diff --git a/id_configurator/modules/npm.py b/id_configurator/modules/npm.py index 14b4c92..193d4ef 100644 --- a/id_configurator/modules/npm.py +++ b/id_configurator/modules/npm.py @@ -15,6 +15,19 @@ separate, higher-precedence level in npm's own terminology. `$prefix` defaults to `/usr/local` for essentially every standard Node.js install (the official Docker image included); unlike a config dropped in one specific user's home, this applies regardless of which user invokes npm. + +`cafile` only covers npm's own registry client, though - it does nothing for +Node itself, e.g. a package's install script or the application's own code +making an HTTPS request. Node has no config file for that: the only way to +extend its trust store is the `NODE_EXTRA_CA_CERTS` environment variable, +read once at process startup. So whenever any certificate is configured - +regardless of whether any npm repository is - it is also set system-wide in +`/etc/environment` (merged with whatever is already there), which every +login-based process (an SSH session, `su -`, ...) picks up via PAM's +`pam_env`. This does not reach a `docker build`/`RUN` step or a container's +own `ENTRYPOINT`/`CMD`, which never go through a login shell: propagating it +there is the resulting image's own `Dockerfile`'s job (a plain `ENV` +instruction), outside what a tool that only writes files can do. """ from __future__ import annotations @@ -29,6 +42,8 @@ logger = logging.getLogger(__name__) _NPMRC = "usr/local/etc/npmrc" _CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt" +_ETC_ENVIRONMENT = "etc/environment" +_NODE_EXTRA_CA_CERTS_VAR = "NODE_EXTRA_CA_CERTS" def _write_npmrc(context: Context, repositories: list[NpmRepository]) -> None: @@ -41,12 +56,29 @@ def _write_npmrc(context: Context, repositories: list[NpmRepository]) -> None: logger.info("configured npm to use %s", repositories[0].url) +def _update_etc_environment(context: Context) -> None: + existing = context.read_src_file(_ETC_ENVIRONMENT) + lines = [] if existing is None else existing.decode("utf-8").splitlines() + lines = [ + line for line in lines if not line.startswith(f"{_NODE_EXTRA_CA_CERTS_VAR}=") + ] + lines.append(f"{_NODE_EXTRA_CA_CERTS_VAR}={_CA_BUNDLE}") + context.write_file(_ETC_ENVIRONMENT, ("\n".join(lines) + "\n").encode("utf-8")) + logger.info("configured %s in %s", _NODE_EXTRA_CA_CERTS_VAR, _ETC_ENVIRONMENT) + + @register class NpmModule(Module): name = "npm" def is_relevant(self, context: Context) -> bool: - return bool(context.config.configurations.npm.repositories) + return bool(context.config.configurations.npm.repositories) or bool( + context.config.configurations.certificates + ) def apply(self, context: Context) -> None: - _write_npmrc(context, context.config.configurations.npm.repositories) + repositories = context.config.configurations.npm.repositories + if repositories: + _write_npmrc(context, repositories) + if context.config.configurations.certificates: + _update_etc_environment(context) diff --git a/id_configurator/modules/php.py b/id_configurator/modules/php.py index 3641a2c..5479f58 100644 --- a/id_configurator/modules/php.py +++ b/id_configurator/modules/php.py @@ -14,6 +14,17 @@ Composer's own HTTPS client (PHP's stream wrapper, generally backed by curl) already trusts whatever the `certificates` module installs into the system CA store, so no per-repository certificate configuration is needed here, unlike Poetry's. + +Whenever any certificate is configured - regardless of whether any PHP +repository is - `openssl.cafile` is also set, pointing at the same bundle, +in a dedicated `conf.d` snippet: `usr/local/etc/php/conf.d/id-configurator- +openssl.ini`, the directory the official PHP Docker images scan for extra +`.ini` files (`docker-php-ext-enable`'s own mechanism), applied regardless +of which SAPI (cli, fpm, apache) or php.ini is in use. This is normally +inferred from OpenSSL's own compiled-in default CAfile (which the +`certificates` module also rebuilds), but not every PHP build honors that +default, so setting it explicitly makes every PHP script's own OpenSSL-based +code (not just Composer's) reliably trust the configured certificates too. """ from __future__ import annotations @@ -28,6 +39,8 @@ from id_configurator.modules.base import Module, register logger = logging.getLogger(__name__) _COMPOSER_CONFIG = "root/.composer/config.json" +_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt" +_OPENSSL_INI = "usr/local/etc/php/conf.d/id-configurator-openssl.ini" def _write_composer_config(context: Context, repositories: list[PhpRepository]) -> None: @@ -45,12 +58,23 @@ def _write_composer_config(context: Context, repositories: list[PhpRepository]) ) +def _write_openssl_ini(context: Context) -> None: + context.write_file(_OPENSSL_INI, f"openssl.cafile={_CA_BUNDLE}\n".encode()) + logger.info("configured openssl.cafile to use %s", _CA_BUNDLE) + + @register class PhpModule(Module): name = "php" def is_relevant(self, context: Context) -> bool: - return bool(context.config.configurations.php.repositories) + return bool(context.config.configurations.php.repositories) or bool( + context.config.configurations.certificates + ) def apply(self, context: Context) -> None: - _write_composer_config(context, context.config.configurations.php.repositories) + repositories = context.config.configurations.php.repositories + if repositories: + _write_composer_config(context, repositories) + if context.config.configurations.certificates: + _write_openssl_ini(context) diff --git a/tests/configs/Apk.yaml.j2 b/tests/configs/Apk.yaml.j2 index 9aa3480..86787d6 100644 --- a/tests/configs/Apk.yaml.j2 +++ b/tests/configs/Apk.yaml.j2 @@ -12,6 +12,7 @@ configurations: repositories: - name: demo url: https://host.docker.internal:{{ 'APK_REPO_PORT' | env }}/ + url_to_replace: https://dl-cdn.alpinelinux.org/* key: | {{ 'dockerfiles/repos/040d0af5f97a0a44.rsa.pub' | file | indent(8, first=true) }} yum: diff --git a/tests/configs/Apt.yaml.j2 b/tests/configs/Apt.yaml.j2 index 0802a3b..504c7ac 100644 --- a/tests/configs/Apt.yaml.j2 +++ b/tests/configs/Apt.yaml.j2 @@ -13,6 +13,7 @@ configurations: uris: - https://host.docker.internal:{{ 'APT_REPO_PORT' | env }}/ suites: ["./"] + uri-to-replace: http://deb.debian.org/* signed-by: | {{ 'dockerfiles/repos/apt-signing-key.pub.asc' | file | indent(8, first=true) }} apk: diff --git a/tests/configs/Yum.yaml.j2 b/tests/configs/Yum.yaml.j2 index a3ffacf..5696450 100644 --- a/tests/configs/Yum.yaml.j2 +++ b/tests/configs/Yum.yaml.j2 @@ -14,6 +14,7 @@ configurations: repositories: - name: demo index-url: https://host.docker.internal:{{ 'YUM_REPO_PORT' | env }}/ + index-url-to-replace: https://mirrors.rockylinux.org/* keys: [] python: repositories: [] diff --git a/tests/test_npm.py b/tests/test_npm.py index 69fa45f..df773df 100644 --- a/tests/test_npm.py +++ b/tests/test_npm.py @@ -28,6 +28,14 @@ EMPTY_CONFIG = { "organisation": "InterDiode", } +CONFIG_WITH_CERTIFICATE_ONLY = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "certificates": [{"name": "interdiode-ca", "content": "-----BEGIN...-----"}] + }, +} + def make_config(data: dict) -> Configuration: return Configuration.model_validate(data) @@ -38,6 +46,13 @@ def test_is_relevant_false_when_no_repositories(tmp_path) -> None: assert NpmModule().is_relevant(context) is False +def test_is_relevant_true_with_certificate_only(tmp_path) -> None: + context = Context( + config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=tmp_path / "dst", src=None + ) + assert NpmModule().is_relevant(context) is True + + def test_is_relevant_true_with_repository(tmp_path) -> None: context = Context( config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None @@ -69,3 +84,50 @@ def test_apply_writes_additional_repositories_scoped(tmp_path) -> None: "registry=https://example.invalid/\n" "@myorg:registry=https://other.invalid/\n" ) + + +def test_apply_without_certificates_does_not_write_etc_environment(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None) + + NpmModule().apply(context) + + assert not (dst / "etc/environment").exists() + + +def test_apply_sets_node_extra_ca_certs_when_certificates_configured( + tmp_path, +) -> None: + dst = tmp_path / "dst" + context = Context( + config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=dst, src=None + ) + + NpmModule().apply(context) + + assert not (dst / "usr/local/etc/npmrc").exists() + environment = (dst / "etc/environment").read_text(encoding="utf-8") + assert environment == "NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt\n" + + +def test_apply_merges_node_extra_ca_certs_into_existing_etc_environment( + tmp_path, +) -> None: + src = tmp_path / "src" + src.mkdir() + (src / "etc").mkdir() + (src / "etc/environment").write_text( + "PATH=/usr/bin\nNODE_EXTRA_CA_CERTS=/old/stale.pem\n", encoding="utf-8" + ) + + dst = tmp_path / "dst" + context = Context( + config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=dst, src=src + ) + + NpmModule().apply(context) + + environment = (dst / "etc/environment").read_text(encoding="utf-8") + assert environment == ( + "PATH=/usr/bin\nNODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt\n" + ) diff --git a/tests/test_php.py b/tests/test_php.py index 7fb07e1..cf9ea35 100644 --- a/tests/test_php.py +++ b/tests/test_php.py @@ -34,6 +34,14 @@ EMPTY_CONFIG = { "organisation": "InterDiode", } +CONFIG_WITH_CERTIFICATE_ONLY = { + "uuid": "12345678-1234-5678-1234-567812345678", + "organisation": "InterDiode", + "configurations": { + "certificates": [{"name": "interdiode-ca", "content": "-----BEGIN...-----"}] + }, +} + def make_config(data: dict) -> Configuration: return Configuration.model_validate(data) @@ -44,6 +52,13 @@ def test_is_relevant_false_when_no_repositories(tmp_path) -> None: assert PhpModule().is_relevant(context) is False +def test_is_relevant_true_with_certificate_only(tmp_path) -> None: + context = Context( + config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=tmp_path / "dst", src=None + ) + assert PhpModule().is_relevant(context) is True + + def test_is_relevant_true_with_repository(tmp_path) -> None: context = Context( config=make_config(CONFIG_WITH_REPO), dst=tmp_path / "dst", src=None @@ -79,3 +94,27 @@ def test_apply_writes_multiple_repositories_in_order(tmp_path) -> None: config_path = dst / "root/.composer/config.json" content = json.loads(config_path.read_text(encoding="utf-8")) assert [repo["name"] for repo in content["repositories"]] == ["demo", "other"] + + +def test_apply_without_certificates_does_not_write_openssl_ini(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context(config=make_config(CONFIG_WITH_REPO), dst=dst, src=None) + + PhpModule().apply(context) + + assert not (dst / "usr/local/etc/php/conf.d/id-configurator-openssl.ini").exists() + + +def test_apply_sets_openssl_cafile_when_certificates_configured(tmp_path) -> None: + dst = tmp_path / "dst" + context = Context( + config=make_config(CONFIG_WITH_CERTIFICATE_ONLY), dst=dst, src=None + ) + + PhpModule().apply(context) + + assert not (dst / "root/.composer/config.json").exists() + ini_content = ( + dst / "usr/local/etc/php/conf.d/id-configurator-openssl.ini" + ).read_text(encoding="utf-8") + assert ini_content == "openssl.cafile=/etc/ssl/certs/ca-certificates.crt\n"