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 <noreply@anthropic.com>
This commit is contained in:
Bastian de Byl
2026-09-13 23:14:45 -04:00
co-authored by Claude Opus 5
parent be0d02b938
commit 6cd4d56de1
26 changed files with 1593 additions and 1 deletions
+20 -1
View File
@@ -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 TAGS=sometag` - Deploy only specific tagged tasks
- `make deploy TARGET=specific-host` - Deploy to specific host instead of all - `make deploy TARGET=specific-host` - Deploy to specific host instead of all
- `make check` - Run deployment in dry-run mode showing potential changes - `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 vault` - Edit encrypted Ansible vault file
- `make list-tags` - List all available Ansible tags - `make list-tags` - List all available Ansible tags
- `make list-tasks` - List all Ansible tasks - `make list-tasks` - List all Ansible tasks
@@ -33,13 +35,16 @@ The project uses Python virtualenv for dependency management:
ansible/ ansible/
├── deploy.yml # Main playbook entry point (imports deploy_home.yml) ├── deploy.yml # Main playbook entry point (imports deploy_home.yml)
├── deploy_home.yml # Core playbook with role definitions ├── deploy_home.yml # Core playbook with role definitions
├── deploy_labelprint.yml # Label print proxy Pi (separate host, Debian)
├── inventories/home/ # Inventory configuration ├── inventories/home/ # Inventory configuration
├── inventories/labelprint/ # Label print proxy inventory
├── roles/ # Ansible roles organized by function ├── roles/ # Ansible roles organized by function
│ ├── common/ # Base system configuration │ ├── common/ # Base system configuration
│ ├── git/ # Git repository management │ ├── git/ # Git repository management
│ ├── podman/ # Container orchestration │ ├── podman/ # Container orchestration
│ ├── ssl/ # Legacy SSL management (deprecated - Caddy handles certificates automatically) │ ├── ssl/ # Legacy SSL management (deprecated - Caddy handles certificates automatically)
│ ├── github-actions/# CI/CD runner setup │ ├── github-actions/# CI/CD runner setup
│ ├── labelprint/ # 4x6 label print proxy (Raspberry Pi, CUPS/TSPL)
│ └── pihole/ # DNS filtering │ └── pihole/ # DNS filtering
└── vars/ └── vars/
└── vault.yml # Encrypted secrets └── vault.yml # Encrypted secrets
@@ -96,13 +101,27 @@ Tasks are tagged by service/component for selective deployment:
## Target Environment ## Target Environment
- Single target host: `home.debyl.io` - Primary target host: `home.debyl.io`
- OS: Fedora (ansible_user: fedora) - OS: Fedora (ansible_user: fedora)
- Container runtime: Podman - Container runtime: Podman
- Web server: Caddy with automatic HTTPS and built-in security (replaced nginx + ModSecurity) - Web server: Caddy with automatic HTTPS and built-in security (replaced nginx + ModSecurity)
- All services accessible via HTTPS with automatic certificate renewal - All services accessible via HTTPS with automatic certificate renewal
- ~~CI/CD: Drone CI infrastructure completely decommissioned~~ - ~~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 ### 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: The `podman` user (and other service users) have `/bin/nologin` as their shell. To run commands as these users via SSH:
+19
View File
@@ -21,6 +21,9 @@ VAULT_FILE=ansible/vars/vault.yml
# Variables # Variables
ANSIBLE_INVENTORY=ansible/inventories/home/hosts.yml 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 #SSH_KEY=${HOME}/.ssh/id_rsa_home_ansible
# Default to all ansible tags to run (passed via 'make deploy TAGS=sometag') # Default to all ansible tags to run (passed via 'make deploy TAGS=sometag')
@@ -29,6 +32,9 @@ SKIP_TAGS?=none
TARGET?=all TARGET?=all
EXTRA_VARS?= EXTRA_VARS?=
# Mounted boot partition of the label print proxy's SD card (see `make bootfs`)
BOOTFS?=/Volumes/bootfs
${VENV}: ${VENV}:
python3 -m venv ${VENV} python3 -m venv ${VENV}
${VENV_BIN}/python3 -m pip install --upgrade pip ${VENV_BIN}/python3 -m pip install --upgrade pip
@@ -55,6 +61,19 @@ SKIP_FILE=./.lint-vars.sh
deploy: ${ANSIBLE} ${VAULT_FILE} 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 ${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} list-tags: ${ANSIBLE} ${VAULT_FILE}
${ANSIBLE} --list-tags -i ${ANSIBLE_INVENTORY} -l ${TARGET} --vault-password-file ${VAULT_PASS_FILE} ansible/deploy.yml ${ANSIBLE} --list-tags -i ${ANSIBLE_INVENTORY} -l ${TARGET} --vault-password-file ${VAULT_PASS_FILE} ansible/deploy.yml
+106
View File
@@ -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=<id>` 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
+14
View File
@@ -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
+16
View File
@@ -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
+162
View File
@@ -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=<id>` 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=<new-id>/' /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) |
+118
View File
@@ -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://<vid>-<pid> (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
@@ -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 }
@@ -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
+92
View File
@@ -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]
+136
View File
@@ -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 <Location />, 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.
+147
View File
@@ -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]
@@ -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]
+6
View File
@@ -0,0 +1,6 @@
---
- import_tasks: base.yml
- import_tasks: driver.yml
- import_tasks: cups.yml
- import_tasks: wifi.yml
- import_tasks: firewall.yml
+133
View File
@@ -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-<uuid>.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]
@@ -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 }}
@@ -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
@@ -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]
@@ -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
<Location />
Order allow,deny
Allow from {{ labelprint_lan_cidr }}
Allow from {{ labelprint_ap_cidr }}
Allow from localhost
</Location>
<Location /admin>
AuthType Default
Require user @SYSTEM
Order allow,deny
Allow from {{ labelprint_lan_cidr }}
Allow from {{ labelprint_ap_cidr }}
</Location>
<Location /admin/conf>
AuthType Default
Require user @SYSTEM
Order allow,deny
Allow from {{ labelprint_lan_cidr }}
Allow from {{ labelprint_ap_cidr }}
</Location>
<Location /admin/log>
AuthType Default
Require user @SYSTEM
Order allow,deny
Allow from {{ labelprint_lan_cidr }}
Allow from {{ labelprint_ap_cidr }}
</Location>
<Policy default>
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.
<Limit Create-Job Print-Job Print-URI Validate-Job>
Order deny,allow
</Limit>
<Limit Send-Document Send-URI Hold-Job Release-Job Restart-Job Purge-Jobs Set-Job-Attributes Create-Job-Subscription Renew-Subscription Cancel-Subscription Get-Notifications Reprocess-Job Cancel-Current-Job Suspend-Current-Job Resume-Job Cancel-Jobs CUPS-Authenticate-Job Close-Job CUPS-Move-Job Cancel-My-Jobs CUPS-Get-Document>
Order deny,allow
</Limit>
# Changing the printer itself needs a local admin.
<Limit Pause-Printer Resume-Printer Enable-Printer Disable-Printer Pause-Printer-After-Current-Job Hold-New-Jobs Release-Held-New-Jobs Deactivate-Printer Activate-Printer Restart-Printer Shutdown-Printer Startup-Printer Promote-Job Schedule-Job-After Cancel-Job CUPS-Accept-Jobs CUPS-Reject-Jobs>
AuthType Default
Require user @SYSTEM
Order deny,allow
</Limit>
<Limit CUPS-Add-Modify-Printer CUPS-Delete-Printer CUPS-Add-Modify-Class CUPS-Delete-Class CUPS-Set-Default>
AuthType Default
Require user @SYSTEM
Order deny,allow
</Limit>
<Limit All>
Order deny,allow
</Limit>
</Policy>
@@ -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
@@ -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;
}
}
@@ -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
@@ -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
@@ -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 <id> --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
@@ -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
Binary file not shown.