Advertise a valid IPPS certificate for AirPrint
ci/woodpecker/push/build Pipeline was successful

This commit is contained in:
juergen committed 2026-10-18 22:35:48 +02:00
1 parent 7f26ca4f2f
commit db23feada5
8 files changed
+561 -15

No files matched your search

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-18
@@ -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 (`<host>.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 `<certificate dir>/<host name>.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:<host name>`,
`DNS:<host name>.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
`<name>.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 `<name>.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
`<host name>.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.
@@ -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 `<host>.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, `<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 `<name>.crt`/`.key` for the
host name and the Avahi FQDN, because CUPS 2.4 keeps serving its own
`<host name>.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
<!-- None: the certificate and restart behaviour extend the existing capability. -->
### 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.
@@ -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 `<host name>.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
@@ -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 <image> -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 `<host name>.crt`/`.key` and `<Avahi FQDN>.crt`/`.key`; verify from outside the container that `openssl s_client -connect host:port -servername <advertised name>` 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 `<host name>.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`.