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 e3d5a15..340d8be 100644 Binary files a/ansible/vars/vault.yml and b/ansible/vars/vault.yml differ