Cross-compile CUPS + cups-filters + Poppler/Ghostscript for a GL.iNet Flint 3 router,
turning a USB-connected printer into an AirPrint-discoverable network printer for iOS and macOS.
Ships a reference foo2zjs driver validated end-to-end against an HP LaserJet 1022 — swap it
for any other CUPS driver (hplip, splix, gutenprint, …) to target a different printer.
Pipeline • Quick Start • Build • Install • Configure • WAN Notify • Troubleshooting
OpenWrt's official feeds don't ship CUPS, cups-filters, Ghostscript, Poppler, or foo2zjs for the
aarch64_cortex-a53 target used by the Flint 3. This project provides a reproducible Docker-based
cross-compile pipeline that produces installable .ipk packages plus a foo2zjs tarball, then installs
and configures them on the router for AirPrint.
The target router is a GL.iNet GL-BE9300 (Flint 3) running GL.iNet firmware v4.8.4 (OpenWrt 23.05-SNAPSHOT, QSDK v12.5) on a Qualcomm IPQ5332 (4× Cortex-A53, 1 GB RAM).
Everything in the print pipeline except the final driver is printer-agnostic — CUPS, cups-filters
(including the project's pdftops shim), Ghostscript, Poppler, Avahi, and the AirPrint advertisement
scripts work unchanged for any USB printer that has a CUPS PPD. The reference build has been validated
end-to-end on an HP LaserJet 1022 (ZjStream protocol) using the foo2zjs driver, but foo2zjs is
the only printer-family-specific component. To target a different printer:
- HP PCL/PostScript: install
hplipfrom the upstream printing feed and use its PPD. - Samsung / Xerox Phaser (QPDL): use splix instead of foo2zjs.
- Canon / Epson / Brother inkjet: use gutenprint (the feed ships it).
- Any PostScript-native printer: no driver needed — ship its PPD directly.
The only files you need to change are scripts/build-foo2zjs.sh (swap the driver source) and
scripts/install-foo2zjs.sh (swap the tarball payload); configure-cups.sh and
configure-airprint.sh are printer-agnostic apart from the Avahi TXT-record strings, which are
sourced from environment variables (PRINTER_NAME, PRINTER_MODEL, etc.).
iOS / macOS device
→ AirPrint (IPP + mDNS discovery via Avahi)
→ CUPS (print server, port 631)
→ pdftops shim (PDF → PostScript via Ghostscript) ──┐ printer-agnostic
→ cups-filters (PostScript / raster conversion) │
→ foomatic-rip (PPD-driven filter chain) ──┘
→ printer driver (foo2zjs / hplip / splix / …) ← printer-specific
→ USB backend (/dev/usb/lp0 or usb:// URI)
→ physical printer (e.g. HP LaserJet 1022)Poppler is the primary PDF backend ("Plan A"). Ghostscript is attempted but treated as optional —
its aarch64 cross-compile is historically fragile, and cups-filters works with Poppler alone.
Because cups-filters 1.0.37 does not build its own pdftops against Poppler 23.x on this target,
configure-cups.sh installs a small Ghostscript-based pdftops shim — see
Troubleshooting for the full diagnosis.
- Docker — works with Docker Desktop, Colima, or any Docker daemon.
- An x86_64 Linux host is strongly recommended. The OpenWrt SDK ships only x86_64 binaries;
on Apple Silicon they run under Rosetta, and shell-heavy host-tool builds (especially
gettext/gnulib-tool) become the bottleneck — expect hours instead of minutes. - SSH key access to the router —
ssh-copy-id -i ~/.ssh/id_ed25519.pub root@192.168.8.1.
| Detail | Value |
|---|---|
| Model | GL.iNet GL-BE9300 (Flint 3) |
| SoC | Qualcomm IPQ5332 |
| Architecture | aarch64_cortex-a53_neon-vfpv4 (runtime) |
| CPU / RAM | 4× Cortex-A53 / 1 GB |
| Firmware | GL.iNet v4.10.1 (OpenWrt 23.05-SNAPSHOT, QSDK 12.5) |
| Toolchain match | GCC 12.3.0, musl 1.2.4 |
The build runs inside a long-lived Docker container (openwrt-build, ubuntu:22.04, --platform linux/amd64).
All SDK state lives in a named Docker volume (openwrt-sdk-vol) rather than a bind mount — this avoids
macOS virtiofs permission issues with the SDK's symlinks and restricted files. The project directory
is bind-mounted at /host for the scripts directory and for writing output artifacts.
Host (Mac or Linux)
scripts/*.sh ─┐
output/*.ipk ◄│── /host ┐
│ │
│ │ openwrt-build (ubuntu:22.04 linux/amd64)
│ │
openwrt-sdk-vol ──┴── /workspace ┴─► OpenWrt SDK 23.05.6 ipq807x
+ feeds (base, packages, printing, luci)
+ build_dir/, staging_dir/, bin/openwrt-printing/
├── scripts/
│ ├── bootstrap.sh # One-shot wrapper: build + install + configure
│ ├── setup-container.sh # Create openwrt-build container + named volume
│ ├── fetch-sdk.sh # Download + extract OpenWrt 23.05.6 ipq807x SDK
│ ├── patch-sdk.sh # Overlay full gnulib tree onto SDK snapshot (gettext fix)
│ ├── prepare-feeds.sh # Wire Vladdrako printing feed, update + install
│ ├── configure-sdk.sh # Write SDK .config for cups/filters/poppler/gs
│ ├── build-stack.sh # Cross-compile poppler + cups + gs + cups-filters
│ ├── build-foomatic-rip.sh # Cross-compile foomatic-rip (missing from cups-filters 1.0.37)
│ ├── build-foo2zjs.sh # Cross-compile foo2zjs binary + wrapper + PPD
│ ├── install-on-router.sh # scp *.ipk → opkg install on router
│ ├── install-foomatic-rip.sh # scp tarball → extract on router
│ ├── install-foo2zjs.sh # scp tarball → extract on router
│ ├── configure-cups.sh # Write cupsd.conf, pdftops shim, open firewall
│ ├── configure-airprint.sh # Write Avahi service file for _ipp._tcp
│ └── install-wan-notify.sh # Install the WAN-switch Bark notifier
├── files/ # Router-side files, laid out as on the router
│ ├── usr/sbin/wan-notify # Detect active-WAN changes, push via Bark
│ ├── etc/hotplug.d/kmwan/99-wan-notify # kmwan hook → wan-notify check
│ └── etc/hotplug.d/iface/99-wan-notify-vpn # VPN iface hook → wan-notify vpn-check
├── output/ # Produced .ipk + foo2zjs tarball (gitignored)
├── build/ # Cached SDK tarball (gitignored)
├── CLAUDE.md # Agent context: plan, URLs, troubleshooting
├── LICENSE # MIT
└── README.md # This fileAll scripts are idempotent and resume-friendly. Re-running after a failure is safe.
scripts/bootstrap.sh wraps the full pipeline — build, install, configure, lpadmin, AirPrint
publish — behind a single CLI. Every printer-facing input is a flag; defaults target the reference
HP LaserJet 1022. Run with --help for the full flag list.
# Reference HP LaserJet 1022 setup (defaults for all flags):
./scripts/bootstrap.sh
# Different printer — override the identity + driver wiring:
./scripts/bootstrap.sh \
--router root@192.168.8.1 \
--printer-name Brother_HLL2350DW \
--printer-model "Brother HL-L2350DW" \
--printer-duplex T \
--device-uri usb://Brother/HL-L2350DW \
--ppd /usr/share/cups/model/Brother-HL-L2350DW.ppd
# Re-run only the router-side steps (the build output is still in output/):
./scripts/bootstrap.sh --skip-build
# Re-push only the Avahi / CUPS config (packages already installed):
./scripts/bootstrap.sh --skip-build --skip-installIf you prefer to drive each stage yourself — for debugging or when re-using pieces of the pipeline in another project — the stage scripts are designed to be called directly, in this order:
# First-time environment bootstrap (~5 min on Linux amd64, ~15 min under Rosetta)
./scripts/setup-container.sh
./scripts/fetch-sdk.sh
./scripts/patch-sdk.sh # gnulib overlay — see Troubleshooting
./scripts/prepare-feeds.sh
./scripts/configure-sdk.sh
# Cross-compile the stack (single `make -jN` invocation with all four targets —
# poppler, cups, ghostscript, cups-filters — so sibling packages run in parallel
# under one jobserver).
./scripts/build-stack.sh
# Cross-compile foomatic-rip (cups-filters 1.0.37 doesn't ship it).
./scripts/build-foomatic-rip.sh
# Cross-compile foo2zjs (direct toolchain invocation — not wrapped as .ipk;
# produces a tarball that unpacks straight into /usr/lib/cups/filter/ and
# /usr/share/cups/model/ on the router).
./scripts/build-foo2zjs.shThe build logs to /workspace/build.log inside the container. Stream it from a second terminal:
docker exec openwrt-build tail -F /workspace/build.logoutput/
├── cups_*.ipk
├── cups-client_*.ipk
├── libcups_*.ipk
├── openprinting-cups-filters_*.ipk
├── libpoppler_*.ipk
├── ghostscript_*.ipk # if the GS build succeeded
├── liblcms2_*.ipk, libpng_*.ipk, libtiff_*.ipk, ...
└── foo2zjs-hp-lj1022.tar.gzThe router's DISTRIB_ARCH is aarch64_cortex-a53_neon-vfpv4 (note the _neon-vfpv4 suffix), but the
upstream OpenWrt SDK emits packages tagged aarch64_cortex-a53. install-on-router.sh handles this
by adding an extra arch aarch64_cortex-a53 200 line to /etc/opkg.conf so opkg accepts both arch
tags. ABI-wise this is safe — NEON + VFPv4 are mandatory parts of ARMv8-A, so the cortex-a53 build
runs identically on the Flint 3.
# SSH key must be in place first:
ssh-copy-id -i ~/.ssh/id_ed25519.pub root@192.168.8.1
# Install the .ipk stack + foo2zjs:
./scripts/install-on-router.sh
./scripts/install-foo2zjs.sh# CUPS: bind to 0.0.0.0:631, allow LAN, open firewall, install pdftops shim
./scripts/configure-cups.sh
# AirPrint: Avahi _ipp._tcp._universal service record. All printer-facing
# strings are env-overridable; defaults target the reference HP LJ 1022.
PRINTER_NAME=My_Printer \
PRINTER_MODEL="Brother HL-L2350DW" \
PRINTER_DESCRIPTION="Brother HL-L2350DW on GL-BE9300" \
./scripts/configure-airprint.shThen add the printer through the CUPS admin UI at http://192.168.8.1:631/admin, or via
lpadmin on the router. Example (reference HP LaserJet 1022 setup):
ssh root@192.168.8.1 \
lpadmin -p HP_LaserJet_1022 -E \
-v usb://HP/LaserJet%201022 \
-P /usr/share/cups/model/HP-LaserJet_1022.ppdFor a different printer, substitute the queue name, USB URI (see lpinfo -v on the router), and
PPD path. The printer then appears on iOS and macOS as an AirPrint destination named
"AirPrint <PRINTER_MODEL> @ <hostname>".
Unrelated to printing, but it lives on the same router: a push notification to the phone whenever the router's active WAN changes — e.g. Ethernet drops and traffic fails over to iPhone USB tethering, and again when it comes back. Notifications go straight from the router to the free, open-source Bark iOS app, which delivers through Apple's push service. Nothing runs on the Mac. ntfy was tried first, but its iOS app only showed messages after a manual refresh.
GL.iNet firmware uses its own multi-WAN daemon, kmwan (not mwan3). It runs
/etc/hotplug.d/kmwan/* on every member status change; our hook there calls wan-notify check,
which works out which member is carrying traffic (failover mode: the online member with the
lowest metric) and only notifies when that changes. Plugging the iPhone in while Ethernet is
healthy stays silent.
Install Bark from the App Store and open it once. It shows example URLs like
https://api.day.app/<key>/…; the <key> part is your device key. Treat it as a password,
because anyone who knows it can push to your phone. Re-running the installer keeps the existing key.
BARK_KEY=<key> ./scripts/install-wan-notify.sh| Event | Bark level | Example |
|---|---|---|
| Failover to a backup WAN | time-sensitive (breaks through Focus) | |
| Back on the primary WAN | active | ✅ WAN: iPhone USB tethering → Ethernet — Now on Ethernet (192.168.1.2). iPhone USB tethering had been active for 14m 5s. |
| Recovery after a full outage | — | Same, with "Internet was down for …" (nothing can be sent while every WAN is down) |
The same notifier also reports VPN tunnel profile failovers. Each VPN Dashboard tunnel (e.g. IL
Tunnel, IT Tunnel) is a route_policy rule with an ordered profile list in /etc/vpn_profiles.d/.
When the tunnel's wgclient/ovpnclient interface goes down, GL's tunnel-switch.sh rewrites the
rule's peer_id to the next profile and restarts the interface. The iface hook
99-wan-notify-vpn runs wan-notify vpn-check on that interface's ifup/ifdown, and it notifies when
any tunnel's profile differs from the last one recorded. Profile names come from the imported
config file name.
| Event | Bark level | Example |
|---|---|---|
| Tunnel fails over to a backup profile | time-sensitive | |
| Tunnel back on its primary profile | active | ✅ IT Tunnel: France-CH-FR-2 → Italy-CH-IT-2 — Back on the primary profile (wgclient1). France-CH-FR-2 had been active for 12m 34s. |
There is no event to hook when tunnel-switch.sh points a tunnel at an interface that is already up
with the target profile, which only happens when two tunnels share a profile. In that case the change
is reported at the next ifup/ifdown of any VPN interface.
ssh root@192.168.8.1 wan-notify status # active WAN, every member, every VPN tunnel's profile
ssh root@192.168.8.1 wan-notify test # send a test notification
ssh root@192.168.8.1 logread -e wan-notify # what was detected / sent
ssh root@192.168.8.1 'uci set wan_notify.main.enabled=0; uci commit wan_notify' # muteSettings live in /etc/config/wan_notify (key; server, default https://api.day.app, or a
self-hosted bark-server; settle, seconds to let kmwan settle before checking, default 5). Changes within
the first 3 minutes after boot are recorded but not notified, since WANs come up one by one.
Publishing retries for about a minute; if the router's own resolver can't resolve the Bark server
(dnsmasq is often briefly unusable right after a WAN switch or VPN reconnect), retries resolve it
over DNS-over-HTTPS via 1.1.1.1 / 8.8.8.8 instead. A rejected key fails at once without retrying.
A GL.iNet firmware upgrade resets /overlay, which removes every package installed outside the
firmware image — i.e. the entire printing stack. Recovery is one command, because the prebuilt
artifacts in output/ remain valid as long as the router's architecture and toolchain are unchanged:
# Verify the target is still the same before trusting output/:
ssh root@192.168.8.1 'cat /etc/openwrt_release; uname -r'
# DISTRIB_ARCH='aarch64_cortex-a53_neon-vfpv4' → output/*.ipk still valid
# anything else → drop --skip-build and rebuild
./scripts/bootstrap.sh --skip-buildSurvives (in /etc, kept by "keep settings") |
Wiped (lived in /overlay) |
|---|---|
arch aarch64_cortex-a53 200 in /etc/opkg.conf |
All .ipk packages: cups, ghostscript, qpdf, lcms2, openprinting-cups-filters, … |
Allow-CUPS / Allow-mDNS firewall rules |
/bin/bash (required by foomatic-rip) |
/etc/cups/cupsd.conf (overwritten by configure-cups.sh anyway) |
/usr/lib/cups/filter/{foomatic-rip,foo2zjs,foo2zjs-wrapper,pdftops} |
/etc/cups/printers.conf — the queue definition |
/etc/cups/ppd/ — leaving the queue pointing at a PPD that no longer exists |
avahi-dbus-daemon (part of the GL.iNet image) |
/etc/modprobe.d/blacklist-usblp.conf, /etc/cups/mime.convs |
WAN notifier: /etc/config/wan_notify, plus its two files (listed in /etc/sysupgrade.conf) |
The surviving-queue-without-a-PPD case is why the lpadmin stage in bootstrap.sh always passes
-v and -P, even when lpstat reports the queue already exists.
ssh root@192.168.8.1 '
lpstat -p HP_LaserJet_1022 -l # expect: idle, enabled
ls -l /etc/cups/ppd/ # expect: HP_LaserJet_1022.ppd, mode 644
netstat -lnt | grep 631 # expect: 0.0.0.0:631 LISTEN
lsmod | grep usblp # expect: no output (blacklisted)
'
# From a Mac on the LAN — Bonjour discovery and IPP capabilities:
dns-sd -B _ipp._tcp local. # expect: "AirPrint HP LaserJet 1022 @ GL-BE9300"
ipptool -tv ipp://192.168.8.1:631/printers/HP_LaserJet_1022 get-printer-attributes.test \
| grep document-format-supported # must include application/pdf
# Physical test page through the full chain (pdftops → foomatic-rip → foo2zjs → USB):
ssh root@192.168.8.1 'lp -d HP_LaserJet_1022 /usr/share/cups/data/default-testpage.pdf'The GL.iNet image has no
/etc/hostname, and/usr/share/cups/data/testprintis detected asapplication/vnd.cups-pdf-banner(rejected). Usedefault-testpage.pdf— it also exercises the real AirPrint ingress path rather than a raw passthrough.
Tested upgrade paths: v4.8.4 → v4.10.1 (no rebuild needed; arch, kernel 5.4.213, and opkg feeds all unchanged).
If you're building on Apple Silicon via Rosetta, gettext-full's host build runs gnulib-tool —
a shell script that spawns thousands of short-lived processes. Each fork roundtrips through Rosetta,
so a single gnulib import can take over an hour. The build isn't stuck; it's emulation-bound.
Move the build to a native x86_64 Linux host and it finishes in under an hour.
Verify it's still progressing:
docker exec openwrt-build bash -c '
L1=$(wc -c < /workspace/build.log); sleep 10
L2=$(wc -c < /workspace/build.log)
echo "bytes added in 10s: $((L2 - L1))"'The SDK's bundled staging_dir/host/share/gnulib/ is a curated 2017 snapshot and omits a handful of
modules that gettext-0.21.1's autogen.sh imports (notably root-uid). scripts/patch-sdk.sh
fixes this by overlaying Ubuntu's gnulib package (apt-installed inside the container) with
rsync --ignore-existing, adding the missing module descriptors without touching the SDK's own
files. It also clears build_dir/hostpkg/gettext-0.21.1 so gettext reimports with the patched tree.
Idempotent — re-running is a no-op after the first pass.
Vladdrako's poppler 23.11.0 Makefile does not pass ENABLE_BOOST=OFF, so CMake hard-fails when
Boost ≥ 1.71 is not installed. The Splash backend isn't needed — cups-filters uses poppler's core
API — so scripts/patch-sdk.sh appends -DENABLE_BOOST=OFF to the CMake options and clears the
stale .configured stamp. Idempotent.
The Vladdrako cups Makefile has its --with-tls conditional inverted:
--with-tls=$(if $(LIBCUPS_OPENSSL),gnutls,openssl) — so selecting GnuTLS in menuconfig
actually passes openssl to configure. scripts/patch-sdk.sh swaps the two branches so the
selected TLS backend is respected. We default to GnuTLS because libgnutls is pre-staged by the
SDK; OpenSSL requires also selecting libopenssl.
This is a known GCC 12 LTO/jobserver fd bug triggered at high parallelism. build-stack.sh
automatically retries failed packages at -j1 when the parallel build fails.
Known-fragile. The pipeline uses Poppler as the primary PDF backend; Ghostscript failure is treated as non-fatal (cups-filters works with Poppler alone).
- Check Avahi is running:
ssh root@192.168.8.1 pgrep avahi-daemon - Verify mDNS traffic is allowed on the LAN side:
ssh root@192.168.8.1 nft list chain inet fw4 input_lan - Confirm the service advertises:
dns-sd -B _ipp._tcp local(from macOS)
cups-filters 1.0.37 does not build its pdftops / pdftopdf / pdftoraster filters against
Poppler 23.x on aarch64 — the Poppler C++ headers moved enough that those filter sources fail to
compile, so the .ipk ships without them. Without pdftops, CUPS has no chain from
application/pdf to application/vnd.cups-postscript (which every foomatic-rip-driven PPD expects
as input). The first visible symptom is that application/pdf and image/urf are missing from
ipptool … document-format-supported — and because iOS AirPrint probes that list before submitting
a job, the printer shows "online" but no Create-Job ever reaches CUPS (tcpdump shows only
Get-Printer-Attributes requests, no job data). configure-cups.sh fixes this by installing a
small Ghostscript-based /usr/lib/cups/filter/pdftops shim and registering it in
/etc/cups/mime.convs at cost 50, which makes PDF and URF appear in document-format-supported
and unblocks iOS job submission. Verify after configure-cups.sh with:
ssh root@192.168.8.1 \
cupsfilter -m application/vnd.cups-postscript \
-p /etc/cups/ppd/<QUEUE>.ppd -i application/pdf --list-filters /dev/null
# Expected output: pdftopsConfirm /etc/opkg.conf on the router contains arch aarch64_cortex-a53 200. install-on-router.sh
adds this line idempotently on every run.
- SDK target
ipq807xis used as the closest upstream match for the router'sipq53xx. Both use Cortex-A53 with identical ABI (NEON + VFPv4 + crypto extensions); packages compiled for ipq807x run unchanged on ipq53xx. - Runtime linker mismatch risk: the router uses
ld-musl-aarch64.so.1from its shipped musl 1.2.4 build; the SDK produces binaries linked against the same. Confirmed compatible. - Kernel version on the router (5.4.213) affects only kernel modules. This project ships userspace only — no kmods — so kernel skew is irrelevant.
- Vladdrako printing feed: https://github.com/Vladdrako/openwrt-printing-packages
- OpenWrt SDK (23.05.6, ipq807x): https://downloads.openwrt.org/releases/23.05.6/targets/ipq807x/generic/
- OpenPrinting / foo2zjs: https://github.com/OpenPrinting/foo2zjs
- HP LaserJet 1022 on OpenPrinting: https://www.openprinting.org/printer/HP/HP-LaserJet_1022
- TheMMcOfficial — CUPS for OpenWrt: https://themmcofficial.github.io/cups-for-openwrt/
MIT — see LICENSE for full text.