From 6cd4d56de14b3d747f6ecba21db808f8e65be4bc Mon Sep 17 00:00:00 2001 From: Bastian de Byl Date: Sun, 13 Sep 2026 23:14:45 -0400 Subject: [PATCH] feat(labelprint): 4x6 label print proxy on a Raspberry Pi A Pi 3B+ (stickah.local) shares a Phomemo PM246 to the LAN as a plain CUPS queue, so any machine can print 4x6 labels -- fulfillr-site's shipping labels in particular -- without installing the vendor driver, which is x86-64 only. The role builds the TSPL CUPS driver from source instead. It is Debian, not Fedora, so it lives in its own inventory and playbook (make deploy-labelprint / check-labelprint) and the home.debyl.io roles can never run against it. make bootfs renders its cloud-init first-boot files onto a freshly imaged SD card from the same templates the role uses. The Wi-Fi credentials for the home and rescue networks are in the vault. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 21 ++- Makefile | 19 ++ ansible/bootfs.yml | 106 ++++++++++++ ansible/deploy_labelprint.yml | 14 ++ ansible/inventories/labelprint/hosts.yml | 16 ++ ansible/roles/labelprint/README.md | 162 ++++++++++++++++++ ansible/roles/labelprint/defaults/main.yml | 118 +++++++++++++ .../roles/labelprint/files/trim-ppd-media.awk | 25 +++ ansible/roles/labelprint/handlers/main.yml | 41 +++++ ansible/roles/labelprint/tasks/base.yml | 92 ++++++++++ ansible/roles/labelprint/tasks/cups.yml | 136 +++++++++++++++ ansible/roles/labelprint/tasks/driver.yml | 147 ++++++++++++++++ ansible/roles/labelprint/tasks/firewall.yml | 20 +++ ansible/roles/labelprint/tasks/main.yml | 6 + ansible/roles/labelprint/tasks/wifi.yml | 133 ++++++++++++++ .../labelprint/templates/bootfs/meta-data.j2 | 11 ++ .../templates/bootfs/network-config.j2 | 18 ++ .../labelprint/templates/bootfs/user-data.j2 | 125 ++++++++++++++ .../roles/labelprint/templates/cupsd.conf.j2 | 97 +++++++++++ .../templates/home-wifi.nmconnection.j2 | 32 ++++ .../labelprint/templates/nftables.conf.j2 | 63 +++++++ .../templates/rescue-ap.nmconnection.j2 | 38 ++++ .../templates/wifi-rescue.service.j2 | 9 + .../labelprint/templates/wifi-rescue.sh.j2 | 132 ++++++++++++++ .../labelprint/templates/wifi-rescue.timer.j2 | 13 ++ ansible/vars/vault.yml | Bin 34461 -> 34980 bytes 26 files changed, 1593 insertions(+), 1 deletion(-) create mode 100644 ansible/bootfs.yml create mode 100644 ansible/deploy_labelprint.yml create mode 100644 ansible/inventories/labelprint/hosts.yml create mode 100644 ansible/roles/labelprint/README.md create mode 100644 ansible/roles/labelprint/defaults/main.yml create mode 100644 ansible/roles/labelprint/files/trim-ppd-media.awk create mode 100644 ansible/roles/labelprint/handlers/main.yml create mode 100644 ansible/roles/labelprint/tasks/base.yml create mode 100644 ansible/roles/labelprint/tasks/cups.yml create mode 100644 ansible/roles/labelprint/tasks/driver.yml create mode 100644 ansible/roles/labelprint/tasks/firewall.yml create mode 100644 ansible/roles/labelprint/tasks/main.yml create mode 100644 ansible/roles/labelprint/tasks/wifi.yml create mode 100644 ansible/roles/labelprint/templates/bootfs/meta-data.j2 create mode 100644 ansible/roles/labelprint/templates/bootfs/network-config.j2 create mode 100644 ansible/roles/labelprint/templates/bootfs/user-data.j2 create mode 100644 ansible/roles/labelprint/templates/cupsd.conf.j2 create mode 100644 ansible/roles/labelprint/templates/home-wifi.nmconnection.j2 create mode 100644 ansible/roles/labelprint/templates/nftables.conf.j2 create mode 100644 ansible/roles/labelprint/templates/rescue-ap.nmconnection.j2 create mode 100644 ansible/roles/labelprint/templates/wifi-rescue.service.j2 create mode 100644 ansible/roles/labelprint/templates/wifi-rescue.sh.j2 create mode 100644 ansible/roles/labelprint/templates/wifi-rescue.timer.j2 diff --git a/CLAUDE.md b/CLAUDE.md index f91179b..3745e8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,8 @@ This is a home infrastructure deployment repository using Ansible for automated - `make deploy TAGS=sometag` - Deploy only specific tagged tasks - `make deploy TARGET=specific-host` - Deploy to specific host instead of all - `make check` - Run deployment in dry-run mode showing potential changes +- `make deploy-labelprint` / `make check-labelprint` - Deploy (or dry-run) the label print proxy Pi. Separate inventory and playbook from the home server - see `ansible/roles/labelprint/README.md` +- `make bootfs BOOTFS=/Volumes/bootfs` - Render the label print proxy's cloud-init first-boot files onto a freshly imaged SD card - `make vault` - Edit encrypted Ansible vault file - `make list-tags` - List all available Ansible tags - `make list-tasks` - List all Ansible tasks @@ -33,13 +35,16 @@ The project uses Python virtualenv for dependency management: ansible/ ├── deploy.yml # Main playbook entry point (imports deploy_home.yml) ├── deploy_home.yml # Core playbook with role definitions +├── deploy_labelprint.yml # Label print proxy Pi (separate host, Debian) ├── inventories/home/ # Inventory configuration +├── inventories/labelprint/ # Label print proxy inventory ├── roles/ # Ansible roles organized by function │ ├── common/ # Base system configuration │ ├── git/ # Git repository management │ ├── podman/ # Container orchestration │ ├── ssl/ # Legacy SSL management (deprecated - Caddy handles certificates automatically) │ ├── github-actions/# CI/CD runner setup +│ ├── labelprint/ # 4x6 label print proxy (Raspberry Pi, CUPS/TSPL) │ └── pihole/ # DNS filtering └── vars/ └── vault.yml # Encrypted secrets @@ -96,13 +101,27 @@ Tasks are tagged by service/component for selective deployment: ## Target Environment -- Single target host: `home.debyl.io` +- Primary target host: `home.debyl.io` - OS: Fedora (ansible_user: fedora) - Container runtime: Podman - Web server: Caddy with automatic HTTPS and built-in security (replaced nginx + ModSecurity) - All services accessible via HTTPS with automatic certificate renewal - ~~CI/CD: Drone CI infrastructure completely decommissioned~~ +### Label print proxy + +- Second target host: `stickah.local` (Raspberry Pi 3B+, Raspberry Pi OS 64-bit, + apt not dnf) with a Phomemo PM246 4x6 label printer on USB +- Deployed only via `make deploy-labelprint` - it is in its own inventory so the + Fedora roles can never run against it +- The SD card image is written by hand with rpi-imager, but its cloud-init files + come from `make bootfs`, rendered from the same templates the role uses +- Login is `stickah` / `stickah` with SSH key auth preferred; password auth is a + deliberate LAN-only fallback so the box is never unreachable +- LAN-only (192.168.1.0/24), discovered over mDNS, no DNS record +- Falls back to its own rescue Wi-Fi AP at 192.168.4.1 when the home SSID is + unreachable + ### Remote SSH Commands for Service Users The `podman` user (and other service users) have `/bin/nologin` as their shell. To run commands as these users via SSH: diff --git a/Makefile b/Makefile index 6a0c408..63a478f 100644 --- a/Makefile +++ b/Makefile @@ -21,6 +21,9 @@ VAULT_FILE=ansible/vars/vault.yml # Variables ANSIBLE_INVENTORY=ansible/inventories/home/hosts.yml +# The label print proxy is a separate inventory and playbook: it is Debian, not +# Fedora, and shares none of the roles home.debyl.io runs. +ANSIBLE_INVENTORY_LABELPRINT=ansible/inventories/labelprint/hosts.yml #SSH_KEY=${HOME}/.ssh/id_rsa_home_ansible # Default to all ansible tags to run (passed via 'make deploy TAGS=sometag') @@ -29,6 +32,9 @@ SKIP_TAGS?=none TARGET?=all EXTRA_VARS?= +# Mounted boot partition of the label print proxy's SD card (see `make bootfs`) +BOOTFS?=/Volumes/bootfs + ${VENV}: python3 -m venv ${VENV} ${VENV_BIN}/python3 -m pip install --upgrade pip @@ -55,6 +61,19 @@ SKIP_FILE=./.lint-vars.sh deploy: ${ANSIBLE} ${VAULT_FILE} ${ANSIBLE} --diff -t ${TAGS} --skip-tags ${SKIP_TAGS} -i ${ANSIBLE_INVENTORY} -l ${TARGET} --vault-password-file ${VAULT_PASS_FILE} $(if ${EXTRA_VARS},-e "${EXTRA_VARS}") ansible/deploy.yml +# Writes the Pi's cloud-init first-boot files onto a freshly imaged SD card. +bootfs: ${ANSIBLE} ${VAULT_FILE} + ${ANSIBLE} --diff -i ${ANSIBLE_INVENTORY_LABELPRINT} --vault-password-file ${VAULT_PASS_FILE} -e "bootfs=${BOOTFS}" ansible/bootfs.yml + +# Label print proxy (stickah.local). Override the address when the Pi has +# fallen back to its rescue AP: +# make deploy-labelprint EXTRA_VARS="ansible_host=192.168.4.1" +deploy-labelprint: ${ANSIBLE} ${VAULT_FILE} + ${ANSIBLE} --diff -t ${TAGS} --skip-tags ${SKIP_TAGS} -i ${ANSIBLE_INVENTORY_LABELPRINT} -l ${TARGET} --vault-password-file ${VAULT_PASS_FILE} $(if ${EXTRA_VARS},-e "${EXTRA_VARS}") ansible/deploy_labelprint.yml + +check-labelprint: ${ANSIBLE} ${VAULT_FILE} + ${ANSIBLE} --check --diff -t ${TAGS} --skip-tags ${SKIP_TAGS} -i ${ANSIBLE_INVENTORY_LABELPRINT} -l ${TARGET} --vault-password-file ${VAULT_PASS_FILE} $(if ${EXTRA_VARS},-e "${EXTRA_VARS}") ansible/deploy_labelprint.yml + list-tags: ${ANSIBLE} ${VAULT_FILE} ${ANSIBLE} --list-tags -i ${ANSIBLE_INVENTORY} -l ${TARGET} --vault-password-file ${VAULT_PASS_FILE} ansible/deploy.yml diff --git a/ansible/bootfs.yml b/ansible/bootfs.yml new file mode 100644 index 0000000..34b9ced --- /dev/null +++ b/ansible/bootfs.yml @@ -0,0 +1,106 @@ +--- +# Renders the Raspberry Pi's cloud-init first-boot files onto a freshly written +# SD card's boot partition: +# +# make bootfs BOOTFS=/Volumes/bootfs +# +# The image itself is still built by hand with rpi-imager -- this only writes +# the two cloud-init files onto it. Everything it writes comes from the same +# templates roles/labelprint uses, so the Pi boots with its Wi-Fi profiles and +# rescue access point already in place, and the first deploy has nothing to +# correct. +# +# The rendered files contain the Wi-Fi PSKs in the clear, as any Pi Wi-Fi setup +# does. They land on the SD card, never in this repo. +- hosts: localhost + gather_facts: false + connection: local + vars_files: + - vars/vault.yml + - roles/labelprint/defaults/main.yml + tasks: + - name: check that BOOTFS points at a Raspberry Pi boot partition + ansible.builtin.stat: + path: "{{ bootfs }}/config.txt" + register: labelprint_bootfs_check + tags: bootfs + + - name: refuse to write to anything else + ansible.builtin.assert: + that: labelprint_bootfs_check.stat.exists + fail_msg: >- + {{ bootfs }} has no config.txt, so it is not a Raspberry Pi boot + partition. Write the image with rpi-imager first, then re-run with + BOOTFS pointing at the mounted boot volume. + tags: bootfs + + - name: look for files rpi-imager already wrote + ansible.builtin.stat: + path: "{{ bootfs }}/{{ item }}" + register: labelprint_bootfs_existing + loop: + - user-data + - network-config + - cmdline.txt + tags: bootfs + + # Keeps whatever rpi-imager put there, so a bad render can be undone by hand + # without reflashing. force:false means the first run's backup is the one + # that survives -- a second run must not overwrite it with our own output. + - name: back up the files rpi-imager wrote + ansible.builtin.copy: + src: "{{ bootfs }}/{{ item.item }}" + dest: "{{ bootfs }}/{{ item.item }}.rpi-imager.bak" + mode: "0644" + force: false + loop: "{{ labelprint_bootfs_existing.results }}" + loop_control: + label: "{{ item.item }}" + when: item.stat.exists + tags: bootfs + + - name: render the cloud-init files + ansible.builtin.template: + src: "roles/labelprint/templates/bootfs/{{ item }}.j2" + dest: "{{ bootfs }}/{{ item }}" + mode: "0644" + loop: + - user-data + - network-config + tags: bootfs + + - name: hash the rendered user-data + ansible.builtin.stat: + path: "{{ bootfs }}/user-data" + checksum_algorithm: sha1 + register: labelprint_user_data_stat + tags: bootfs + + - name: stamp the instance id with that hash + ansible.builtin.template: + src: roles/labelprint/templates/bootfs/meta-data.j2 + dest: "{{ bootfs }}/meta-data" + mode: "0644" + vars: + labelprint_user_data_id: "{{ labelprint_user_data_stat.stat.checksum[:12] }}" + tags: bootfs + + # rpi-imager writes `ds=nocloud;i=` onto the kernel command line, and + # that id outranks the one in meta-data. Leave it alone and a card that has + # booted even once is seen by cloud-init as the same instance forever: it + # skips users, write_files and runcmd, silently, and the only symptom is a + # Pi that came up with none of this applied. Both places have to agree. + - name: pin the same instance id on the kernel command line + ansible.builtin.replace: + path: "{{ bootfs }}/cmdline.txt" + regexp: '(ds=nocloud[^\s]*?);i=[^\s]+' + replace: '\1;i={{ labelprint_hostname }}-{{ labelprint_user_data_stat.stat.checksum[:12] }}' + tags: bootfs + + - name: what to do next + ansible.builtin.debug: + msg: + - "Wrote user-data, network-config, meta-data and cmdline.txt to {{ bootfs }}." + - "Instance id is {{ labelprint_hostname }}-{{ labelprint_user_data_stat.stat.checksum[:12] }}; the Pi re-applies this config whenever it changes." + - "Eject the volume, boot the Pi, then: make deploy-labelprint" + tags: bootfs diff --git a/ansible/deploy_labelprint.yml b/ansible/deploy_labelprint.yml new file mode 100644 index 0000000..81e0f46 --- /dev/null +++ b/ansible/deploy_labelprint.yml @@ -0,0 +1,14 @@ +--- +# Label print proxy (Raspberry Pi 3B+, Raspberry Pi OS trixie). +# +# The Pi image itself is built by hand with rpi-imager and written to the SD +# card outside of Ansible -- see roles/labelprint/README.md for what that image +# has to contain. Everything from first boot onwards lives in the labelprint +# role: the print driver, the CUPS queue, security updates, and the Wi-Fi +# rescue access point. +- hosts: labelprint + vars_files: + - vars/vault.yml + roles: + - role: labelprint + tags: labelprint diff --git a/ansible/inventories/labelprint/hosts.yml b/ansible/inventories/labelprint/hosts.yml new file mode 100644 index 0000000..04bf855 --- /dev/null +++ b/ansible/inventories/labelprint/hosts.yml @@ -0,0 +1,16 @@ +--- +# The 4x6 label print proxy: a Raspberry Pi 3B+ with the Phomemo PM246 on USB, +# shared to the LAN over IPP/AirPrint. Deliberately a separate inventory from +# inventories/home: this host is Debian/apt and shares none of the Fedora roles +# that home.debyl.io runs, so `make deploy` can never reach it by accident. +# +# Reached by mDNS (avahi on the Pi, Bonjour/UDM Pro on the LAN) rather than by a +# DNS record. If the Pi has fallen back to its rescue access point, it is not on +# the LAN at all -- join the rescue SSID and deploy against 192.168.4.1 instead: +# +# make deploy-labelprint EXTRA_VARS="ansible_host=192.168.4.1" +labelprint: + hosts: + stickah.local: + ansible_user: stickah + ansible_python_interpreter: /usr/bin/python3 diff --git a/ansible/roles/labelprint/README.md b/ansible/roles/labelprint/README.md new file mode 100644 index 0000000..cf26e63 --- /dev/null +++ b/ansible/roles/labelprint/README.md @@ -0,0 +1,162 @@ +# labelprint — 4x6 label print proxy + +A Raspberry Pi 3B+ (`stickah.local`) with a **Phomemo PM246** on USB, shared to +the LAN so any Mac, Windows or Linux machine can print 4x6 labels without +installing a printer driver. Built for +[`fulfillr-site`](../../../../debyltech/fulfillr-site), which prints shipping +labels straight out of the browser, but the queue is a plain 4x6 label printer +and anything can use it. + +Deployed on its own: + +``` +make deploy-labelprint +make check-labelprint # dry run +make deploy-labelprint TAGS=cups # just the queue +``` + +## Why not the Phomemo driver + +The vendor driver that runs on `yoga` installs +`/usr/lib/cups/filter/rastertolabeltspl`, and that file is an **x86-64 ELF**. +There is no ARM build, so none of it can be reused on the Pi. + +The PM246 speaks TSPL over USB, so this role builds +[RunTheWall/tspl-cups-driver](https://github.com/RunTheWall/tspl-cups-driver) +(MIT) from a pinned commit instead: a CUPS raster filter, a backend and a PPD, +about a minute of compiling on the Pi. Pinned to a commit rather than installed +from the project's apt repo so that a version bump is a reviewed change in this +repo, and so the Pi carries no third-party signing key. + +The PM246's USB id is not in the driver's auto-detect list. `tspl://auto` is +tried first; if the queue cannot find the printer, read the id off the Pi and +pin it — see `labelprint_device_uri` in [defaults/main.yml](defaults/main.yml). + +## The image + +The SD card image is built by hand, but the cloud-init files on it are not: +`make bootfs` renders them from the same templates the role uses, so the Pi +boots with its Wi-Fi profiles, its rescue access point and its login already in +place. Nothing here needs the printer driver — that is Ansible's job. + +1. **Write the image** with rpi-imager: **Raspberry Pi OS (64-bit)**, Bookworm or + newer, so NetworkManager is the network stack. Verified against the + `2026-06-18` pi-gen build. Skip the customisation screen entirely — anything + set there is about to be overwritten. +2. **Render the boot files** onto the mounted boot partition: + + ``` + make bootfs # defaults to /Volumes/bootfs + make bootfs BOOTFS=/path/to/boot + ``` + + It refuses to write anywhere without a `config.txt`, and keeps whatever + rpi-imager wrote as `*.rpi-imager.bak`. +3. **Eject**, boot the Pi, and give cloud-init a couple of minutes. +4. `make deploy-labelprint`. + +`make bootfs` is safe to re-run. The instance id is a hash of the rendered +`user-data`, so an unchanged render leaves the card alone, and a changed one +makes the Pi re-apply it on the next boot. + +That id is written in **two** places, and they have to agree: `meta-data`, and +the `ds=nocloud;i=` token rpi-imager puts on the kernel command line in +`cmdline.txt`. The command line wins. Leave rpi-imager's id there and a card +that has booted even once looks like the same instance to cloud-init forever — +it skips `users`, `write_files` and `runcmd` without a word, and the only +symptom is a Pi that came up with none of this applied. `make bootfs` rewrites +both. + +To force a re-apply on a Pi that is already running, without pulling the card: + +``` +sudo sed -i 's/;i=[^ ]*/;i=/' /boot/firmware/cmdline.txt +sudo cloud-init clean --logs +sudo reboot +``` + +### Getting in + +- `ssh stickah@stickah.local` — your `~/.ssh/id_ed25519.pub` is installed, and + that is what Ansible uses. +- Password auth is **on**, with the password `stickah`. It is deliberately + trivial and deliberately not hashed: this is the fallback for a Pi that will + not take the key — reflashed card, someone else's laptop, standing at the + bench — and port 22 is reachable only from the LAN or from the rescue AP, + which has a real WPA2 password of its own. If that trade stops being the right + one, `labelprint_user_password` in [defaults/main.yml](defaults/main.yml) is + the only thing to change. + +## Wi-Fi and the rescue AP + +Normally the Pi is a station on the home SSID. When that network is +unreachable — the password changed, the router died, the Pi moved — the +`wifi-rescue` watchdog brings up an access point so there is still a way in: + +- Join the rescue SSID (both it and its password are in the vault). +- The Pi is at **192.168.4.1**: `ssh pi@192.168.4.1`, or print directly to + `ipp://192.168.4.1:631/printers/labels`. +- Deploy to it there with + `make deploy-labelprint EXTRA_VARS="ansible_host=192.168.4.1"`. + +The Pi also keeps the Wi-Fi profile netplan rendered from the image's original +`network-config`, at a lower autoconnect priority than `home-wifi`. It is a +deliberate fallback: if `home-wifi` is ever rendered wrong, the Pi still comes +back on the LAN rather than stranding itself on the rescue AP. + +The 3B+ has one radio and cannot hold an AP and a station link at the same +time, so the watchdog cannot listen for the home SSID while the AP is up. +Instead the AP drops every five minutes to scan -- about seven seconds if the +home SSID is plainly gone, up to half a minute if it is worth a join attempt -- +and comes back immediately if the home network is still missing. When the home SSID +does return, the Pi rejoins it and shuts the AP down by itself. + +If you are working over the rescue AP and do not want your session cut: + +``` +touch /run/wifi-rescue.hold +``` + +The hold expires after 30 minutes, so a forgotten hold file cannot strand the +Pi. Deploys take the hold automatically for as long as they run. + +Watch it work with `journalctl -t wifi-rescue -f`. + +## Adding the printer from a client + +Nothing to install anywhere — the Pi renders. + +- **macOS** — System Settings → Printers → `+`. It appears under its own name + as an **AirPrint** printer. (`BrowseDNSSDSubTypes _print,_universal` in + cupsd.conf is what makes macOS offer the driverless add instead of guessing at + Generic PostScript.) +- **Windows 10/11** — Add printer; it is discovered as IPP Everywhere / Mopria. +- **Linux** — discovered by `cups-browsed`, or add + `ipp://stickah.local:631/printers/labels` by hand. + +## Access + +LAN only, `192.168.1.0/24`, plus the rescue AP subnet. Enforced twice on +purpose: at nftables ([templates/nftables.conf.j2](templates/nftables.conf.j2)) +and again in cupsd's own `Location` blocks +([templates/cupsd.conf.j2](templates/cupsd.conf.j2)). CUPS is explicitly set +`--no-remote-any`; the upstream driver's `install.sh` turns that on, which would +offer this printer to anything that can route to the Pi. + +The Pi patches itself: `unattended-upgrades` is on, with the Raspberry Pi +archives added to the origins allowlist (Debian's default covers only the Debian +security origin, which would leave the kernel and firmware — the packages most +specific to this hardware — unpatched). Kernel updates reboot at 04:00. Nothing +here holds state across a reboot; a queued job is spooled to disk and resumes. + +## Secrets + +In `ansible/vars/vault.yml` (`make vault`), no `vault_` prefix, per repo +convention: + +| Key | What | +| --- | --- | +| `stickah_ssid` | Home SSID | +| `stickah_psk` | Home passphrase, or the 64-hex precomputed PSK | +| `stickah_ssid_rescue` | Rescue AP SSID | +| `stickah_psk_rescue` | Rescue AP passphrase (WPA2, 8+ characters) | diff --git a/ansible/roles/labelprint/defaults/main.yml b/ansible/roles/labelprint/defaults/main.yml new file mode 100644 index 0000000..acfa502 --- /dev/null +++ b/ansible/roles/labelprint/defaults/main.yml @@ -0,0 +1,118 @@ +--- +# --------------------------------------------------------------------------- +# Identity +# --------------------------------------------------------------------------- +# Reached as stickah.local. There is no DNS record for it: the UDM Pro passes +# mDNS across the LAN, so avahi on the Pi is the whole of the name service. +labelprint_hostname: stickah + +# The login the image creates and Ansible connects as. Password auth stays on +# with a trivial, documented password: this box has to be reachable when the SSH +# key is not an option -- a reflashed card, a different laptop, someone standing +# at the workshop bench -- and it is only reachable from the LAN or from its own +# rescue AP in the first place. The key is what Ansible actually uses. +labelprint_user: stickah +labelprint_user_password: stickah +labelprint_authorized_key_file: ~/.ssh/id_ed25519.pub + +# --------------------------------------------------------------------------- +# Printer +# --------------------------------------------------------------------------- +# Phomemo PM246, 4x6 direct thermal, 203 dpi, speaks TSPL over USB. +labelprint_queue: labels +labelprint_queue_info: 4x6 Label Printer (Phomemo PM246) +labelprint_queue_location: stickah + +# The tspl backend finds the printer's usblp node by USB id. "auto" matches only +# the ids the driver already knows, and the PM246 is not yet one of them -- so if +# a deploy leaves the queue unable to find the printer, read the id off the Pi: +# +# for n in /dev/usb/lp*; do udevadm info -q property -n "$n" | grep -E 'ID_(VENDOR|MODEL)_ID|ID_SERIAL_SHORT'; done +# +# and pin it here as tspl://- (a DASH, not a colon: CUPS parses ":pid" +# as a port number and rejects the URI), or as tspl:///dev/usb/lp0. +labelprint_device_uri: "tspl://auto" + +# 203dpi matches the PM246 head. The PPD defaults to 300dpi, which would render +# every label at ~2/3 scale on this printer. +labelprint_resolution: 203dpi +labelprint_media: na_index-4x6_4x6in + +# Cut every other media size out of the PPD, so 4x6 is the only paper a client +# can pick. Driverless clients build their own PPD from the IPP media-supported +# list cupsd derives from ours, and there is no lpadmin option that restricts +# that list -- trimming the PPD is the only lever. +# +# The trade is real: the driver's PPD also offers 100x150mm, 4x4, 2.25x1.5, 2x1 +# and a custom range, and this takes all of them away. Set false if you ever +# want this queue to run stock other than 4x6; a second queue off the untrimmed +# PPD is the better answer if you want both. +labelprint_media_only_4x6: true +# The PPD page-size keyword kept when the above is on. Pairs with +# labelprint_media, which is the same size under its IPP name. +labelprint_ppd_pagesize: w288h432 +# 0-15. 8 is the driver's default and a sane starting point for the cheap +# thermal stock; raise it if barcodes scan poorly, lower it if edges bleed. +labelprint_darkness: 8 +# in/sec x10. 40 = 4 in/sec. +labelprint_print_speed: 40 + +# --------------------------------------------------------------------------- +# Driver: RunTheWall/tspl-cups-driver (MIT) +# --------------------------------------------------------------------------- +# Built from source at a pinned commit rather than installed from the project's +# apt repo: this keeps a third-party signing key and package feed off the Pi, +# and makes the version we run a reviewed, deliberate bump in git history. +# +# The vendor Phomemo driver is not an option here -- its rastertolabeltspl +# filter ships as an x86-64 ELF only, and this host is aarch64. +labelprint_driver_repo: https://github.com/RunTheWall/tspl-cups-driver.git +labelprint_driver_version: f433b7774d80a4f6a901b6b998cb710fd79918a4 +labelprint_driver_src: /usr/local/src/tspl-cups-driver +labelprint_ppd_dir: /usr/share/ppd/tspl +# The PPD the queue is actually built from. +labelprint_ppd_active: >- + {{ labelprint_ppd_dir }}/{{ + 'tspl-label-4x6.ppd' if labelprint_media_only_4x6 else 'tspl-label.ppd' + }} + +# --------------------------------------------------------------------------- +# Network +# --------------------------------------------------------------------------- +# The only subnet allowed to reach CUPS. Everything else is dropped at nftables +# and refused again by cupsd's own access rules. +labelprint_lan_cidr: 192.168.1.0/24 + +# Rescue access point, brought up when the home SSID is unreachable. The Pi 3B+ +# has a single radio and cannot hold an AP and a station link at once, so this +# is strictly a fallback -- see templates/wifi-rescue.sh.j2. +labelprint_ap_addr: 192.168.4.1 +labelprint_ap_cidr: 192.168.4.0/24 +# How often the watchdog checks, and how long the AP stays up before it drops +# for a few seconds to scan for the home SSID again. +labelprint_watchdog_interval_secs: 60 +labelprint_ap_rescan_secs: 300 +# How long a hand-placed /run/wifi-rescue.hold pins the radio before the +# watchdog ignores it. Bounded so a forgotten hold file cannot strand the Pi. +labelprint_hold_max_age_secs: 1800 + +# --------------------------------------------------------------------------- +# Packages +# --------------------------------------------------------------------------- +labelprint_deps: + [ + avahi-daemon, + build-essential, + cups, + cups-filters, + dnsmasq-base, + git, + libcups2-dev, + network-manager, + nftables, + unattended-upgrades, + ] + +# Secrets live in ansible/vars/vault.yml (no vault_ prefix, per repo +# convention): stickah_ssid, stickah_psk, +# stickah_ssid_rescue, stickah_psk_rescue diff --git a/ansible/roles/labelprint/files/trim-ppd-media.awk b/ansible/roles/labelprint/files/trim-ppd-media.awk new file mode 100644 index 0000000..f4d8c1d --- /dev/null +++ b/ansible/roles/labelprint/files/trim-ppd-media.awk @@ -0,0 +1,25 @@ +# Cuts every media size except one out of a CUPS PPD. +# +# macOS and Windows add this printer driverless: they never see this PPD, they +# see the IPP media-supported list cupsd derives from it. Trimming here is +# therefore the only way to make 4x6 the single choice a client can offer -- +# there is no lpadmin option that restricts the media list. +# +# Regenerated from the driver's own PPD on every deploy, so a driver version +# bump carries its new PPD through this filter rather than being pinned to a +# fork. +# +# awk -v keep=w288h432 -f trim-ppd-media.awk tspl-label.ppd +# +# The four families below are keyed by the PPD page-size keyword in field 2, +# which reads as "w288h432/4 x 6 in:" -- hence the split on "/". +/^\*(PageSize|PageRegion|ImageableArea|PaperDimension) / { + split($2, f, "/") + if (f[1] != keep) next +} + +# Custom sizes would put "Manage Custom Sizes" back in the client's paper menu +# and let a job arrive at any dimension, which is the thing being prevented. +/^\*(CustomPageSize|ParamCustomPageSize|MaxMediaWidth|MaxMediaHeight)/ { next } + +{ print } diff --git a/ansible/roles/labelprint/handlers/main.yml b/ansible/roles/labelprint/handlers/main.yml new file mode 100644 index 0000000..89748b3 --- /dev/null +++ b/ansible/roles/labelprint/handlers/main.yml @@ -0,0 +1,41 @@ +--- +- name: restart cups + become: true + ansible.builtin.systemd: + name: cups.service + state: restarted + +- name: reload udev rules + become: true + ansible.builtin.command: + argv: [udevadm, control, --reload-rules] + changed_when: true + notify: trigger udev + +# --action=add, not the default "change": udev only creates SYMLINK+= entries +# when a device is added, so a change event reloads the rule and leaves +# /dev/usb/tspl-label missing until the printer is next replugged or rebooted. +- name: trigger udev + become: true + ansible.builtin.command: + argv: [udevadm, trigger, --subsystem-match=usbmisc, --action=add] + changed_when: true + +- name: reload systemd + become: true + ansible.builtin.systemd: + daemon_reload: true + +# NetworkManager only reads new keyfiles from disk on request. This does not +# disturb the live connection. +- name: reload networkmanager connections + become: true + ansible.builtin.command: + argv: [nmcli, connection, reload] + changed_when: true + +- name: reload nftables + become: true + ansible.builtin.systemd: + name: nftables.service + state: reloaded diff --git a/ansible/roles/labelprint/tasks/base.yml b/ansible/roles/labelprint/tasks/base.yml new file mode 100644 index 0000000..b98b6ce --- /dev/null +++ b/ansible/roles/labelprint/tasks/base.yml @@ -0,0 +1,92 @@ +--- +- name: set the hostname + become: true + ansible.builtin.hostname: + name: "{{ labelprint_hostname }}" + tags: [labelprint, base] + +# The hostname is also the mDNS name, and avahi publishes whatever is in +# /etc/hosts for 127.0.1.1. cloud-init writes this line on first boot from the +# image's own hostname, so it has to be corrected here too or the Pi answers to +# the wrong .local name. +- name: point 127.0.1.1 at the hostname + become: true + ansible.builtin.lineinfile: + path: /etc/hosts + regexp: '^127\.0\.1\.1\s' + line: "127.0.1.1\t{{ labelprint_hostname }}" + owner: root + group: root + mode: "0644" + tags: [labelprint, base] + +- name: install the print proxy packages + become: true + ansible.builtin.apt: + name: "{{ labelprint_deps }}" + state: present + update_cache: true + cache_valid_time: 3600 + tags: [labelprint, base] + +- name: publish the host over mDNS + become: true + ansible.builtin.systemd: + name: avahi-daemon.service + enabled: true + state: started + tags: [labelprint, base] + +# --------------------------------------------------------------------------- +# Unattended security updates +# --------------------------------------------------------------------------- +# This box sits on the LAN with an open IPP port and is not something anyone +# logs into for months at a time, so it patches itself. +- name: enable unattended upgrades + become: true + ansible.builtin.copy: + dest: /etc/apt/apt.conf.d/20auto-upgrades + content: | + APT::Periodic::Update-Package-Lists "1"; + APT::Periodic::Unattended-Upgrade "1"; + APT::Periodic::AutocleanInterval "7"; + owner: root + group: root + mode: "0644" + tags: [labelprint, base, updates] + +# Debian's stock 50unattended-upgrades allowlists the Debian security origin +# only. Raspberry Pi OS serves its own kernel, firmware and userland from the +# Raspberry Pi archives, so without these two extra origins the packages most +# specific to this hardware are exactly the ones that never get patched. +- name: allow the Raspberry Pi origins and reboot for kernel updates + become: true + ansible.builtin.copy: + dest: /etc/apt/apt.conf.d/52unattended-upgrades-labelprint + content: | + Unattended-Upgrade::Origins-Pattern { + "origin=Raspbian,codename=${distro_codename},label=Raspbian"; + "origin=Raspberry Pi Foundation,codename=${distro_codename},label=Raspberry Pi Foundation"; + }; + Unattended-Upgrade::Remove-Unused-Kernel-Packages "true"; + Unattended-Upgrade::Remove-Unused-Dependencies "true"; + // Nothing here holds state across a reboot -- a queued job is spooled on + // disk and resumes -- so take the kernel update at 04:00 rather than + // leaving the Pi running an unpatched kernel until someone notices. + Unattended-Upgrade::Automatic-Reboot "true"; + Unattended-Upgrade::Automatic-Reboot-Time "04:00"; + owner: root + group: root + mode: "0644" + tags: [labelprint, base, updates] + +- name: enable the unattended-upgrades timers + become: true + ansible.builtin.systemd: + name: "{{ item }}" + enabled: true + state: started + loop: + - apt-daily.timer + - apt-daily-upgrade.timer + tags: [labelprint, base, updates] diff --git a/ansible/roles/labelprint/tasks/cups.yml b/ansible/roles/labelprint/tasks/cups.yml new file mode 100644 index 0000000..96405d4 --- /dev/null +++ b/ansible/roles/labelprint/tasks/cups.yml @@ -0,0 +1,136 @@ +--- +- name: configure cupsd + become: true + ansible.builtin.template: + src: cupsd.conf.j2 + dest: /etc/cups/cupsd.conf + owner: root + group: lp + mode: "0640" + validate: /usr/sbin/cupsd -t -c %s + notify: restart cups + tags: [labelprint, cups] + +- name: enable cups + become: true + ansible.builtin.systemd: + name: cups.service + enabled: true + state: started + tags: [labelprint, cups] + +# cupsd.conf has to be in place and cupsd running before lpadmin can talk to it. +- name: apply pending cups changes before touching the queue + ansible.builtin.meta: flush_handlers + tags: [labelprint, cups] + +# --------------------------------------------------------------------------- +# The queue +# --------------------------------------------------------------------------- +# lpadmin is not idempotent and has no "show me everything you would set" mode, +# so the desired definition is fingerprinted and the fingerprint compared with +# what was last applied. The marker is written only after lpadmin succeeds. +# Checksummed here rather than taken from the install task's return value, so +# that the fingerprint is the same whether or not this run included the driver +# tasks -- `make deploy-labelprint TAGS=cups` must not look like a change. +- name: checksum the installed PPD + become: true + ansible.builtin.stat: + path: "{{ labelprint_ppd_active }}" + checksum_algorithm: sha1 + register: labelprint_ppd_stat + tags: [labelprint, cups] + +- name: build the desired queue fingerprint + ansible.builtin.set_fact: + labelprint_queue_want: >- + {{ + [ + labelprint_device_uri, + labelprint_queue_info, + labelprint_queue_location, + labelprint_resolution, + labelprint_media, + labelprint_darkness | string, + labelprint_print_speed | string, + labelprint_ppd_stat.stat.checksum | default('none'), + ] | join('|') + }} + tags: [labelprint, cups] + +- name: read the queue fingerprint that was last applied + become: true + ansible.builtin.slurp: + src: "/etc/cups/.{{ labelprint_queue }}.fingerprint" + register: labelprint_queue_have + failed_when: false + tags: [labelprint, cups] + +- name: create or update the label queue + become: true + ansible.builtin.command: + argv: + - lpadmin + - -p + - "{{ labelprint_queue }}" + - -E + - -v + - "{{ labelprint_device_uri }}" + - -P + - "{{ labelprint_ppd_active }}" + - -D + - "{{ labelprint_queue_info }}" + - -L + - "{{ labelprint_queue_location }}" + - -o + - printer-is-shared=true + - -o + - "Resolution={{ labelprint_resolution }}" + - -o + - "media={{ labelprint_media }}" + - -o + - "Darkness={{ labelprint_darkness }}" + - -o + - "PrintSpeed={{ labelprint_print_speed }}" + when: >- + (labelprint_queue_have.content | default('') | b64decode | trim) + != labelprint_queue_want | trim + register: labelprint_lpadmin + changed_when: true + tags: [labelprint, cups] + +- name: record the applied queue fingerprint + become: true + ansible.builtin.copy: + dest: "/etc/cups/.{{ labelprint_queue }}.fingerprint" + content: "{{ labelprint_queue_want | trim }}" + owner: root + group: root + mode: "0600" + when: labelprint_lpadmin is changed + tags: [labelprint, cups] + +- name: accept and enable the label queue + become: true + ansible.builtin.command: + argv: ["{{ item }}", "{{ labelprint_queue }}"] + loop: + - cupsaccept + - cupsenable + changed_when: false + tags: [labelprint, cups] + +# There is deliberately no `cupsctl` here. It is the obvious way to say +# "share on the LAN only", but cupsctl edits cupsd.conf through cupsd itself, +# which rewrites the file from its parsed form and drops every comment. That +# makes the template above differ on the next run, which re-templates and +# restarts cups, which lets cupsctl rewrite it again -- a deploy that reports +# changes forever and never converges. +# +# Nothing is lost. `cupsctl --share-printers` amounts to `Browsing On` plus a +# per-queue shared flag, and both are already set -- the first in the template, +# the second by lpadmin's printer-is-shared=true above. `--no-remote-any` is the +# absence of `Allow from all` in , which is how the template is +# written. The driver's own install.sh runs `cupsctl --remote-any`, which would +# offer this printer to anything that can route to the Pi; that is exactly what +# we are not doing. diff --git a/ansible/roles/labelprint/tasks/driver.yml b/ansible/roles/labelprint/tasks/driver.yml new file mode 100644 index 0000000..f5ce3eb --- /dev/null +++ b/ansible/roles/labelprint/tasks/driver.yml @@ -0,0 +1,147 @@ +--- +# Builds RunTheWall/tspl-cups-driver (MIT) from a pinned commit. The build is +# three files -- a CUPS raster filter, a backend and a PPD -- so it is cheap to +# do on the Pi itself and avoids trusting a prebuilt binary. +- name: fetch the tspl driver source + become: true + ansible.builtin.git: + repo: "{{ labelprint_driver_repo }}" + dest: "{{ labelprint_driver_src }}" + version: "{{ labelprint_driver_version }}" + force: true + register: labelprint_driver_checkout + tags: [labelprint, driver] + +- name: check whether the filter is already built + become: true + ansible.builtin.stat: + path: /usr/lib/cups/filter/rastertotspl + register: labelprint_filter + tags: [labelprint, driver] + +# `make` alone is not idempotent enough to report honestly -- it prints a +# recipe line on a rebuild and nothing on a no-op -- so the decision to build is +# made from the checkout state instead. +- name: build the tspl raster filter + become: true + community.general.make: + chdir: "{{ labelprint_driver_src }}" + when: labelprint_driver_checkout.changed or not labelprint_filter.stat.exists + tags: [labelprint, driver] + +- name: install the tspl raster filter + become: true + ansible.builtin.copy: + src: "{{ labelprint_driver_src }}/src/rastertotspl" + dest: /usr/lib/cups/filter/rastertotspl + remote_src: true + owner: root + group: root + mode: "0755" + notify: restart cups + tags: [labelprint, driver] + +# 0700 and root-owned on purpose: cupsd refuses to run a backend that is group- +# or world-writable, and runs it as an unprivileged user if it is not 0700. +# Writing to the printer's usblp node needs the privileged path. +- name: install the tspl backend + become: true + ansible.builtin.copy: + src: "{{ labelprint_driver_src }}/backend/tspl" + dest: /usr/lib/cups/backend/tspl + remote_src: true + owner: root + group: root + mode: "0700" + notify: restart cups + tags: [labelprint, driver] + +- name: create the PPD directory + become: true + ansible.builtin.file: + path: "{{ labelprint_ppd_dir }}" + state: directory + owner: root + group: root + mode: "0755" + tags: [labelprint, driver] + +- name: install the tspl PPD + become: true + ansible.builtin.copy: + src: "{{ labelprint_driver_src }}/ppd/tspl-label.ppd" + dest: "{{ labelprint_ppd_dir }}/tspl-label.ppd" + remote_src: true + owner: root + group: root + mode: "0644" + tags: [labelprint, driver] + +# --------------------------------------------------------------------------- +# The 4x6-only PPD +# --------------------------------------------------------------------------- +# Regenerated from the driver's PPD every run rather than kept as a fork, so a +# driver bump brings its new PPD through the same filter. +- name: create the helper directory + become: true + ansible.builtin.file: + path: /usr/local/share/labelprint + state: directory + owner: root + group: root + mode: "0755" + when: labelprint_media_only_4x6 + tags: [labelprint, driver] + +- name: install the PPD media trim filter + become: true + ansible.builtin.copy: + src: trim-ppd-media.awk + dest: /usr/local/share/labelprint/trim-ppd-media.awk + owner: root + group: root + mode: "0644" + when: labelprint_media_only_4x6 + tags: [labelprint, driver] + +- name: render the 4x6-only PPD + become: true + ansible.builtin.command: + argv: + - awk + - -v + - "keep={{ labelprint_ppd_pagesize }}" + - -f + - /usr/local/share/labelprint/trim-ppd-media.awk + - "{{ labelprint_ppd_dir }}/tspl-label.ppd" + register: labelprint_ppd_trim + changed_when: false + when: labelprint_media_only_4x6 + tags: [labelprint, driver] + +- name: install the 4x6-only PPD + become: true + ansible.builtin.copy: + content: "{{ labelprint_ppd_trim.stdout }}\n" + dest: "{{ labelprint_ppd_dir }}/tspl-label-4x6.ppd" + owner: root + group: root + mode: "0644" + validate: cupstestppd -q %s + when: labelprint_media_only_4x6 + tags: [labelprint, driver] + +# Gives the printer a stable /dev/usb/tspl-label symlink across USB +# re-enumeration. Harmless if the PM246's id is not in the shipped rules -- the +# backend still finds it by walking /dev/usb/lp*. +- name: install the tspl udev rules + become: true + ansible.builtin.copy: + src: "{{ labelprint_driver_src }}/udev/99-tspl-label.rules" + dest: /etc/udev/rules.d/99-tspl-label.rules + remote_src: true + owner: root + group: root + mode: "0644" + notify: reload udev rules + tags: [labelprint, driver] diff --git a/ansible/roles/labelprint/tasks/firewall.yml b/ansible/roles/labelprint/tasks/firewall.yml new file mode 100644 index 0000000..be686d0 --- /dev/null +++ b/ansible/roles/labelprint/tasks/firewall.yml @@ -0,0 +1,20 @@ +--- +- name: install the nftables ruleset + become: true + ansible.builtin.template: + src: nftables.conf.j2 + dest: /etc/nftables.conf + owner: root + group: root + mode: "0755" + validate: /usr/sbin/nft -c -f %s + notify: reload nftables + tags: [labelprint, firewall] + +- name: enable nftables + become: true + ansible.builtin.systemd: + name: nftables.service + enabled: true + state: started + tags: [labelprint, firewall] diff --git a/ansible/roles/labelprint/tasks/main.yml b/ansible/roles/labelprint/tasks/main.yml new file mode 100644 index 0000000..1da4347 --- /dev/null +++ b/ansible/roles/labelprint/tasks/main.yml @@ -0,0 +1,6 @@ +--- +- import_tasks: base.yml +- import_tasks: driver.yml +- import_tasks: cups.yml +- import_tasks: wifi.yml +- import_tasks: firewall.yml diff --git a/ansible/roles/labelprint/tasks/wifi.yml b/ansible/roles/labelprint/tasks/wifi.yml new file mode 100644 index 0000000..aaef914 --- /dev/null +++ b/ansible/roles/labelprint/tasks/wifi.yml @@ -0,0 +1,133 @@ +--- +# WPA2-PSK takes an 8-63 character passphrase, or exactly 64 hex characters as a +# precomputed key. NetworkManager stores a shorter one without complaint -- +# psk-flags stays 0 and the value sits in the keyfile -- and then refuses to +# activate with "Secrets were required, but not provided", which reads like a +# missing password rather than an invalid one. +# +# For the rescue AP that failure is invisible until the day the home network is +# down and this is the only way in, so it is checked here instead. Only lengths +# are reported, never the values. +- name: check the Wi-Fi secrets are usable as WPA2-PSK + ansible.builtin.assert: + that: + - (vars[item] | length >= 8 and vars[item] | length <= 63) + or (vars[item] is match('^[0-9a-fA-F]{64}$')) + fail_msg: >- + {{ item }} is {{ vars[item] | length }} characters, which WPA2 will not + accept. Use an 8-63 character passphrase, or a 64-character hex + precomputed key. Fix it with `make vault`. + quiet: true + # The loop carries the variable NAME, never its value: a failed assert prints + # the item it was iterating over, so looping over the secrets themselves would + # dump both passwords to the terminal on any failure. + loop: + - stickah_psk + - stickah_psk_rescue + tags: [labelprint, wifi] + +# The watchdog can pull the radio out from under this very play if it decides +# the Pi is offline while we are mid-deploy over the rescue AP. The hold expires +# on its own after {{ labelprint_hold_max_age_secs }}s, so an aborted run cannot +# leave the watchdog disabled. +- name: hold the radio for the duration of this deploy + become: true + ansible.builtin.file: + path: /run/wifi-rescue.hold + state: touch + owner: root + group: root + mode: "0644" + changed_when: false + tags: [labelprint, wifi] + +- name: install the home Wi-Fi profile + become: true + ansible.builtin.template: + src: home-wifi.nmconnection.j2 + dest: /etc/NetworkManager/system-connections/home-wifi.nmconnection + owner: root + group: root + mode: "0600" + notify: reload networkmanager connections + tags: [labelprint, wifi] + +- name: install the rescue access point profile + become: true + ansible.builtin.template: + src: rescue-ap.nmconnection.j2 + dest: /etc/NetworkManager/system-connections/rescue-ap.nmconnection + owner: root + group: root + mode: "0600" + notify: reload networkmanager connections + tags: [labelprint, wifi] + +# --------------------------------------------------------------------------- +# Coexisting with netplan +# --------------------------------------------------------------------------- +# The hand-built image configures Wi-Fi through cloud-init's network-config, and +# on Raspberry Pi OS trixie netplan's NetworkManager integration turns that into +# a persistent profile of its own at /etc/netplan/90-NM-.yaml -- not the +# /etc/netplan/50-cloud-init.yaml you would expect, and not something a +# cloud-init clean removes. +# +# That profile is deliberately left in place. It carries the same SSID as +# home-wifi, and autoconnect-priority decides between them: 100 here against +# netplan's 0, so NetworkManager picks ours every time. What netplan's copy buys +# is a fallback that predates anything in this role -- if home-wifi is ever +# rendered wrong, the Pi still comes back on the LAN instead of stranding itself +# on the rescue AP. Its PSK goes stale when the home password changes; that +# costs nothing, because a stale profile simply fails and ours is tried first. +# +# What is worth stopping is cloud-init rewriting the network on a future +# re-instance, which would put a third opinion in play. +- name: stop cloud-init from rewriting the network config + become: true + ansible.builtin.copy: + dest: /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg + content: | + network: {config: disabled} + owner: root + group: root + mode: "0644" + tags: [labelprint, wifi] + +# --------------------------------------------------------------------------- +# The watchdog +# --------------------------------------------------------------------------- +- name: install the Wi-Fi rescue watchdog + become: true + ansible.builtin.template: + src: wifi-rescue.sh.j2 + dest: /usr/local/sbin/wifi-rescue + owner: root + group: root + mode: "0755" + tags: [labelprint, wifi] + +- name: install the Wi-Fi rescue units + become: true + ansible.builtin.template: + src: "{{ item }}.j2" + dest: "/etc/systemd/system/{{ item }}" + owner: root + group: root + mode: "0644" + loop: + - wifi-rescue.service + - wifi-rescue.timer + notify: reload systemd + tags: [labelprint, wifi] + +- name: apply pending unit changes + ansible.builtin.meta: flush_handlers + tags: [labelprint, wifi] + +- name: enable the Wi-Fi rescue watchdog + become: true + ansible.builtin.systemd: + name: wifi-rescue.timer + enabled: true + state: started + tags: [labelprint, wifi] diff --git a/ansible/roles/labelprint/templates/bootfs/meta-data.j2 b/ansible/roles/labelprint/templates/bootfs/meta-data.j2 new file mode 100644 index 0000000..ad5faf9 --- /dev/null +++ b/ansible/roles/labelprint/templates/bootfs/meta-data.j2 @@ -0,0 +1,11 @@ +# {{ ansible_managed }} -- rendered by `make bootfs` +# +# cloud-init applies user-data once per INSTANCE, not once per boot: rewrite +# user-data on a card that has already booted and nothing happens, because +# cloud-init recognises the instance id and skips straight to per-boot modules. +# +# Deriving the id from a hash of user-data itself fixes that. Re-running +# `make bootfs` with no changes leaves the id alone, so a card keeps its +# identity; change anything in user-data and the id changes with it, and the +# Pi re-applies the new config on its next boot without a reflash. +instance-id: {{ labelprint_hostname }}-{{ labelprint_user_data_id }} diff --git a/ansible/roles/labelprint/templates/bootfs/network-config.j2 b/ansible/roles/labelprint/templates/bootfs/network-config.j2 new file mode 100644 index 0000000..41a8df9 --- /dev/null +++ b/ansible/roles/labelprint/templates/bootfs/network-config.j2 @@ -0,0 +1,18 @@ +# {{ ansible_managed }} -- rendered by `make bootfs` +# +# Ethernet only, on purpose. Wi-Fi is NOT configured here: cloud-init renders +# this through netplan into a persistent profile of netplan's own, which is not +# a file this repo manages or can readily update. The Wi-Fi profiles are written +# straight into /etc/NetworkManager/system-connections by user-data instead, so +# the image and Ansible manage the same files. +# +# A card that has already booted with Wi-Fi in network-config keeps netplan's +# profile. That is fine, and useful -- see the "Coexisting with netplan" note in +# roles/labelprint/tasks/wifi.yml. +network: + version: 2 + ethernets: + eth0: + dhcp4: true + dhcp6: true + optional: true diff --git a/ansible/roles/labelprint/templates/bootfs/user-data.j2 b/ansible/roles/labelprint/templates/bootfs/user-data.j2 new file mode 100644 index 0000000..5488dc8 --- /dev/null +++ b/ansible/roles/labelprint/templates/bootfs/user-data.j2 @@ -0,0 +1,125 @@ +#cloud-config +# {{ ansible_managed }} -- rendered by `make bootfs BOOTFS=...` +# +# First boot of the label print proxy. The job of this file is to make the Pi +# REACHABLE and nothing more: a login that works, a network that works, and a +# rescue access point for when it does not. The printer driver and the CUPS +# queue are Ansible's job -- see roles/labelprint/. +# +# The Wi-Fi profiles and the rescue watchdog below are rendered from the very +# same templates the role uses, so the first `make deploy-labelprint` reports no +# change on any of them. That is the point: the Pi can already rescue itself +# before Ansible has ever run. +# +# Applies on FIRST BOOT ONLY. Rewriting this file on a card that has already +# booted does nothing -- reflash the image. + +hostname: {{ labelprint_hostname }} +manage_etc_hosts: true +manage_resolv_conf: false +timezone: America/New_York +keyboard: + model: pc105 + layout: "us" + +apt: + preserve_sources_list: true + +users: + - name: {{ labelprint_user }} + shell: /bin/bash + lock_passwd: false + # Deliberately trivial, and deliberately not hashed: this password is + # documented in roles/labelprint/README.md, so a hash of it would protect + # nothing while pretending otherwise. It exists so that a Pi which will not + # take the SSH key -- wrong key, reflashed card, someone else's laptop -- is + # still reachable from the LAN or the rescue AP, which is the only place it + # can be reached from at all (see templates/nftables.conf.j2). + plain_text_passwd: {{ labelprint_user_password }} + sudo: "ALL=(ALL) NOPASSWD:ALL" + groups: [sudo, adm, lpadmin, plugdev, dialout, netdev, users] + ssh_authorized_keys: + - {{ lookup('file', labelprint_authorized_key_file) }} + +# The key is what Ansible actually uses; the password is the fallback. +ssh_pwauth: true +disable_root: true +chpasswd: + expire: false + +# Pre-installing what the role needs makes the first deploy quick, and means a +# Pi that comes up on the rescue AP with no internet still has cups and +# NetworkManager. Ansible installs the same list, so nothing here is load-bearing. +package_update: true +packages: +{% for pkg in labelprint_deps %} + - {{ pkg }} +{% endfor %} + +write_files: + # Stop cloud-init rewriting the network on a future re-instance. + - path: /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg + owner: root:root + permissions: '0644' + content: | + network: {config: disabled} + + # Belt and braces. netplan can hand NetworkManager an "only manage what I + # listed" policy, and what this image lists is eth0; a stock Raspberry Pi OS + # leaves that policy empty, but an unmanaged wlan0 would take the rescue AP + # down with it, so it is not worth depending on. + - path: /etc/NetworkManager/conf.d/10-labelprint.conf + owner: root:root + permissions: '0644' + content: | + [keyfile] + unmanaged-devices=none + + [device] + # A randomised MAC would give the Pi a different DHCP lease on every + # association, which makes it hard to find on the UDM Pro's client list. + wifi.scan-rand-mac-address=no + + - path: /etc/NetworkManager/system-connections/home-wifi.nmconnection + owner: root:root + permissions: '0600' + content: | + {{ lookup('template', 'roles/labelprint/templates/home-wifi.nmconnection.j2') | indent(6) }} + + - path: /etc/NetworkManager/system-connections/rescue-ap.nmconnection + owner: root:root + permissions: '0600' + content: | + {{ lookup('template', 'roles/labelprint/templates/rescue-ap.nmconnection.j2') | indent(6) }} + + - path: /usr/local/sbin/wifi-rescue + owner: root:root + permissions: '0755' + content: | + {{ lookup('template', 'roles/labelprint/templates/wifi-rescue.sh.j2') | indent(6) }} + + - path: /etc/systemd/system/wifi-rescue.service + owner: root:root + permissions: '0644' + content: | + {{ lookup('template', 'roles/labelprint/templates/wifi-rescue.service.j2') | indent(6) }} + + - path: /etc/systemd/system/wifi-rescue.timer + owner: root:root + permissions: '0644' + content: | + {{ lookup('template', 'roles/labelprint/templates/wifi-rescue.timer.j2') | indent(6) }} + +runcmd: + # RPi OS soft-blocks the radio until a regulatory domain is known. cmdline.txt + # carries cfg80211.ieee80211_regdom=US, but unblock anyway -- a blocked radio + # is the one failure that takes the rescue AP down with it. + - [rfkill, unblock, wifi] + - [systemctl, enable, --now, ssh] + - [systemctl, enable, --now, avahi-daemon] + # Let NetworkManager pick up the keyfiles written above. The profile netplan + # renders from network-config is left alone on purpose -- see the "Coexisting + # with netplan" note in roles/labelprint/tasks/wifi.yml. + - [nmcli, connection, reload] + - [systemctl, daemon-reload] + - [systemctl, enable, --now, wifi-rescue.timer] diff --git a/ansible/roles/labelprint/templates/cupsd.conf.j2 b/ansible/roles/labelprint/templates/cupsd.conf.j2 new file mode 100644 index 0000000..012e065 --- /dev/null +++ b/ansible/roles/labelprint/templates/cupsd.conf.j2 @@ -0,0 +1,97 @@ +# {{ ansible_managed }} +# +# CUPS on the label print proxy. The Pi does all the rendering, so macOS, +# Windows and Linux clients add the queue driverless over IPP Everywhere / +# AirPrint and never install a Phomemo driver. +# +# Reachable from the LAN and from the rescue access point only. Everything else +# is refused here and dropped again at nftables. + +LogLevel warn +PageLogFormat +MaxLogSize 1m +# A label job is worth retrying: the printer is often powered off or out of +# stock when the job is submitted, and the default is to bin the job outright. +ErrorPolicy retry-job + +# Only trusted, local networks reach this port -- see the Location blocks below +# and roles/labelprint/templates/nftables.conf.j2. Listening on all interfaces +# rather than a fixed address so the queue is still reachable at +# {{ labelprint_ap_addr }} when the Pi has fallen back to its rescue AP. +Listen 631 +Listen /run/cups/cups.sock + +# Advertise over Bonjour/mDNS so clients discover the queue by themselves. +Browsing On +BrowseLocalProtocols dnssd +# Dropping the _cups subtype is what makes macOS and iOS offer a driverless +# "AirPrint" add instead of guessing at a Generic PostScript driver. +BrowseDNSSDSubTypes _print,_universal + +DefaultAuthType Basic +WebInterface Yes + + + Order allow,deny + Allow from {{ labelprint_lan_cidr }} + Allow from {{ labelprint_ap_cidr }} + Allow from localhost + + + + AuthType Default + Require user @SYSTEM + Order allow,deny + Allow from {{ labelprint_lan_cidr }} + Allow from {{ labelprint_ap_cidr }} + + + + AuthType Default + Require user @SYSTEM + Order allow,deny + Allow from {{ labelprint_lan_cidr }} + Allow from {{ labelprint_ap_cidr }} + + + + AuthType Default + Require user @SYSTEM + Order allow,deny + Allow from {{ labelprint_lan_cidr }} + Allow from {{ labelprint_ap_cidr }} + + + + JobPrivateAccess default + JobPrivateValues default + SubscriptionPrivateAccess default + SubscriptionPrivateValues default + + # Anyone on the LAN may print and manage their own jobs -- this is a label + # printer in a workshop, not a shared office device with quotas. + + Order deny,allow + + + + Order deny,allow + + + # Changing the printer itself needs a local admin. + + AuthType Default + Require user @SYSTEM + Order deny,allow + + + + AuthType Default + Require user @SYSTEM + Order deny,allow + + + + Order deny,allow + + diff --git a/ansible/roles/labelprint/templates/home-wifi.nmconnection.j2 b/ansible/roles/labelprint/templates/home-wifi.nmconnection.j2 new file mode 100644 index 0000000..f1c0228 --- /dev/null +++ b/ansible/roles/labelprint/templates/home-wifi.nmconnection.j2 @@ -0,0 +1,32 @@ +# {{ ansible_managed }} +# +# The normal, everyday Wi-Fi link. autoconnect-priority outranks anything +# cloud-init/netplan rendered for the same SSID, so this is the profile +# NetworkManager picks. autoconnect-retries=0 means retry forever rather than +# giving up after four attempts and leaving the Pi off the network until +# someone power-cycles it -- the rescue AP is the fallback, not the +# destination. +[connection] +id=home-wifi +uuid={{ (stickah_ssid ~ '-home-wifi') | to_uuid }} +type=wifi +interface-name=wlan0 +autoconnect=true +autoconnect-priority=100 +autoconnect-retries=0 + +[wifi] +mode=infrastructure +ssid={{ stickah_ssid }} + +[wifi-security] +key-mgmt=wpa-psk +# Accepts either a passphrase or the 64-hex precomputed PSK. +psk={{ stickah_psk }} + +[ipv4] +method=auto + +[ipv6] +method=auto +addr-gen-mode=default diff --git a/ansible/roles/labelprint/templates/nftables.conf.j2 b/ansible/roles/labelprint/templates/nftables.conf.j2 new file mode 100644 index 0000000..6521ad9 --- /dev/null +++ b/ansible/roles/labelprint/templates/nftables.conf.j2 @@ -0,0 +1,63 @@ +#!/usr/sbin/nft -f +# {{ ansible_managed }} +# +# The print proxy answers to the LAN and to its own rescue access point, and to +# nothing else. This is the outer half of the same rule that cupsd enforces in +# its Location blocks -- both are here on purpose, so a mistake in one is not +# the only thing standing between the printer and the rest of the world. + +# Declare-then-delete rather than `flush ruleset`: NetworkManager's shared mode +# keeps its own table for the rescue AP's dnsmasq, and a global flush would take +# that with it every time this file is reloaded. +table inet labelprint +delete table inet labelprint + +table inet labelprint { + set trusted { + type ipv4_addr + flags interval + elements = { {{ labelprint_lan_cidr }}, {{ labelprint_ap_cidr }} } + } + + chain input { + type filter hook input priority filter; policy drop; + + ct state established,related accept + ct state invalid drop + iif lo accept + + icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } accept + icmpv6 type { echo-request, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept + + # DHCP replies to our own client. Broadcast, so conntrack does not see + # them as related to the request we sent. + udp dport 68 accept + + # ssh and IPP, from the LAN or from a machine on the rescue AP. + ip saddr @trusted tcp dport { 22, 631 } accept + ip saddr @trusted udp dport 631 accept + + # mDNS: how every client finds this printer, since it has no DNS record. + ip saddr @trusted udp dport 5353 accept + + # DHCP for whoever joins the rescue AP. Deliberately not restricted by + # source address: a client asking for its first lease has no address + # yet and sends DHCPDISCOVER from 0.0.0.0, so a source-matched rule + # would mean the rescue network never hands out a lease at all. Only a + # machine already associated to our own AP can reach this port. + iifname "wlan0" udp dport 67 accept + + # DNS, once they have an address. + iifname "wlan0" ip saddr {{ labelprint_ap_cidr }} udp dport 53 accept + iifname "wlan0" ip saddr {{ labelprint_ap_cidr }} tcp dport 53 accept + } + + # The rescue AP is a way in to this Pi, not a route to anywhere else. + chain forward { + type filter hook forward priority filter; policy drop; + } + + chain output { + type filter hook output priority filter; policy accept; + } +} diff --git a/ansible/roles/labelprint/templates/rescue-ap.nmconnection.j2 b/ansible/roles/labelprint/templates/rescue-ap.nmconnection.j2 new file mode 100644 index 0000000..6b802ff --- /dev/null +++ b/ansible/roles/labelprint/templates/rescue-ap.nmconnection.j2 @@ -0,0 +1,38 @@ +# {{ ansible_managed }} +# +# Rescue access point. Never autoconnects -- wifi-rescue brings it up only when +# the home SSID is unreachable, so that a changed password or a dead router +# leaves a way back in to reconfigure the Pi. +# +# Join this SSID and the Pi is at {{ labelprint_ap_addr }}: ssh pi@{{ labelprint_ap_addr }}, +# or print to it directly at ipp://{{ labelprint_ap_addr }}:631/printers/{{ labelprint_queue }}. +[connection] +id=rescue-ap +uuid={{ (stickah_ssid_rescue ~ '-rescue-ap') | to_uuid }} +type=wifi +interface-name=wlan0 +autoconnect=false + +[wifi] +mode=ap +ssid={{ stickah_ssid_rescue }} +# The 3B+ radio does 5 GHz, but 2.4 GHz AP mode is what brcmfmac is reliable +# at, and a rescue network only has to carry an SSH session. +band=bg +channel=6 + +[wifi-security] +key-mgmt=wpa-psk +proto=rsn +pairwise=ccmp +group=ccmp +psk={{ stickah_psk_rescue }} + +# "shared" makes NetworkManager run a dnsmasq for DHCP and DNS on this +# interface, so a laptop that joins gets an address without any further setup. +[ipv4] +method=shared +address1={{ labelprint_ap_addr }}/24 + +[ipv6] +method=ignore diff --git a/ansible/roles/labelprint/templates/wifi-rescue.service.j2 b/ansible/roles/labelprint/templates/wifi-rescue.service.j2 new file mode 100644 index 0000000..19eb89a --- /dev/null +++ b/ansible/roles/labelprint/templates/wifi-rescue.service.j2 @@ -0,0 +1,9 @@ +# {{ ansible_managed }} +[Unit] +Description=Wi-Fi rescue access point watchdog +After=NetworkManager.service +Requires=NetworkManager.service + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/wifi-rescue diff --git a/ansible/roles/labelprint/templates/wifi-rescue.sh.j2 b/ansible/roles/labelprint/templates/wifi-rescue.sh.j2 new file mode 100644 index 0000000..c500c27 --- /dev/null +++ b/ansible/roles/labelprint/templates/wifi-rescue.sh.j2 @@ -0,0 +1,132 @@ +#!/bin/sh +# {{ ansible_managed }} +# +# Keeps the label print proxy reachable. +# +# Normally the Pi is a station on the home SSID. If that network is gone -- the +# password changed, the AP died, the Pi was carried somewhere else -- it brings +# up its own rescue access point so there is still a way in to reconfigure it. +# It keeps checking, and hands the radio back the moment the home SSID returns. +# +# The 3B+ has one radio and brcmfmac will not hold an AP and a station link at +# the same time, so this cannot listen for the home SSID while the AP is up. +# Instead the AP drops for a few seconds every {{ labelprint_ap_rescan_secs }}s +# to scan, and comes straight back if the home network is still missing. +# +# If you are working over the rescue AP and do not want the radio pulled out +# from under your SSH session: +# +# touch /run/wifi-rescue.hold +# +# The hold expires by itself after {{ (labelprint_hold_max_age_secs / 60) | int }} minutes, so a forgotten hold file +# cannot strand the Pi permanently. +set -eu + +HOME_CON=home-wifi +AP_CON=rescue-ap +SSID='{{ stickah_ssid }}' +AP_SSID='{{ stickah_ssid_rescue }}' +IFACE=wlan0 +HOLD=/run/wifi-rescue.hold +STAMP=/run/wifi-rescue.ap-since +RESCAN_SECS={{ labelprint_ap_rescan_secs }} +HOLD_MAX_AGE={{ labelprint_hold_max_age_secs }} + +log() { logger -t wifi-rescue -- "$@"; } + +con_active() { nmcli -t -f NAME connection show --active | grep -qxF "$1"; } + +# A default IPv4 route is the honest test for "on a real network". The rescue +# AP uses NetworkManager's shared mode, which hands out addresses but installs +# no default route, so the AP can never make this look true. +online() { [ -n "$(ip -4 route show default 2>/dev/null)" ]; } + +held() { + [ -e "$HOLD" ] || return 1 + age=$(( $(date +%s) - $(stat -c %Y "$HOLD" 2>/dev/null || echo 0) )) + if [ "$age" -lt "$HOLD_MAX_AGE" ]; then + return 0 + fi + log "hold file is ${age}s old; expiring it" + rm -f "$HOLD" + return 1 +} + +start_ap() { + con_active "$AP_CON" && return 0 + log "starting rescue AP '$AP_SSID' on {{ labelprint_ap_addr }}" + if nmcli --wait 20 connection up "$AP_CON" >/dev/null 2>&1; then + date +%s > "$STAMP" + else + log "ERROR: rescue AP failed to start" + fi +} + +join_home() { + # Bounded: the default 90s wait outlives the watchdog interval, and an + # SSID that is not there is not going to appear in the next minute. + # + # --wait is a GLOBAL nmcli option and has to precede the subcommand. Written + # as `connection up --wait 20` it is rejected outright with "invalid + # extra argument" -- and only for a connection that exists, so it looks fine + # against a typo'd name. That failure mode is silent and total: every join + # returns failure and the rescue AP never starts. + nmcli --wait 20 connection up "$HOME_CON" >/dev/null 2>&1 || return 1 + online +} + +held && exit 0 + +if online; then + if con_active "$AP_CON"; then + log "back on the network; shutting the rescue AP down" + nmcli connection down "$AP_CON" >/dev/null 2>&1 || true + rm -f "$STAMP" + fi + exit 0 +fi + +if con_active "$AP_CON"; then + since=$(cat "$STAMP" 2>/dev/null || echo 0) + [ $(( $(date +%s) - since )) -lt "$RESCAN_SECS" ] && exit 0 + + log "rescue AP up for ${RESCAN_SECS}s; dropping it to scan for '$SSID'" + nmcli connection down "$AP_CON" >/dev/null 2>&1 || true + sleep 2 + nmcli device wifi rescan ifname "$IFACE" >/dev/null 2>&1 || true + sleep 5 + + scan=$(nmcli -t -f SSID device wifi list ifname "$IFACE" 2>/dev/null || true) + if printf '%s\n' "$scan" | grep -qxF "$SSID"; then + log "'$SSID' is back; rejoining" + try=yes + elif [ -z "$(printf '%s' "$scan" | tr -d '[:space:]')" ]; then + # Nothing at all came back. Either the radio has not settled after + # dropping the AP, or the home SSID is hidden and will never show up in + # a scan. Worth 20 seconds to find out. + log "scan came back empty; trying '$SSID' anyway" + try=yes + else + # Other networks are visible and ours is not, so it really is gone. + # Straight back to the AP -- no point spending the join timeout. + try=no + fi + + if [ "$try" = yes ]; then + if join_home; then + log "rejoined '$SSID'" + rm -f "$STAMP" + exit 0 + fi + log "join failed; returning to the rescue AP" + fi + start_ap + exit 0 +fi + +log "offline and no rescue AP; trying '$SSID'" +if join_home; then + log "joined '$SSID'" + exit 0 +fi +start_ap diff --git a/ansible/roles/labelprint/templates/wifi-rescue.timer.j2 b/ansible/roles/labelprint/templates/wifi-rescue.timer.j2 new file mode 100644 index 0000000..bb72c28 --- /dev/null +++ b/ansible/roles/labelprint/templates/wifi-rescue.timer.j2 @@ -0,0 +1,13 @@ +# {{ ansible_managed }} +[Unit] +Description=Run the Wi-Fi rescue watchdog every {{ labelprint_watchdog_interval_secs }}s + +[Timer] +# Waits for NetworkManager to have had a fair go at the home SSID before the +# first check, so a slow DHCP lease at boot does not trip the rescue AP. +OnBootSec=90 +OnUnitActiveSec={{ labelprint_watchdog_interval_secs }} +AccuracySec=5 + +[Install] +WantedBy=timers.target diff --git a/ansible/vars/vault.yml b/ansible/vars/vault.yml index e3d5a15fb9a3bd18efd77a84e2a10faeb824070f..340d8be2508dc53b18cecf889304a2e7555949d1 100644 GIT binary patch literal 34980 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 E>>7+UKL7v# literal 34461 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ℑ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 F-P