From db23feada5516728891553452459ee123c3684a7 Mon Sep 17 00:00:00 2001 From: Juergen Holly Date: Sun, 18 Oct 2026 22:19:11 +0200 Subject: [PATCH] Advertise a valid IPPS certificate for AirPrint --- Containerfile | 4 +- README.md | 107 ++++++++++- entrypoint.sh | 180 +++++++++++++++++- .../.openspec.yaml | 2 + .../design.md | 118 ++++++++++++ .../proposal.md | 67 +++++++ .../specs/printserver/cups/spec.md | 69 +++++++ .../advertise-valid-ipps-certificate/tasks.md | 29 +++ 8 files changed, 561 insertions(+), 15 deletions(-) create mode 100644 openspec/changes/advertise-valid-ipps-certificate/.openspec.yaml create mode 100644 openspec/changes/advertise-valid-ipps-certificate/design.md create mode 100644 openspec/changes/advertise-valid-ipps-certificate/proposal.md create mode 100644 openspec/changes/advertise-valid-ipps-certificate/specs/printserver/cups/spec.md create mode 100644 openspec/changes/advertise-valid-ipps-certificate/tasks.md diff --git a/Containerfile b/Containerfile index d5f19b6..4dac085 100644 --- a/Containerfile +++ b/Containerfile @@ -27,7 +27,8 @@ ENV DEBIAN_FRONTEND=noninteractive # --- Task 3.1: base system, CUPS, Avahi, DBus and conversion filters ------- # cups-filters provides the PDF/PWG/URF -> PostScript filters used for # driverless clients (AirPrint/IPP Everywhere). Ghostscript and Poppler supply -# the underlying converters. +# the underlying converters, openssl is used by the entrypoint to generate the +# IPPS certificate that AirPrint clients (macOS/iOS) validate. RUN apt-get update \ && apt-get install -y --no-install-recommends \ cups \ @@ -42,6 +43,7 @@ RUN apt-get update \ ghostscript \ poppler-utils \ ca-certificates \ + openssl \ && rm -rf /var/lib/apt/lists/* # --- Task 3.2: install the BR-Script3 PostScript PPD ----------------------- diff --git a/README.md b/README.md index 8641bd2..20cf94e 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ becomes: | --- | --- | | `Containerfile` | Image definition (Debian base, CUPS + cups-filters + Avahi, BR-Script3 PPD) | | `cupsd.conf` | Container CUPS configuration (listen on all interfaces, share printers) | -| `entrypoint.sh` | Starts D-Bus, Avahi and CUPS, then registers the printer queue | +| `entrypoint.sh` | Starts D-Bus, Avahi and CUPS, generates the `_ipps` certificate, then registers the printer queue | | `assets/Brother HL-4150CDN/ppd/` | Official BR-Script3 PPD used for the queue | | `assets/Brother HL-4150CDN/Windows/` | Vendor Windows driver the PPD was extracted from | @@ -85,6 +85,8 @@ podman run -d --name printserver \ | `PRINTER_NAME` | `HL4150CDN` | CUPS queue name. Also used as the name advertised via AirPrint/DNS-SD. | | `PRINTER_DESCRIPTION` | *(empty)* | CUPS printer description (`lpadmin -D`), shown in the CUPS web UI. | | `PPD_FILE` | `/usr/share/cups/model/BR4150_2.PPD` | PPD used for the queue. | +| `CERT_EXTRA_NAMES` | *(empty)* | Comma-separated extra DNS names and/or IP addresses to put into the generated `_ipps` certificate. | +| `CERT_DAYS` | `3650` | Validity of the generated certificate, in days. | No queue is created when neither `PRINTER_IP` nor `PRINTER_URI` is set. @@ -121,6 +123,51 @@ set `PRINTER_DESCRIPTION` (this does not change the advertised name): -e PRINTER_NAME=HL4150CDN -e PRINTER_DESCRIPTION="Brother HL-4150CDN (Office)" ``` +### TLS certificate for `_ipps` + +CUPS advertises the queue twice over mDNS: `_ipp._tcp` (plain) and `_ipps._tcp` +(TLS). macOS and iOS prefer the secure one, and *validate the certificate +against the name they connected to*. The name clients see is Avahi's +`.local`, which is not necessarily the container's host name — so the +entrypoint installs its own self-signed certificate instead of leaving it to +CUPS. Its SANs cover the container host name, `.local`, Avahi's +FQDN, `localhost`, `127.0.0.1` and everything in `CERT_EXTRA_NAMES`: + +```sh +-e CERT_EXTRA_NAMES=192.168.100.20,print.example.org +``` + +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: + +```sh +podman cp printserver:/etc/cups/ssl/server.crt . +openssl x509 -in server.crt -noout -subject -ext subjectAltName +echo | openssl s_client -connect localhost:631 -servername "$(hostname).local" \ + | openssl x509 -noout -subject -ext subjectAltName +``` + +Because the certificate is self-signed, a fully validating client (macOS/iOS +AirPrint, `curl` without `-k`) rejects it until you trust it. CUPS' own tools +(`lpadmin`, `ipptool`) are lenient, so plain printing works without this step: + +- **macOS** (needs your password): + + ```sh + sudo security add-trusted-cert -d -r trustRoot \ + -k /Library/Keychains/System.keychain ./server.crt + ``` + +- **iOS/iPadOS**: AirDrop or mail `server.crt` to the device, install the + profile, then enable full trust under *Settings → General → About → + Certificate Trust Settings*. + +Alternatively, keep using plain IPP (port 631 without TLS) — printing and +duplex/colour options work there too. + ## Printing options The BR-Script3 PPD exposes the HL-4150CDN's hardware options. Common ones: @@ -147,7 +194,7 @@ podman exec printserver lp -d HL4150CDN \ | Platform | Path | Notes | | --- | --- | --- | -| macOS | AirPrint / IPP Everywhere | Discovered via mDNS; no driver needed | +| macOS | AirPrint / IPP Everywhere | Discovered via mDNS; no driver needed. If *Use:* shows *Generic PostScript Printer*, see [Troubleshooting](#macos-adds-the-printer-as-a-generic-postscript-printer) | | iOS / iPadOS | AirPrint | Discovered via mDNS; no driver needed | | Linux | IPP / IPP Everywhere | Native or with the shared PPD | | Windows | IPP or raw 9100 | Install Brother's **BR-Script3** Windows driver, pointed at the printer or the shared queue | @@ -251,15 +298,57 @@ the firewall. If plain IPP works but the *secure* connection fails, this is the `_ipps` service: macOS prefers `_ipps._tcp`, which CUPS advertises unconditionally -(also when no certificate exists). CUPS creates its TLS certificate lazily on -the first TLS connection, in `/etc/cups/ssl` — so that directory must be -writable. If it is not, CUPS logs -`Unable to encrypt connection: Unable to create server credentials` and the -secure connection fails while plain IPP keeps working. Use a **writable (named) -volume** for `/etc/cups`; see the bind-mount caveat under *Notes and -limitations*. After changing anything, remove the printer in macOS and add it +(also when no certificate exists). The certificate lives in `/etc/cups/ssl`, so +that directory must be writable — use a **writable (named) volume**; see the +bind-mount caveat under *Notes and limitations*. If it is not writable, CUPS +logs `Unable to encrypt connection: Unable to create server credentials` and the +secure connection fails while plain IPP keeps working. + +The other cause is the certificate itself: clients only accept it when the name +they connected to is in its SANs *and* they trust it (see *TLS certificate for +`_ipps`*). Typical symptoms: `curl https://.local:631/` ends with +`(60) SSL certificate problem`, or `ipptool -tv ipps://…` succeeds while +macOS/iOS refuse. Add the missing name via `CERT_EXTRA_NAMES` or trust the +certificate. After changing anything, remove the printer in macOS and add it again — macOS caches the failed attempt. +### macOS adds the printer as a Generic PostScript printer + +Symptom: the queue prints, but *Use:* shows **Generic PostScript Printer** and +the print dialog offers no duplex, no colour and no tray 2 — while iOS, which +queries the printer live, offers all of them. On the Mac, +`lpoptions -p -l` lists only `APOptionalDuplexer` plus +`Duplex`/`PageSize`/`ManualFeed`/`InputSlot`, and +`/etc/cups/ppd/.ppd` contains `*PCFileName: "Generic.ppd"`. + +macOS only takes its driverless AirPrint path when it accepts the printer's +secure `_ipps` service; if that fails it silently falls back to its bundled +generic PostScript driver. Duplex then stays hidden, because the generic PPD +gates it behind `APOptionalDuplexer False`. The usual cause is the certificate +(see *TLS certificate for `_ipps`*), so check that first: + +```sh +podman exec printserver avahi-browse -rt _ipps._tcp # what is advertised +podman cp printserver:/etc/cups/ssl/server.crt . && + openssl x509 -in server.crt -noout -subject -ext subjectAltName +``` + +A verified way around it is to add the queue from the command line, driverless +over the same secure service (`ipptool -tv ipp://:631/printers/` +prints the UUID; drop the `urn:uuid:` prefix): + +```sh +sudo lpadmin -x +sudo lpadmin -p -E -m everywhere \ + -v "dnssd://%20%40%20._ipps._tcp.local./?uuid=" +lpoptions -p -l # Duplex, ColorModel and tray 2 show up now +``` + +In the GUI, remove the printer and add it again, then pick **AirPrint** (not +*Generic PostScript Printer*) under *Use*. If only the generic entry is offered, +the secure connection is still being rejected — fix the certificate, or add the +queue from the command line as above. + ## CUPS 3 and the future of PPDs Adding the queue with a PPD makes `lpadmin` print: diff --git a/entrypoint.sh b/entrypoint.sh index 1cfb792..7209c8d 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,9 +1,10 @@ #!/bin/sh # Startup script for the containerized CUPS print server. # -# Starts the D-Bus system bus, Avahi (mDNS/DNS-SD) and CUPS, then registers -# the Brother HL-4150CDN queue if it does not exist yet. The CUPS daemon is -# kept in the foreground so the container lives as long as the print server. +# Starts the D-Bus system bus, Avahi (mDNS/DNS-SD) and CUPS, installs the TLS +# certificate for the secure AirPrint service, then registers the Brother +# HL-4150CDN queue if it does not exist yet. The CUPS daemon is kept in the +# foreground so the container lives as long as the print server. # # The queue uses Brother's BR-Script3 (PostScript 3) PPD and, by default, # sends jobs to the printer over raw AppSocket (socket://:9100). CUPS @@ -17,6 +18,9 @@ # PRINTER_NAME CUPS queue name and advertised (DNS-SD) name. # PRINTER_DESCRIPTION CUPS Description (lpadmin -D) shown in the web UI. # PPD_FILE PPD used for the queue. +# CERT_EXTRA_NAMES Comma-separated extra DNS names / IP addresses to put +# into the generated IPPS certificate (server SANs). +# CERT_DAYS Validity of the generated certificate (default 3650). set -eu PRINTER_NAME="${PRINTER_NAME:-HL4150CDN}" @@ -56,7 +60,11 @@ if [ ! -s /etc/machine-id ] && [ ! -s /var/lib/dbus/machine-id ]; then log "generating D-Bus machine id" dbus-uuidgen > /var/lib/dbus/machine-id fi -if [ ! -S /run/dbus/system_bus_socket ]; then +if ! pgrep -x dbus-daemon >/dev/null 2>&1; then + # Check for the process, not the socket: a previous run leaves the socket and + # PID file behind (podman restart, systemd Restart=), and dbus-daemon then + # refuses to start and avahi-daemon would hang on a dead bus. + rm -f /run/dbus/system_bus_socket /run/dbus/pid log "starting dbus-daemon (system bus)" dbus-daemon --system --fork fi @@ -65,9 +73,171 @@ fi mkdir -p /run/avahi-daemon if ! pgrep -x avahi-daemon >/dev/null 2>&1; then log "starting avahi-daemon" - avahi-daemon --daemonize --no-chroot + # A previous run (podman restart, systemd Restart=) leaves a stale PID file + # and socket behind; avahi-daemon then refuses to start, which under `set -e` + # would take the whole container down. + rm -f /run/avahi-daemon/pid /run/avahi-daemon/socket + if ! avahi-daemon --daemonize --no-chroot; then + log "warning: avahi-daemon failed to start; AirPrint discovery will not work" + fi fi +# --- TLS certificate for IPPS (AirPrint) ------------------------------------ +# CUPS advertises the secure _ipps._tcp service and both macOS and iOS prefer +# it. Clients validate the certificate against the name they connected to, but +# the certificate CUPS generates on demand is named after the container's host +# name - which is not necessarily the name Avahi advertises (.local). +# 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). +CERT_DIR="${CERT_DIR:-/etc/cups/ssl}" +CERT_CRT="${CERT_DIR}/server.crt" +CERT_KEY="${CERT_DIR}/server.key" +CERT_SAN_FILE="${CERT_DIR}/server.san" +CERT_DAYS="${CERT_DAYS:-3650}" +CERT_EXTRA_NAMES="${CERT_EXTRA_NAMES:-}" + +# FQDN Avahi advertises for this host, e.g. "podp.local" - the name clients +# really connect to. Requires the D-Bus system bus and avahi-daemon above. +avahi_fqdn() { + dbus-send --system --print-reply --dest=org.freedesktop.Avahi \ + / org.freedesktop.Avahi.Server.GetHostNameFqdn 2>/dev/null \ + | sed -n 's/.*string "\(.*\)"/\1/p' | head -n 1 | sed 's/\.$//' +} + +# Every name a client may use for this server, as certificate SAN entries. +# An entry is treated as an IP address when it only contains digits, dots and +# colons; anything else is treated as a DNS name. +cert_san_list() { + printf '%s\n' "DNS:localhost" "DNS:localhost.localdomain" "IP:127.0.0.1" + printf '%s\n' "DNS:host.containers.internal" + if [ -n "${CERT_FQDN}" ]; then + printf 'DNS:%s\n' "${CERT_FQDN}" + fi + if [ -n "${CERT_HOSTNAME}" ]; then + printf 'DNS:%s\n' "${CERT_HOSTNAME}" + case "${CERT_HOSTNAME}" in + *.*) ;; + *) printf 'DNS:%s.local\n' "${CERT_HOSTNAME}" ;; + esac + fi + printf '%s\n' "${CERT_EXTRA_NAMES}" | tr ',' '\n' | while IFS= read -r name; do + [ -n "${name}" ] || continue + case "${name}" in + *[!0-9.:]*) printf 'DNS:%s\n' "${name}" ;; + *) printf 'IP:%s\n' "${name}" ;; + esac + done +} + +cert_common_name() { + if [ -n "${CERT_HOSTNAME}" ]; then + printf '%s' "${CERT_HOSTNAME}" + else + printf 'localhost' + 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. +install_default_cert_copy() { + [ -s "${CERT_CRT}" ] || return 0 + for name in "${CERT_HOSTNAME}" "${CERT_FQDN}"; do + case "${name}" in + "" | */*) 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 + chmod 0644 "${CERT_DIR}/${name}.crt" 2>/dev/null || true + chmod 0640 "${CERT_DIR}/${name}.key" 2>/dev/null || true + chown root:lp "${CERT_DIR}/${name}.crt" "${CERT_DIR}/${name}.key" 2>/dev/null || true + done +} + +# Generate the certificate unless the existing one already covers every wanted +# SAN entry. Only *missing* names trigger a regeneration (extra SANs are +# harmless, and reissuing on every start would force clients that trusted the +# certificate to trust it again), sorted so the order of discovery does not +# matter. Changing CERT_EXTRA_NAMES (or the host name) takes effect next start. +ensure_certificate() { + sans="$(cert_san_list | sort -u | tr '\n' ',' | sed 's/,$//')" + + if [ -s "${CERT_CRT}" ] && [ -s "${CERT_KEY}" ] && [ -f "${CERT_SAN_FILE}" ]; then + have=",$(cat "${CERT_SAN_FILE}")," + complete=1 + for entry in $(printf '%s' "${sans}" | tr ',' '\n'); do + case "${have}" in + *",${entry},"*) ;; + *) + complete=0 + break + ;; + esac + done + if [ "${complete}" -eq 1 ]; then + log "TLS certificate is up to date (${CERT_CRT})" + install_default_cert_copy + return 0 + fi + fi + + mkdir -p "${CERT_DIR}" 2>/dev/null || true + if [ ! -w "${CERT_DIR}" ]; then + log "warning: ${CERT_DIR} is not writable; falling back to the certificate CUPS generates itself" + log "warning: iOS/macOS may then reject the secure _ipps connection (see README)" + return 0 + fi + + log "generating self-signed TLS certificate for IPPS (${CERT_DAYS} days)" + # basicConstraints=CA:TRUE so the same file can be imported as a trusted + # root on macOS/iOS; the key/cert are otherwise only used for this server. + if openssl req -x509 -newkey rsa:2048 -sha256 -nodes -days "${CERT_DAYS}" \ + -keyout "${CERT_KEY}" -out "${CERT_CRT}" \ + -subj "/CN=$(cert_common_name)" \ + -addext "subjectAltName=${sans}" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "subjectKeyIdentifier=hash" >/dev/null 2>&1; then + chmod 0644 "${CERT_CRT}" + chmod 0640 "${CERT_KEY}" + chown root:lp "${CERT_CRT}" "${CERT_KEY}" 2>/dev/null || true + printf '%s' "${sans}" > "${CERT_SAN_FILE}" + log "certificate SANs: ${sans}" + install_default_cert_copy + else + log "warning: could not generate a certificate; CUPS will generate its own" + rm -f "${CERT_CRT}" "${CERT_KEY}" + 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). +ensure_cupsd_cert_directives() { + conf=/etc/cups/cupsd.conf + [ -f "${conf}" ] || return 0 + if [ -s "${CERT_CRT}" ] && [ -s "${CERT_KEY}" ] \ + && ! grep -qE '^[[:space:]]*ServerCertificate[[:space:]]' "${conf}"; then + if [ -w "${conf}" ]; then + log "adding ServerCertificate/ServerKey to ${conf}" + printf '\n# TLS certificate for IPPS (AirPrint). See entrypoint.sh.\nServerCertificate %s\nServerKey %s\n' \ + "${CERT_CRT}" "${CERT_KEY}" >> "${conf}" + else + log "warning: ${conf} is not writable; cannot point cupsd at the certificate" + fi + fi +} + +CERT_HOSTNAME="$(hostname 2>/dev/null || true)" +CERT_FQDN="$(avahi_fqdn || true)" +ensure_certificate +ensure_cupsd_cert_directives + # --- CUPS ------------------------------------------------------------------- log "starting cupsd" /usr/sbin/cupsd -f & diff --git a/openspec/changes/advertise-valid-ipps-certificate/.openspec.yaml b/openspec/changes/advertise-valid-ipps-certificate/.openspec.yaml new file mode 100644 index 0000000..5c881ba --- /dev/null +++ b/openspec/changes/advertise-valid-ipps-certificate/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-18 diff --git a/openspec/changes/advertise-valid-ipps-certificate/design.md b/openspec/changes/advertise-valid-ipps-certificate/design.md new file mode 100644 index 0000000..b150169 --- /dev/null +++ b/openspec/changes/advertise-valid-ipps-certificate/design.md @@ -0,0 +1,118 @@ +# Design + +## Context + +See `proposal.md` — Why. + +The relevant facts about the running system, established by inspecting a live +container of this image (CUPS 2.4.2 on Debian bookworm): + +- CUPS advertises the queue on both `_ipp._tcp` and `_ipps._tcp`; macOS and iOS + prefer the latter and check the certificate's subject alternative names + against the name they connected to. CUPS' own clients (`lpadmin`, `ipptool`) + do not perform that check. +- The name clients use is the one Avahi advertises (`.local`, queryable + over D-Bus as `GetHostNameFqdn`), not the container host name. The two can + differ: observed example, host name `host.containers.internal` served while + Avahi advertised `podp.local`. +- CUPS generates a certificate on demand for the *host name*, so that + certificate cannot cover names the host name does not imply (in the example + above, `podp.local` was absent, and so was a `.local` form, because CUPS only + appends `.local` to dot-less host names). +- In CUPS 2.4, `ServerCertificate`/`ServerKey` are accepted and preserved by + `cupsctl` (which rewrites `cupsd.conf`), but cupsd still served the + certificate kept at `/.crt`. Writing our + certificate to that path is what changed the served certificate. +- `/run` survives a container restart (`podman restart`, systemd restart), and + `/etc/cups` is a named volume that survives container replacement. + +## Goals / Non-Goals + +**Goals:** +- The certificate CUPS presents for `_ipps` validates for every name a client + may use, including the Avahi-advertised one. +- Restarting the container is safe and does not reissue the certificate. +- Everything stays self-contained: no external CA, no configuration file edits + by the user, no new volumes. + +**Non-Goals:** +- Obtaining a publicly trusted certificate or running a private CA. +- Obtaining the printer's own (non-AirPrint) capabilities over IPP. +- Changing how jobs are converted or delivered to the printer. + +## Decisions + +**Generate the certificate in the entrypoint rather than shipping one.** +A committed certificate would have a fixed key (shared by every deployment), a +fixed expiry, and no knowledge of the local host name. Generating at first start +keeps the key per deployment and lets the SANs reflect the actual names. + +**Collect SANs from several sources instead of one "server name" setting.** +Clients may reach the server by the Avahi FQDN, the container host name, a +`.local` form, an IP address or a DNS name users add themselves. Adding all of +them is cheap and only ever widens acceptance: `DNS:`, +`DNS:.local` (when the host name has no dot), the Avahi FQDN, +`localhost`, `localhost.localdomain`, `host.containers.internal`, +`IP:127.0.0.1`, and the comma-separated `CERT_EXTRA_NAMES`. SANs are used +because modern clients ignore the common name; the common name is set to the +host name deliberately (see next decision). + +**Keep the common name equal to the host name.** +CUPS' certificate handling is name-based; keeping the common name equal to the +host name keeps it consistent with CUPS' own notion of its identity, while the +SANs carry the names clients actually use. `basicConstraints=CA:TRUE` is set so +the file can also be imported as a trust anchor on macOS/iOS. + +**Install the certificate in two places.** As `ServerCertificate`/`ServerKey` +(the documented way, added to `cupsd.conf` when the directives are absent — +which also repairs a `cupsd.conf` that `cupsctl` rewrote), *and* as +`.crt`/`.key` for both the host name and the Avahi FQDN, because that is +where cupsd 2.4 actually loaded the certificate from in testing. Belt and +braces: either path alone would be enough on some versions, together they hold +on the version in the image. The copies are rewritten on every start, since +cupsd may replace them. + +**Reuse the certificate whenever it already covers the required names.** +Reissuing forces every client that trusted the old certificate to trust a new +one, so regeneration is limited to genuinely missing names, and the recorded +SAN list is compared as a set (sorted, deduplicated) so the order in which +names are discovered does not matter. A transient failure to query Avahi +therefore does not churn the certificate. Consequence: names are never *removed* +from an existing certificate; that requires deleting it. + +**Treat TLS failures as non-fatal, but fix the restart defect properly.** +If the certificate directory is not writable (for example a read-only bind +mount), the entrypoint warns and lets CUPS fall back to its own certificate, +because plain IPP printing still works. For startup, the stale-state problem is +fixed at its root: D-Bus and Avahi are started based on whether their *process* +is running, and their leftover socket/PID files are removed first. Avahi failing +to start is reported as a warning rather than ending the entrypoint, so a +discovery problem cannot take the whole print server down. + +## Risks / Trade-offs + +- [Self-signed certificate is rejected by validating clients] → Documented: + trust the certificate on macOS/iOS, or keep using plain IPP. The entrypoint + cannot influence a client's trust store. +- [Only the common name is known on first start if Avahi is not up yet] → The + name is re-checked on every start and the certificate is reissued when a + required name is missing, so the next start heals it. Host networking keeps + this rare. +- [A name that later disappears stays in the certificate] → Accepted: extra + SANs are harmless and churn would invalidate client trust. +- [Destroying `server.san` loses the record of covered names] → Worst case the + certificate is reissued once, which is the same as losing the certificate. +- [Restarting rewrites `.crt`, overwriting a certificate the user placed + there by hand] → Accepted; this image owns the certificate directory. +- [Host name changes are not picked up until a restart] → Accepted; the + certificate is inspected at startup only. + +## Migration Plan + +Deploy by rebuilding the image; no data migration. An existing `/etc/cups` +volume is repaired on first start: the `ServerCertificate`/`ServerKey` +directives are appended to the existing `cupsd.conf` and the stale +`.crt` is replaced. Clients that already added the queue as a +*Generic PostScript printer* must remove and re-add it, because macOS caches the +driver choice. Rollback is redeploying the previous image; a certificate written +by this version is ignored by it. diff --git a/openspec/changes/advertise-valid-ipps-certificate/proposal.md b/openspec/changes/advertise-valid-ipps-certificate/proposal.md new file mode 100644 index 0000000..21bd7b5 --- /dev/null +++ b/openspec/changes/advertise-valid-ipps-certificate/proposal.md @@ -0,0 +1,67 @@ +# Proposal + +## Why + +macOS adds the shared queue as its bundled *Generic PostScript Printer* instead +of driverlessly over AirPrint, so the print dialog offers no duplex, no colour +and no tray selection — while iOS, which queries the printer live, does offer +them. The cause is the secure `_ipps` service: CUPS serves a certificate issued +for the container's host name, which is not the `.local` name Avahi +advertises and that clients validate. Anything that checks the name (macOS/iOS +AirPrint, `curl`) rejects the secure add and macOS silently falls back to the +generic driver; CUPS' own tools are lenient, so the server looks healthy. + +Testing the fix surfaced a second, independent defect: restarting the container +(`podman restart`, or a systemd `Restart=` of the quadlet) leaves stale D-Bus +and Avahi PID files and sockets in `/run`. Both daemons then refuse to start +and `set -e` stops the entrypoint, so the print server stays dead until the +container is recreated. + +## What Changes + +- Generate and install the `_ipps` certificate in the entrypoint: self-signed, + with SANs covering the container host name, `.local`, the name + Avahi advertises, `localhost`, `localhost.localdomain`, + `host.containers.internal` and `127.0.0.1`, plus whatever `CERT_EXTRA_NAMES` + lists; `CERT_DAYS` sets its validity. +- Install the certificate where CUPS actually looks for it: as + `ServerCertificate`/`ServerKey` (added to `cupsd.conf` when missing, also + repairing a file rewritten by `cupsctl`) and as `.crt`/`.key` for the + host name and the Avahi FQDN, because CUPS 2.4 keeps serving its own + `.crt` even when `ServerCertificate` is set. +- Reuse an existing certificate when it already covers every required name, so + a restart does not reissue it and clients that trusted it do not have to + trust it again. +- Add `openssl` explicitly to the image; it was only a transitive dependency of + `ca-certificates`. +- Remove stale D-Bus and Avahi runtime state (socket, PID file) before starting + those daemons, and warn instead of exiting when Avahi cannot start. +- Document the certificate, `CERT_EXTRA_NAMES`/`CERT_DAYS`, the client trust + step and the macOS generic-driver fallback in the README. Documentation only, + no spec behaviour. + +Non-goals: shipping a publicly trusted certificate or a private CA, changing the +`_ipp` (plain) service, and changing how jobs are converted or sent to the +printer. + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `printserver/cups`: the image dependency requirement now includes the TLS + tooling, and the capability gains requirements for a secure AirPrint + certificate that matches the advertised name and for starting cleanly when + stale runtime state is left behind by a previous run. + +## Impact + +- `entrypoint.sh`: certificate generation/installation, D-Bus and Avahi startup. +- `Containerfile`: explicit `openssl`. +- `README.md`: `CERT_EXTRA_NAMES`/`CERT_DAYS`, a certificate section, and the + macOS generic-driver fallback under Troubleshooting. +- Clients: macOS/iOS can validate the secure service; plain IPP and existing + client-side Windows driver setups are unaffected. +- Existing deployments: an existing `/etc/cups` volume is repaired on the next + start (the stale certificate is replaced); no volume reset is needed. diff --git a/openspec/changes/advertise-valid-ipps-certificate/specs/printserver/cups/spec.md b/openspec/changes/advertise-valid-ipps-certificate/specs/printserver/cups/spec.md new file mode 100644 index 0000000..4e3e2f6 --- /dev/null +++ b/openspec/changes/advertise-valid-ipps-certificate/specs/printserver/cups/spec.md @@ -0,0 +1,69 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Container image builds with required dependencies +The container SHALL include CUPS, cups-filters, Avahi/mDNS, D-Bus, the +conversion tools needed to turn client documents into PostScript and the TLS +tooling needed for the secure AirPrint service, and SHALL NOT require any 32-bit +runtime libraries. + +#### Scenario: Image contains CUPS +- **WHEN** the image is built +- **THEN** CUPS is installed and available at /usr/sbin/cupsd + +#### Scenario: No 32-bit runtime required +- **WHEN** the image is built +- **THEN** the image contains no i386 multiarch libraries and no Brother vendor binaries + +#### Scenario: TLS tooling is available +- **WHEN** the image is built +- **THEN** `openssl` is installed so the entrypoint can generate the certificate for the secure AirPrint service + +## ADDED 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: 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 + +### Requirement: Startup recovers from stale runtime state +The container SHALL start successfully after a restart even though the previous +run left runtime state behind, without requiring a new container or volume. + +#### Scenario: Restart with leftover runtime state +- **WHEN** the container is restarted and the runtime directory still holds the previous run's D-Bus and Avahi sockets and PID files +- **THEN** D-Bus and Avahi start again, CUPS serves the queue and AirPrint discovery works + +#### Scenario: Avahi cannot start +- **WHEN** the Avahi daemon fails to start +- **THEN** the entrypoint logs a warning and keeps serving IPP rather than exiting diff --git a/openspec/changes/advertise-valid-ipps-certificate/tasks.md b/openspec/changes/advertise-valid-ipps-certificate/tasks.md new file mode 100644 index 0000000..1acf72f --- /dev/null +++ b/openspec/changes/advertise-valid-ipps-certificate/tasks.md @@ -0,0 +1,29 @@ +# Tasks + +## 1. Image dependency + +- [x] 1.1 Install `openssl` explicitly in the `Containerfile` and verify it is present in the built image (`podman run --rm --entrypoint sh -c 'command -v openssl'` prints a path) + +## 2. Certificate generation, installation and documentation + +- [x] 2.1 Build the SAN list from the container host name, its `.local` form, the Avahi-advertised FQDN (D-Bus `GetHostNameFqdn`), `localhost`, `localhost.localdomain`, `host.containers.internal`, `127.0.0.1` and `CERT_EXTRA_NAMES`, sorted and deduplicated; verify the entrypoint log line `certificate SANs: …` lists all of them for a test container started with `CERT_EXTRA_NAMES=192.168.100.20` +- [x] 2.2 Generate the self-signed certificate and key with `openssl` (CN = host name, `basicConstraints=critical,CA:TRUE`, validity from `CERT_DAYS`) when the certificate directory has none; verify `/etc/cups/ssl/server.crt` exists in the container and `openssl x509 -noout -ext subjectAltName` lists the expected names +- [x] 2.3 Make CUPS serve that certificate: add `ServerCertificate`/`ServerKey` to `cupsd.conf` when absent (repairing a `cupsctl`-rewritten file) and write copies to `.crt`/`.key` and `.crt`/`.key`; verify from outside the container that `openssl s_client -connect host:port -servername ` shows the advertised name in the presented certificate's SANs +- [x] 2.4 Reuse the certificate whenever every required name is already covered, reissue only when one is missing, never reorder-triggered; verify a restart logs `TLS certificate is up to date`, and that adding a `CERT_EXTRA_NAMES` entry makes the next start generate a new certificate +- [x] 2.5 Warn instead of exiting when the certificate cannot be written; verify with a read-only `/etc/cups` mount that the entrypoint logs the warning and CUPS still serves plain IPP +- [x] 2.6 Document `CERT_EXTRA_NAMES`/`CERT_DAYS`, the certificate, how to inspect it and how to trust it on macOS/iOS in `README.md`; verify the environment-variable table and the certificate section match `entrypoint.sh` + +## 3. Startup robustness + +- [x] 3.1 Start D-Bus based on whether `dbus-daemon` is running and remove its stale socket/PID file first; verify that after `podman restart` the container is `running` and `pgrep -x dbus-daemon` inside it succeeds +- [x] 3.2 Apply the same stale-state cleanup to Avahi and downgrade a start failure to a warning; verify that after a restart `avahi-browse -rt _ipp._tcp` inside the container lists the queue and the container stays up even when avahi cannot start + +## 4. Integration verification + +- [x] 4.1 On a fresh named volume, start the image and verify the client-visible result: the served certificate covers the Avahi-advertised name, the queue is advertised over mDNS after a restart, and the restart logs `TLS certificate is up to date` +- [x] 4.2 Verify an existing deployment volume is repaired on first start: with a volume whose `cupsd.conf` has no `ServerCertificate` directive and whose `.crt` is a stale foreign certificate, start the container and verify the directive is added and the stale certificate is replaced + +## 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`.