359 lines
16 KiB
Bash
Executable File
359 lines
16 KiB
Bash
Executable File
#!/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://<ip>: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 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}"
|
|
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}"
|
|
|
|
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 (<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 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
|
|
# <name>.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. <name>.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
|
|
|
|
# --- 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
|
|
|
|
log "ready: CUPS on 631/tcp, mDNS on 5353/udp"
|
|
|
|
# Keep the container running by waiting on cupsd.
|
|
wait "${CUPSD_PID}"
|