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 • 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.8.4 (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
├── 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>".
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.