#!/bin/sh # Startup script for the containerized CUPS 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 # converts client documents to PostScript and the PPD turns device options # (tray, duplex, colour, ...) into PostScript "setpagedevice" instructions. # # Configuration (all optional, via environment): # PRINTER_IP IP address / host name of the network printer. # PRINTER_URI Full device URI; overrides PRINTER_IP when set. # PRINTER_PROTOCOL socket (default), lpd or ipp - used with PRINTER_IP. # PRINTER_NAME CUPS queue name; base of the advertised (DNS-SD) # name " @ " while no Description is set. # PRINTER_DESCRIPTION CUPS Description (lpadmin -D), shown in the web UI; # takes precedence over PRINTER_NAME as the advertised # (DNS-SD) name. # PPD_FILE PPD used for the queue. # PRINTER_OPTIONS Space-separated PPD option assignments (key=value) # applied as queue defaults on every start, e.g. # "BRColorMode=Vivid TonerSaveMode=False". # 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}" PRINTER_IP="${PRINTER_IP:-}" PRINTER_URI="${PRINTER_URI:-}" PRINTER_PROTOCOL="${PRINTER_PROTOCOL:-socket}" PRINTER_DESCRIPTION="${PRINTER_DESCRIPTION:-}" PPD_FILE="${PPD_FILE:-/usr/share/cups/model/BR4150_2.PPD}" PRINTER_OPTIONS="${PRINTER_OPTIONS:-}" LOG_PREFIX="[entrypoint]" log() { echo "${LOG_PREFIX} $*"; } # Build a device URI from PRINTER_IP unless an explicit PRINTER_URI is given. # Raw AppSocket is the default: the HL-4150CDN accepts PostScript directly on # port 9100 (its BR-Script3 interpreter auto-detects the job language). if [ -z "${PRINTER_URI}" ] && [ -n "${PRINTER_IP}" ]; then case "${PRINTER_PROTOCOL}" in socket) PRINTER_URI="socket://${PRINTER_IP}:9100" ;; lpd) PRINTER_URI="lpd://${PRINTER_IP}/binary_p1" ;; ipp) PRINTER_URI="ipp://${PRINTER_IP}/ipp/print" ;; *) log "unknown PRINTER_PROTOCOL '${PRINTER_PROTOCOL}', defaulting to socket" PRINTER_URI="socket://${PRINTER_IP}:9100" ;; esac fi # --- D-Bus system bus ------------------------------------------------------- mkdir -p /run/dbus /var/lib/dbus 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 ! 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 # --- Avahi / mDNS ----------------------------------------------------------- mkdir -p /run/avahi-daemon if ! pgrep -x avahi-daemon >/dev/null 2>&1; then log "starting avahi-daemon" # 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 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" 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 } # 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 cert_lookup_names | sort -u | while IFS= read -r name; do [ -n "${name}" ] || continue 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. 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 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 & CUPSD_PID=$! # Wait until CUPS accepts requests. i=0 while [ "$i" -lt 30 ]; do if lpstat -r >/dev/null 2>&1; then break fi i=$((i + 1)) sleep 0.5 done # Share printers on the network and allow remote administration. cupsctl --share-printers --remote-admin >/dev/null 2>&1 || true # --- Brother HL-4150CDN queue ---------------------------------------------- if lpstat -p "${PRINTER_NAME}" >/dev/null 2>&1; then log "printer ${PRINTER_NAME} already exists" elif [ -n "${PRINTER_URI}" ]; then if [ -f "${PPD_FILE}" ]; then log "adding printer ${PRINTER_NAME} -> ${PRINTER_URI}" lpadmin -p "${PRINTER_NAME}" -E -v "${PRINTER_URI}" -P "${PPD_FILE}" \ || log "warning: lpadmin failed to add ${PRINTER_NAME}" else log "warning: PPD ${PPD_FILE} not found; skipping printer creation" fi else log "neither PRINTER_URI nor PRINTER_IP is set; skipping automatic printer creation" fi # Make sure the queue is shared, named for advertising and set as default. if lpstat -p "${PRINTER_NAME}" >/dev/null 2>&1; then lpadmin -p "${PRINTER_NAME}" -o printer-is-shared=true -E >/dev/null 2>&1 || true if [ -n "${PRINTER_DESCRIPTION}" ]; then lpadmin -p "${PRINTER_NAME}" -D "${PRINTER_DESCRIPTION}" >/dev/null 2>&1 || true fi lpadmin -d "${PRINTER_NAME}" >/dev/null 2>&1 || true log "queue ${PRINTER_NAME} is shared and set as default" fi # Apply optional queue defaults from PRINTER_OPTIONS (see README). Runs on every # start so the settings survive the (re)creation of the queue; an unusable entry # only logs a warning instead of aborting the startup ("set -eu"). Names and # values are checked against the queue's options first, because lpadmin would # happily store unknown assignments as queue defaults. apply_printer_options() { [ -n "${PRINTER_OPTIONS}" ] || return 0 if ! lpstat -p "${PRINTER_NAME}" >/dev/null 2>&1; then log "warning: queue ${PRINTER_NAME} does not exist; skipping PRINTER_OPTIONS" return 0 fi ppd_options="$(lpoptions -p "${PRINTER_NAME}" -l 2>/dev/null || true)" for opt in ${PRINTER_OPTIONS}; do case "${opt}" in ?*=*) ;; *) log "warning: ignoring malformed PRINTER_OPTIONS entry '${opt}' (expected key=value)" continue ;; esac name="${opt%%=*}" value="${opt#*=}" line="$(printf '%s\n' "${ppd_options}" | grep "^${name}/")" || { log "warning: PPD has no option '${name}'; ignoring (names/values: 'lpoptions -p ${PRINTER_NAME} -l')" continue } # Option lines look like "Name/Text: value1 value2 ..." ('*' = default). values="$(printf '%s\n' "${line#*: }" | tr ' ' '\n' | sed 's/^\*//')" if ! printf '%s\n' "${values}" | grep -qxF "${value}" \ && ! { case "${value}" in Custom.*) printf '%s\n' "${values}" | grep -q 'Custom\.' ;; *) false ;; esac; }; then log "warning: PPD option '${name}' has no value '${value}'; ignoring" continue fi if lpadmin -p "${PRINTER_NAME}" -o "${opt}" >/dev/null 2>&1; then log "queue default ${opt} applied" else log "warning: lpadmin -o ${opt} failed; ignoring" fi done } apply_printer_options # --- Verify CUPS is reachable on the network -------------------------------- # cupsd also runs with nothing but its domain socket, e.g. when it cannot bind # the privileged port 631 (rootless Podman with host networking). It then still # advertises the queue over mDNS, so clients see a printer they cannot reach. # Detect that and fail loudly instead of reporting a healthy server. if ! curl -fsS --noproxy '*' --max-time 2 -o /dev/null http://127.0.0.1:631/ 2>/dev/null; then log "ERROR: cupsd is not listening on TCP 631 - network clients (AirPrint/IPP) cannot reach this server" if [ -f /var/log/cups/error_log ]; then log " recent CUPS errors:" tail -n 5 /var/log/cups/error_log | sed 's/^/ /' fi log " If the log shows 'Unable to open listen socket for address 0.0.0.0:631 - Permission denied'," log " port 631 is privileged and this rootless container may not bind it on the host network." log " Either allow unprivileged binding of 631 (host, needs root), persistently:" log " echo 'net.ipv4.ip_unprivileged_port_start=631' > /etc/sysctl.d/90-unprivileged-ports.conf" log " sysctl --system" log " or run this container as a rootful (system) service." log " See the Troubleshooting section of the README." exit 1 fi # --- DNS-SD name collision check -------------------------------------------- # CUPS advertises every shared queue as the DNS-SD instance # " @ " - the queue name when no Description is set. # Avahi can register only one instance per name, and CUPS 2.4 does not rename # on collision (scheduler/dnssd.c: "TODO: Handle collisions with # avahi_alternate_service_name"), so with duplicate derived names one queue is # silently never advertised. Clients that pinned the missing name - macOS # stores the exact instance name in the queue's dnssd:// device URI - then # report the printer offline. Detection only; a collision is never fatal # (see README, "macOS shows the printer offline after some time"). check_dnssd_name_collisions() { # Queue names and Descriptions straight from cupsd, forced to the C locale # so the "Description:" detail line parses regardless of the container # locale. An empty or failed result skips the check silently. lpstat_out="$(LC_ALL=C lpstat -l -p 2>/dev/null || true)" [ -n "${lpstat_out}" ] || return 0 # One "basequeue1, queue2" line per colliding name. The Shared flags # come from printers.conf (both and blocks, # "Shared Yes/No"); a queue that cannot be read there (file not flushed # after this run's lpadmin, no Shared line yet) counts as shared: a false # warning is actionable, a missed one reproduces the incident. Every parse # failure degrades to "no collision found" so that set -eu never trips # inside this diagnostic. collisions="$(printf '%s\n' "${lpstat_out}" | awk -v conf=/etc/cups/printers.conf ' BEGIN { while ((getline line < conf) > 0) { if (line ~ /^<(Default)?Printer /) { cur = line sub(/^<(Default)?Printer /, "", cur) sub(/>$/, "", cur) } else if (line ~ /^<\/(Default)?Printer>$/) { cur = "" } else if (cur != "" && line ~ /^Shared /) { flag[cur] = tolower(substr(line, 8)) } } close(conf) } /^[ \t]*printer / { curq = $2 order[++n] = curq next } /^[ \t]*Description: / { sub(/^[ \t]*Description: /, "") if (curq != "") desc[curq] = $0 } END { for (i = 1; i <= n; i++) { q = order[i] if ((q in flag) && flag[q] == "no") continue base = ((q in desc) && desc[q] != "") ? desc[q] : q count[base]++ list[base] = list[base] (list[base] == "" ? "" : ", ") q } for (b in count) if (count[b] > 1) print b "\t" list[b] }' || true)" [ -n "${collisions}" ] || return 0 dnssd_host=" @ $(hostname -s 2>/dev/null || hostname 2>/dev/null || printf host)" printf "%s\n" "${collisions}" \ | while IFS="$(printf '\t')" read -r base queues; do log "warning: queues ${queues} would all be advertised as the DNS-SD name" log " '${base}${dnssd_host}' - Avahi keeps only one of them, so every" log " other queue is silently NOT advertised, and clients pinned to the" log " missing name (macOS dnssd:// device URI) show the printer offline." log " fix: give each queue a unique Description (lpadmin -p -D \"...\")" log " or remove the redundant queue (lpadmin -x ); then remove" log " and re-add the printer on the client - see README, \"macOS shows" log " the printer offline after some time\"." done return 0 } check_dnssd_name_collisions log "ready: CUPS on 631/tcp, mDNS on 5353/udp" # Keep the container running by waiting on cupsd. wait "${CUPSD_PID}"