From 50497780c7dd7dfae1f3b1a93888c5cabbbf3586 Mon Sep 17 00:00:00 2001 From: Juergen Holly Date: Mon, 19 Oct 2026 20:27:32 +0200 Subject: [PATCH] fix ipps certificate lookup --- README.md | 14 ++- entrypoint.sh | 76 ++++++++++--- .../.openspec.yaml | 2 + .../fix-ipps-certificate-lookup/design.md | 102 ++++++++++++++++++ .../fix-ipps-certificate-lookup/proposal.md | 75 +++++++++++++ .../specs/printserver/cups/spec.md | 41 +++++++ .../fix-ipps-certificate-lookup/tasks.md | 19 ++++ 7 files changed, 314 insertions(+), 15 deletions(-) create mode 100644 openspec/changes/fix-ipps-certificate-lookup/.openspec.yaml create mode 100644 openspec/changes/fix-ipps-certificate-lookup/design.md create mode 100644 openspec/changes/fix-ipps-certificate-lookup/proposal.md create mode 100644 openspec/changes/fix-ipps-certificate-lookup/specs/printserver/cups/spec.md create mode 100644 openspec/changes/fix-ipps-certificate-lookup/tasks.md diff --git a/README.md b/README.md index 20cf94e..3e87bad 100644 --- a/README.md +++ b/README.md @@ -140,8 +140,18 @@ FQDN, `localhost`, `127.0.0.1` and everything in `CERT_EXTRA_NAMES`: The certificate lives in `/etc/cups/ssl` (so that directory must be writable: use a named volume, see the bind-mount caveat under *Notes and limitations*) and is only regenerated when one of the names it must cover is missing, so a -restart normally logs `TLS certificate is up to date`. Inspect what the server -actually presents: +restart normally logs `TLS certificate is up to date`. + +It is also copied — as `.crt`/`.key` — for every name CUPS may look +it up by, because CUPS 2.4 does not choose the certificate by the name the client +requested: it reverse-resolves the *local* address the client connected to and +loads `.crt` from `/etc/cups/ssl`. A host-networked container resolves its +own LAN address to the name Podman writes into `/etc/hosts` +(`host.containers.internal`), and IPv6 literals are used as-is, so those names +need a copy too. Otherwise CUPS mints a certificate of its own for the address, +whose SANs do not cover the advertised `.local` name. + +Inspect what the server actually presents: ```sh podman cp printserver:/etc/cups/ssl/server.crt . diff --git a/entrypoint.sh b/entrypoint.sh index 7209c8d..2f79417 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -90,7 +90,8 @@ fi # A mismatch makes the secure add fail, and macOS then silently falls back to # its "Generic PostScript Printer" driver (no duplex, no colour, no trays). # Install our own certificate with SANs covering every name this server answers -# to, and point cupsd at it (see ensure_cupsd_cert_directives below). +# to, and make it reachable under every name cupsd looks the certificate up by +# (see install_default_cert_copy below). CERT_DIR="${CERT_DIR:-/etc/cups/ssl}" CERT_CRT="${CERT_DIR}/server.crt" CERT_KEY="${CERT_DIR}/server.key" @@ -139,17 +140,63 @@ cert_common_name() { fi } -# Install a copy of the certificate where cupsd looks by default, i.e. -# .crt/.key in the certificate directory, for every name this server -# could be known as (host name and Avahi FQDN). CUPS generates its own -# certificate there and, at least in CUPS 2.4, keeps serving that file even when -# ServerCertificate points elsewhere - the copies make sure the SANs are covered -# either way. Re-installed on every start because cupsd may have replaced them. +# Local addresses of this host (IPv4 and IPv6), excluding loopback - cupsd +# answers loopback connections with the default common name instead. +local_addresses() { + addrs="$(hostname -I 2>/dev/null || true)" + if [ -z "${addrs}" ] && command -v ip >/dev/null 2>&1; then + addrs="$(ip -o addr show 2>/dev/null | awk '{ print $4 }' | cut -d/ -f1)" + fi + printf '%s\n' ${addrs} | grep -vx -e '' -e '127\.0\.0\.1' -e '::1' || true +} + +# The file name cupsd derives from a name. http_make_path() in +# cups/tls-openssl.c keeps only alphanumerics, '-' and '.', replacing every +# other character with '_' (an IPv6 literal thus becomes e.g. +# fd26_182_833e_0_be24_11ff_fe21_341c). +cert_cups_name() { + printf '%s' "$1" | sed 's/[^A-Za-z0-9.-]/_/g' +} + +# Every name cupsd may look the server certificate up by. On a server TLS +# handshake CUPS 2.4 does *not* use the requested (SNI) name: it takes the +# local socket address the client connected to, reverse-resolves it and loads +# .crt from the keychain directory (/etc/cups/ssl). When the lookup +# yields a numeric address cupsd keeps it unless it starts with a digit - which +# it does not for IPv6 literals. See _httpTLSStart() in cups/tls-openssl.c. +# Any name we miss makes cupsd mint a certificate of its own for it, and that +# certificate does not cover the name the client actually asked for. +cert_lookup_names() { + cert_san_list | sed -n 's/^DNS://p' | while IFS= read -r name; do + [ -n "${name}" ] || continue + printf '%s\n' "$(cert_cups_name "${name}")" + done + local_addresses | while IFS= read -r addr; do + [ -n "${addr}" ] || continue + # httpAddrString() spells an IPv6 zone as "+zone" (URI form)... + printf '%s\n' "$(cert_cups_name "$(printf '%s' "${addr}" | sed 's/%/+/')")" + bare="$(printf '%s' "${addr}" | sed 's/%.*//')" + name="$(getent hosts "${bare}" 2>/dev/null | awk 'NR == 1 { print $2 }')" + if [ -n "${name}" ]; then + printf '%s\n' "$(cert_cups_name "${name}")" + fi + done +} + +# Install a copy of the certificate under every name cupsd looks it up by, +# i.e. .crt/.key in the certificate directory. CUPS 2.4 ignores the +# ServerCertificate directive (see ensure_cupsd_cert_directives) and resolves +# the connection's local address instead, so without these copies it serves a +# certificate it generated itself whose SANs do not cover the advertised name. +# For example a host-networked container resolves its own LAN address to the +# name podman wrote into /etc/hosts ("host.containers.internal"). Re-installed +# on every start because cupsd may have written its own certificate since. install_default_cert_copy() { [ -s "${CERT_CRT}" ] || return 0 - for name in "${CERT_HOSTNAME}" "${CERT_FQDN}"; do + cert_lookup_names | sort -u | while IFS= read -r name; do + [ -n "${name}" ] || continue case "${name}" in - "" | */*) continue ;; + */*) continue ;; esac cp -f "${CERT_CRT}" "${CERT_DIR}/${name}.crt" 2>/dev/null || continue cp -f "${CERT_KEY}" "${CERT_DIR}/${name}.key" 2>/dev/null || continue @@ -214,10 +261,13 @@ ensure_certificate() { fi } -# Point cupsd at our certificate. Only done when the files exist: cupsd refuses -# to start when ServerCertificate cannot be read. Skipped when the directives -# are already present, so this also repairs a cupsd.conf previously rewritten by -# cupsctl (which drops directives it does not manage). +# Point cupsd at our certificate. CUPS 2.4 has dropped ServerCertificate/ +# ServerKey (cups-files.conf logs an INFO and ignores them); the certificate +# lookup there goes through the keychain directory, which install_default_cert_copy() +# populates. The directives are kept for older CUPS, and only added when the +# files exist: cupsd refuses to start when ServerCertificate cannot be read. +# Skipped when already present, so this also repairs a cupsd.conf previously +# rewritten by cupsctl (which drops directives it does not manage). ensure_cupsd_cert_directives() { conf=/etc/cups/cupsd.conf [ -f "${conf}" ] || return 0 diff --git a/openspec/changes/fix-ipps-certificate-lookup/.openspec.yaml b/openspec/changes/fix-ipps-certificate-lookup/.openspec.yaml new file mode 100644 index 0000000..c1bc902 --- /dev/null +++ b/openspec/changes/fix-ipps-certificate-lookup/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-19 diff --git a/openspec/changes/fix-ipps-certificate-lookup/design.md b/openspec/changes/fix-ipps-certificate-lookup/design.md new file mode 100644 index 0000000..e50ec43 --- /dev/null +++ b/openspec/changes/fix-ipps-certificate-lookup/design.md @@ -0,0 +1,102 @@ +# Design + +## Context + +See `proposal.md` — Why. + +The previous change assumed cupsd served the file named by `ServerCertificate`, +and only wrote `.crt`/`.crt` on top of that. Observing a +live deployment (CUPS 2.4.2 on Debian bookworm, host networking) and reading the +sources shows a different mechanism: + +- CUPS 2.4.2 reads `ServerKeychain` (a directory, default `ssl` → + `/etc/cups/ssl`), `ServerName` (default common name) and `CreateSelfSignedCerts` + (`scheduler/conf.c`). `ServerCertificate`/`ServerKey` are dead: in + `cupsd.conf` CUPS logs "Please move … to the cups-files.conf file", and in + `cups-files.conf` it logs "is no longer supported" and ignores the value. +- In `_httpTLSStart()` (`cups/tls-openssl.c`) a server connection takes the + **local** socket address (`getsockname`), not the SNI/host name. Loopback + yields an empty name, so the lookup falls back to the default common name. Any + other address is reverse-resolved with `httpAddrLookup()`; if that fails the + numeric string is used. A result that starts with a digit or `[` is discarded + (numeric IPv4), but an IPv6 literal such as `fd26:…` is kept. +- The certificate file is built with `http_make_path()`: it keeps only + alphanumerics, `-` and `.`, turning everything else into `_`. An IPv6 literal + `fd26:182:833e:0:be24:11ff:fe21:341c` therefore becomes the file name + `fd26_182_833e_0_be24_11ff_fe21_341c.crt`. +- With host networking, Podman writes the host's address into the container's + `/etc/hosts` as `host.containers.internal`. A client connecting to the LAN + address therefore makes cupsd look for `host.containers.internal.crt`. + +Observed, same cupsd and port, different certificate depending on the address +the client connected to: + +| Connected to | cupsd lookup name | Served certificate | +| --- | --- | --- | +| `127.0.0.1` | default common name (`podp.internal.jacs.at`) | ours (copy existed) | +| `192.168.100.20` | `host.containers.internal` | cupsd's own, `CN=host.containers.internal` | +| `[fd26:…]:631` | IPv6 literal | cupsd's own, `CN=fd26:…` | + +## Goals / Non-Goals + +**Goals:** +- cupsd presents our certificate (with the advertised name among its SANs) no + matter which of the server's addresses the client connected to. +- The certificate content and the reuse/regeneration behaviour stay as they are, + so already-trusted clients keep working across an image upgrade. + +**Non-Goals:** +- Adding the server's addresses to the certificate's IP SANs. Clients that use + the advertised `.local` name validate that name; IP-based URLs remain covered + by `CERT_EXTRA_NAMES`. +- Removing `ServerCertificate`/`ServerKey` entirely; they are kept for CUPS + versions before 2.4. + +## Decisions + +**Enumerate the names cupsd can look up instead of guessing two.** +`install_default_cert_copy()` now writes a copy for every DNS name in the SAN +list *and* for each local address (the address literal with `%zone` written as +`+zone` and mangled the way `http_make_path()` does, plus the name the address +reverse-resolves to via `getent hosts`). This is derived from the running +system, so it also covers names a future Podman version writes into `/etc/hosts` +and any IPv6 literal, without hard-coding `host.containers.internal`. + +**Reuse the existing helpers and regeneration logic.** +The SAN list already contains `host.containers.internal`, `localhost` and the +advertised names, so enumerating DNS SANs covers the name-based lookups; the +local-address enumeration adds the literal/IPv6 cases. Nothing in +`ensure_certificate()` changes, so the certificate is not reissued on upgrade. + +**Locate local addresses with `hostname -I`, fall back to `ip -o addr`.** +Debian's `hostname` (present in `bookworm-slim`) supports `-I` and prints all +IPv4 and IPv6 addresses; `ip` from `iproute2` is not installed, so it is only a +fallback. Loopback is excluded because cupsd handles it with the default common +name. + +**Keep the copies' permissions consistent with the certificate.** +Each copy gets the same mode/owner (`0644` cert, `0640` key, `root:lp`) as +`server.crt`/`server.key`, so cupsd can read them. + +## Risks / Trade-offs + +- [A local address changes while the container runs] → The names are recomputed + on every start; a changed address is covered after the next restart. Addresses + are stable with host networking. +- [Link-local IPv6 addresses whose scope `hostname -I` does not print] → Their + literal copy may differ from what cupsd computes, so a client connecting over + such an address could still get cupsd's certificate. Accepted; clients use the + advertised `.local` name, which typically resolves to a global/ULA address. +- [Writing a folder full of certificate copies] → A handful of small files in + `/etc/cups/ssl`; rewrite is idempotent and cheap. +- [Comma-free, space-free names only] → `http_make_path()` collapses any other + character to `_`, so the name we write matches what cupsd computes by + construction. + +## Migration Plan + +Rebuild and redeploy the image; no volume reset. On first start the missing +`.crt`/`.key` copies (notably `host.containers.internal`) appear and cupsd +serves them from then on. The certificate itself is unchanged, so no client +needs to re-trust it. Rollback is redeploying the previous image; the extra +copies are simply ignored. diff --git a/openspec/changes/fix-ipps-certificate-lookup/proposal.md b/openspec/changes/fix-ipps-certificate-lookup/proposal.md new file mode 100644 index 0000000..b311271 --- /dev/null +++ b/openspec/changes/fix-ipps-certificate-lookup/proposal.md @@ -0,0 +1,75 @@ +# Proposal + +## Why + +The previous change (`advertise-valid-ipps-certificate`) made the container +*generate* a certificate with the right subject alternative names, but clients +still get a certificate they do not trust. From the workstation, +`curl https://podp.local:631/` fails with `SSL certificate problem: unable to get +local issuer certificate`, and the certificate the server presents has +`CN=host.containers.internal` — not the certificate the entrypoint installed. + +The reasoning in the previous change ("CUPS 2.4 keeps serving +`ServerCertificate`, so also write `.crt`") was wrong. Reading the +CUPS 2.4.2 sources: + +- `ServerCertificate`/`ServerKey` are no longer supported. In `cupsd.conf` they + log "Please move … to the cups-files.conf file"; in `cups-files.conf` they log + "is no longer supported" and are ignored (`scheduler/conf.c`). The certificate + directory is `ServerKeychain` (default `ssl` → `/etc/cups/ssl`). +- On a server TLS handshake, `_httpTLSStart()` (`cups/tls-openssl.c`) ignores + the requested (SNI) name. It takes the **local socket address** the client + connected to, reverse-resolves it, and loads `.crt` from the keychain + directory. A name that is not alphanumeric, `-` or `.` is mangled to `_` + (`http_make_path()`), and a numeric IPv6 result is used verbatim because CUPS + only rejects numeric names that start with a digit. + +So with host networking the client's connection to the LAN address +`192.168.100.20` makes cupsd look for `/etc/cups/ssl/host.containers.internal.crt` +(the name Podman writes into `/etc/hosts` for the host address). That file did +not exist — only `.crt` and `.crt` were written — so cupsd +minted its own, which does not cover `podp.local`. IPv6 connections get a +certificate named after the IPv6 literal for the same reason. The previous +change's verification only checked `localhost`, where cupsd falls back to the +default common name and happened to find the copy. + +## What Changes + +- Derive the certificate copies from what cupsd actually looks up: write + `.crt`/`.key` for every DNS name in the SAN list **and** for every + local address (the address literal, mangled the way `http_make_path()` does, + plus whatever name the address reverse-resolves to). This covers the Podman + `host.containers.internal` name for the LAN address and the IPv6 literals. +- Keep the SAN list and the regeneration logic unchanged: the certificate + content was already correct and reissuing it is not desirable. +- Correct the comments, the README and the spec so they no longer claim + `ServerCertificate` is what makes cupsd serve our certificate. The directive + is kept only for CUPS versions older than 2.4. + +Non-goals: generating a publicly trusted certificate or a private CA, adding the +local addresses to the certificate's IP SANs (that needs `CERT_EXTRA_NAMES` and +is out of scope), and changing the plain `_ipp` service. + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `printserver/cups`: the requirement that the secure AirPrint certificate + matches the advertised name gains a scenario for serving that certificate for + the address a client connected to, not only for the name it asked for. + +## Impact + +- `entrypoint.sh`: `install_default_cert_copy` now enumerates the local + addresses and cupsd's filename mangling; `ensure_cupsd_cert_directives` + comment corrected. +- `README.md`: certificate section explains how CUPS selects the certificate. +- `openspec/specs/printserver/cups/spec.md`: one scenario added to the + certificate requirement. +- Clients: fully validating clients (macOS/iOS AirPrint, `curl`) complete the + secure connection once they trust the certificate; plain IPP is unaffected. +- Existing deployments: an existing `/etc/cups` volume is fixed on the next + start; the certificate itself is not reissued, so clients that already trust + it keep trusting it. diff --git a/openspec/changes/fix-ipps-certificate-lookup/specs/printserver/cups/spec.md b/openspec/changes/fix-ipps-certificate-lookup/specs/printserver/cups/spec.md new file mode 100644 index 0000000..343b2d0 --- /dev/null +++ b/openspec/changes/fix-ipps-certificate-lookup/specs/printserver/cups/spec.md @@ -0,0 +1,41 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Secure AirPrint certificate matches the advertised name +The container SHALL serve the secure `_ipps` (TLS) service with a certificate +whose subject alternative names cover the names clients use to reach it, so +clients that validate the name complete the secure connection instead of falling +back to a generic driver. + +#### Scenario: Certificate is created on first start +- **WHEN** the container starts and no certificate is present in the certificate directory +- **THEN** a self-signed certificate and key are generated whose subject alternative names cover the container host name, its `.local` form, the name Avahi advertises, `localhost`, `localhost.localdomain`, `host.containers.internal` and `127.0.0.1` + +#### Scenario: Additional names are accepted +- **WHEN** the container is started with `CERT_EXTRA_NAMES` set to a comma-separated list of DNS names and/or IP addresses +- **THEN** every listed name is included as a subject alternative name of the generated certificate + +#### Scenario: Validity is configurable +- **WHEN** the container is started with `CERT_DAYS` +- **THEN** the generated certificate is valid for that number of days + +#### Scenario: An existing certificate is reused +- **WHEN** the container starts and the existing certificate already covers every required name +- **THEN** it is kept instead of reissued, so clients that trusted it do not have to trust a new certificate + +#### Scenario: Missing names trigger reissue +- **WHEN** the container starts and a required name — for example a newly added `CERT_EXTRA_NAMES` entry — is not covered by the existing certificate +- **THEN** a certificate covering it is generated + +#### Scenario: CUPS presents the certificate +- **WHEN** a client connects over `_ipps` to the name Avahi advertises +- **THEN** the certificate CUPS presents contains that name in its subject alternative names + +#### Scenario: Certificate is served for the connecting address +- **WHEN** a client connects over `_ipps` to one of the host's addresses — for example its LAN address or an IPv6 address — rather than to the advertised name +- **THEN** CUPS presents the container's certificate, so the names in its subject alternative names (including the advertised name) still apply + +#### Scenario: A validating client must trust the certificate +- **WHEN** a client validates the certificate chain +- **THEN** the connection is rejected until the self-signed certificate is trusted on the client, while the plain `_ipp` service keeps working diff --git a/openspec/changes/fix-ipps-certificate-lookup/tasks.md b/openspec/changes/fix-ipps-certificate-lookup/tasks.md new file mode 100644 index 0000000..41cdcdd --- /dev/null +++ b/openspec/changes/fix-ipps-certificate-lookup/tasks.md @@ -0,0 +1,19 @@ +# Tasks + +## 1. Serve the certificate for the connecting address + +- [x] 1.1 Add a helper that lists the container's local addresses (IPv4 and IPv6, loopback excluded) using `hostname -I`, falling back to `ip -o addr`; verify the logic with a stubbed `hostname`/`getent` (IPv4 only, IPv6 only, address containing a `%zone`, and no addresses at all) +- [x] 1.2 Add a helper that mangles a name the way `http_make_path()` in CUPS does (keep `[A-Za-z0-9.-]`, replace the rest with `_`) and derive the lookup names: every DNS SAN, each address literal with `%zone` spelled `+zone`, and each address's reverse-resolved name (`getent hosts`) +- [x] 1.3 Rewrite `install_default_cert_copy()` to install `.crt`/`.key` for all those names, preserving the `0644`/`0640` `root:lp` permissions; verify with a stubbed environment that the copies include `host.containers.internal.crt`, `podp.local.crt` and `.crt` +- [ ] 1.4 After rebuilding and redeploying on the podman host, verify from another host that `curl https://podp.local:631/` succeeds and `openssl s_client -connect :631 -servername podp.local` shows our certificate (SANs contain the advertised name), not `CN=host.containers.internal` + +## 2. Correct the reasoning and documentation + +- [x] 2.1 Correct the certificate comments in `entrypoint.sh`: CUPS 2.4 ignores `ServerCertificate`/`ServerKey`, selects the certificate by the connection's local address and mangles the name with `http_make_path()` +- [x] 2.2 Update the README certificate section to describe how CUPS selects the certificate and why the per-name copies exist +- [x] 2.3 Add a scenario to the `printserver/cups` spec covering the certificate served for the address a client connected to + +## Workflow follow-up + +- Archive this change (`/opsx-archive`) so the delta merges into `openspec/specs/printserver/cups/spec.md`. +- Commit the implementation together with the archived change using `jj`.