Skip to content

Repository files navigation

AirPrint for GL.iNet Flint 3 (GL-BE9300)

License: MIT OpenWrt 23.05 Target: aarch64 Toolchain: GCC 12.3 Platform: Docker

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


✨ What This Is

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).

Printer-agnostic by design

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 hplip from 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.).


🖨️ Print Pipeline

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.


🚀 Quick Start

Prerequisites

  • 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.

Router Specs

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

🏗️ Architecture

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/

Project Layout

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 file

📦 Build

All scripts are idempotent and resume-friendly. Re-running after a failure is safe.

One-shot deploy (recommended)

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-install

Running the stages manually

If 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.sh

Watching the Build

The build logs to /workspace/build.log inside the container. Stream it from a second terminal:

docker exec openwrt-build tail -F /workspace/build.log

Expected Output

output/
├── 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.gz

🔌 Install on Router

The 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

⚙️ Configure

# 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.sh

Then 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.ppd

For 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>".


📣 WAN & VPN Switch Notifications

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) ⚠️ WAN: Ethernet → iPhone USB tethering — Now on iPhone USB tethering (172.20.10.3). Ethernet had been active for 3h 12m.
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 ⚠️ IL Tunnel: Israel_via_SW-SE-IL-1 → Israel_via_IC-IS-IL-1 — Switched to fallback profile 2 of 2 (wgclient2). Israel_via_SW-SE-IL-1 had been active for 3h 12m.
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'   # mute

Settings 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.


🔁 After a Firmware Upgrade

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-build

What survives an upgrade, and what does not

Survives (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.

Verifying the restore

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/testprint is detected as application/vnd.cups-pdf-banner (rejected). Use default-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).


🔧 Troubleshooting

Build is slow / looks stuck

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))"'

gnulib-tool: module root-uid doesn't exist

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.

poppler CMake: Boost recommended for Splash. Use ENABLE_BOOST=OFF to skip.

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.

cups configure: --with-tls=openssl was specified but neither OpenSSL nor LibreSSL were found

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.

nspr fails with "write jobserver: Bad file descriptor"

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.

Ghostscript fails to cross-compile

Known-fragile. The pipeline uses Poppler as the primary PDF backend; Ghostscript failure is treated as non-fatal (cups-filters works with Poppler alone).

Printer not discovered by iOS / macOS

  1. Check Avahi is running: ssh root@192.168.8.1 pgrep avahi-daemon
  2. Verify mDNS traffic is allowed on the LAN side: ssh root@192.168.8.1 nft list chain inet fw4 input_lan
  3. Confirm the service advertises: dns-sd -B _ipp._tcp local (from macOS)

CUPS: No filter to convert from application/pdf / iOS shows online but never prints

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: pdftops

opkg install refuses packages — architecture mismatch

Confirm /etc/opkg.conf on the router contains arch aarch64_cortex-a53 200. install-on-router.sh adds this line idempotently on every run.


🛡️ Notes on ABI and Source Compatibility

  • SDK target ipq807x is used as the closest upstream match for the router's ipq53xx. 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.1 from 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.

📚 Key References


📄 License

MIT — see LICENSE for full text.

Contributors

Languages