This commit is contained in:
1 parent
7f26ca4f2f
commit
db23feada5
8 files changed
+561
-15
No files matched your search
+3
-1
@@ -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 -----------------------
|
||||
|
||||
@@ -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
|
||||
`<host>.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, `<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://<host>.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 <queue> -l` lists only `APOptionalDuplexer` plus
|
||||
`Duplex`/`PageSize`/`ManualFeed`/`InputSlot`, and
|
||||
`/etc/cups/ppd/<queue>.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://<host>:631/printers/<queue>`
|
||||
prints the UUID; drop the `urn:uuid:` prefix):
|
||||
|
||||
```sh
|
||||
sudo lpadmin -x <queue>
|
||||
sudo lpadmin -p <queue> -E -m everywhere \
|
||||
-v "dnssd://<queue>%20%40%20<host>._ipps._tcp.local./?uuid=<printer-uuid>"
|
||||
lpoptions -p <queue> -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:
|
||||
|
||||
+175
-5
@@ -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://<ip>: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,8 +73,170 @@ 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 (<host>.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.
|
||||
# <name>.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"
|
||||
|
||||
@@ -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`.
|
||||
Reference in new issue
Block a user