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:
co-authored by
Claude Opus 5
parent
be0d02b938
commit
6cd4d56de1
@@ -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]
|
||||
@@ -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.
|
||||
@@ -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]
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
- import_tasks: base.yml
|
||||
- import_tasks: driver.yml
|
||||
- import_tasks: cups.yml
|
||||
- import_tasks: wifi.yml
|
||||
- import_tasks: firewall.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-<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]
|
||||
Reference in New Issue
Block a user