From 0d0844ca6469e5460b6fd624efa68a618e828b4e Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Mon, 12 Jan 2026 03:08:49 +0100 Subject: [PATCH 1/2] nixos/acme: add compat for IP SANs to minica fallback With minica we need to pass domain names and IP addresses separately. --- nixos/modules/security/acme/default.nix | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/nixos/modules/security/acme/default.nix b/nixos/modules/security/acme/default.nix index c03dd38ce472..99371ac6770e 100644 --- a/nixos/modules/security/acme/default.nix +++ b/nixos/modules/security/acme/default.nix @@ -20,6 +20,16 @@ let mkAccountHash = acmeServer: data: mkHash "${toString acmeServer} ${data.keyType} ${data.email}"; accountDirRoot = "/var/lib/acme/.lego/accounts/"; + isIP = + address: + let + isIPv6 = lib.length (lib.splitString ":" address) > 2; + isIPv4 = (lib.match "^([0-9]+\\.){3}[0-9]+$" address) != null; + in + isIPv6 || isIPv4; + + isDomainName = name: !isIP name; + # Lockdir is acme-setup.service's RuntimeDirectory. # Since that service is a oneshot with RemainAfterExit, # the folder will exist during all renewal services. @@ -387,10 +397,14 @@ let exit 0 fi - minica \ - --ca-key ca/key.pem \ - --ca-cert ca/cert.pem \ - --domains ${lib.escapeShellArg (builtins.concatStringsSep "," ([ data.domain ] ++ extraDomains))} + minica ${ + lib.cli.toCommandLineShellGNU { } { + ca-key = "ca/key.pem"; + ca-cert = "ca/cert.pem"; + domains = lib.concatStringsSep "," (lib.filter isDomainName ([ data.domain ] ++ extraDomains)); + ip-addresses = lib.concatStringsSep "," (lib.filter isIP ([ data.domain ] ++ extraDomains)); + } + } # Create files to match directory layout for real certificates ( From 51d1c22f8512222e198ff39973acadf09e2c9a94 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Mon, 12 Jan 2026 05:57:06 +0100 Subject: [PATCH 2/2] nixos/tests/acme: test IP subject alt names With IP SANs becoming available on LE excercising them in our tests seems prudent. --- nixos/tests/acme/http01-builtin.nix | 8 +++++++- nixos/tests/acme/python-utils.py | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/nixos/tests/acme/http01-builtin.nix b/nixos/tests/acme/http01-builtin.nix index a82ef6f5652a..fce54470941f 100644 --- a/nixos/tests/acme/http01-builtin.nix +++ b/nixos/tests/acme/http01-builtin.nix @@ -1,11 +1,11 @@ { - config, lib, pkgs, ... }: let domain = "example.test"; + ip = "192.168.1.2"; in { name = "http01-builtin"; @@ -34,6 +34,7 @@ in environment.systemPackages = [ pkgs.openssl ]; security.acme.certs."${config.networking.fqdn}" = { + extraDomainNames = [ ip ]; listenHTTP = ":80"; }; @@ -124,6 +125,7 @@ in [ alt_names ] DNS.1 = ${config.networking.fqdn} + IP.1 = ${ip} ''; csrData = pkgs.runCommand "csr-and-key" @@ -142,6 +144,7 @@ in security.acme.certs."${config.networking.fqdn}" = { csr = "${csrData}/request.csr"; csrKey = "${csrData}/key.pem"; + extraDomainNames = lib.mkForce [ ]; }; }; }; @@ -158,6 +161,7 @@ in ${(import ./utils.nix).pythonUtils} domain = "${domain}" + ip = "${ip}" cert = "${certName}" cert2 = "builtin-2." + domain cert3 = "builtin-3." + domain @@ -173,6 +177,7 @@ in check_issuer(builtin, cert, "pebble") check_domain(builtin, cert, cert) + check_ip(builtin, cert, ip) with subtest("Validate permissions"): check_permissions(builtin, cert, "acme") @@ -300,6 +305,7 @@ in builtin.wait_for_unit("renew-triggered.target") check_issuer(builtin, cert, "pebble") + check_ip(builtin, cert, ip) with subtest("Generate self-signed certs"): acme.shutdown() diff --git a/nixos/tests/acme/python-utils.py b/nixos/tests/acme/python-utils.py index d542324084aa..304e4f2e3a68 100644 --- a/nixos/tests/acme/python-utils.py +++ b/nixos/tests/acme/python-utils.py @@ -84,6 +84,11 @@ def check_domain(node, cert_name, domain, fail=False) -> None: cmd = f"openssl x509 -noout -checkhost '{domain}' -in /var/lib/acme/{cert_name}/cert.pem" run(node, cmd, fail=fail) +# Ensures the provided ip address matches with the given cert +def check_ip(node, cert_name, ip_address, fail=False) -> None: + cmd = f"openssl x509 -noout -checkip '{ip_address}' -in /var/lib/acme/{cert_name}/cert.pem" + run(node, cmd, fail=fail) + # Ensures the required values for OCSP stapling are present # Pebble doesn't provide a full OCSP responder, so just checks the URL def check_stapling(node, cert_name, ca_domain, fail=False):