Print server

bdf printd is a print server: it takes jobs over HTTP, keeps them in one file, sends them to IPP printers (and to printers of 9100 and LPD) in order, follows each until the printer is done, and retries a printer that is not reached. A business system hands it a document and goes on; the server does the waiting. It speaks IPP to the printers the way bdf print does, without a driver of the system.

bdf printd -config printd.yaml                           # in the foreground; Ctrl-C or SIGTERM stops it
bdf print -server http://127.0.0.1:8640 -queue office invoice.bdf     # submit, print the job's id, return
bdf print -server http://127.0.0.1:8640 -queue office -wait 10m report.xlsx   # and follow it until printed
bdf jobs -server http://127.0.0.1:8640                    # the jobs not done
bdf printers -server http://127.0.0.1:8640                # the queues and the printers

$BDF_SERVER and $BDF_TOKEN stand in for -server and the token. A browser at http://127.0.0.1:8640/ shows the queues, the printers and the jobs as they change.

What it does

Configuration

listen: 127.0.0.1:8640              # the default
spool: /var/lib/bdf/spool.db        # default: spool.db next to this file
user: bdf                           # the requesting-user-name the printers see
tls: { cert: /etc/bdf/tls.crt, key: /etc/bdf/tls.key }   # required beyond the loopback
tokens:
  - { name: erp, file: /etc/bdf/erp.token, queues: [office, warehouse], roles: [submit], act-as: true }
  - { name: ops, env: BDF_OPS_TOKEN, roles: [submit, manage, admin] }
printers:
  mfp1: { url: "ipps://mfp1.example.local/ipp/print", fingerprint: "sha256:…" }
  mfp2: { url: "ipp://192.0.2.20/ipp/print", username: print, password-env: MFP2_PASSWORD }
  old-laser: { url: "socket://192.0.2.30:9100" }                     # no IPP: PostScript over the raw port
  plotter: { url: "lpd://192.0.2.31/lp1", format: pdf, pjl: true }   # PDF over LPD, with a PJL header
  labels: { url: "socket://192.0.2.40:9100", profile: zd421-203 }    # ZPL labels
  register: { url: "serial:///dev/ttyUSB0?baud=19200", format: escpos }   # ESC/POS receipts
  zebra-usb: { url: "ipp://localhost:631/printers/zebra", profile: zd421-300 }   # a raw queue of CUPS
queues:
  office:
    printers: [mfp1, mfp2]
    policy: failover                # or any
    defaults: { sides: two-sided-long-edge, print-color-mode: monochrome }
    format: auto                    # auto (PDF when the printer takes it, else PostScript, PWG Raster, Apple Raster, PCLm), pdf, ps, pwg-raster, urf or pclm
    raster-if-lossy: true
    max-inflight: 2
  warehouse: { printers: [mfp2] }
retention: { documents: 7d, records: 400d }
limits: { max-document: 256MiB, max-jobs: 10000, max-bytes: 10GiB, max-impressions: 10000,
          renders: 0, retry-for: 24h, uploads: 8, events: 64 }
fonts: { dirs: [/usr/share/fonts/bdf], no-system: false }
temp-dir: /var/tmp/bdf

bdf printd -config printd.yaml -check reads the configuration, asks each printer what it supports, and prints a table, without opening the spool (exit status 1 when a printer does not answer).

ipp: shows the queues as IPP printers (below); upstreams: and printers of proxy: true fetch jobs across a NAT (below).

Who may do what

The API

JSON, under /api/v1. An error is {"error": {"code": "…", "message": "…"}} with an HTTP status: 400 invalid or bad-document, 401 unauthorized, 403 forbidden or wrong-password, 404 not-found or no-queue, 409 not-possible (not in the job's state), 413 too-large, 415 unknown-format, 503 not-accepting or busy, 507 full.

Method and path
POST /queues/{q}/jobs Submit: multipart/form-data with document (the file) and, in any order, ticket and options (JSON), name, password, priority, hold-until, hold-until-time. 201 and the job
GET /jobs?queue=&user=&state=&after=&limit= The jobs not done; state=all with those done, or a state. after and next page by id
GET /jobs/{id} A job
POST /jobs/{id}/cancel, hold, release, retry, password, move Cancel (at the printer too), hold, release ({"password"} too), send again, give the password, move ({"queue"})
PATCH /jobs/{id} {"priority": n}
GET /queues, GET /queues/{q} Queues and how many jobs are in each state
POST /queues/{q}/pause, resume, reject, accept Stop sending, send again, stop taking jobs, take them again (admin)
GET /printers, GET /printers/{p} Printers, their state, and what one supports
GET /discover?timeout= IPP printers on the network, by DNS-SD (admin)
GET /accounting?from=&to=&format=jsonl|csv The jobs done: who printed what, where, and how many sheets (manage)
GET /backup A consistent copy of spool.db (admin)
GET /events?job= Server-Sent Events: job, queue and printer, with the JSON of each
curl -F document=@invoice.bdf -F 'ticket={"sides":"two-sided-long-edge","copies":2}' \
     -H "Authorization: Bearer $TOKEN" https://print.example.local:8640/api/v1/queues/office/jobs

A ticket also carries what a job says about itself, which the server keeps with the job, tells back over IPP, and sends to the printer: job-account-id and job-accounting-user-id (the account the job is charged to and its user, asked of a printer that says it takes them), document-metadata (key=value strings about the document, with the keys of Dublin Core, title, creator, identifier, subject, description, date …, or keys of one's own that begin with x-; a printer keeps them with the job), and bdf-attributes, job attributes of one's own named x-… ({"x-cost-center": "cc-42", "x-tags": ["a", "b"]}), sent as they are; a printer that does not know one says so and prints without it (with strict, the job fails). bdf print has -account, -accounting-user, -metadata key=value and -attribute x-name=value for them.

Out of /api/v1: /healthz (200 while the server serves), /metrics (Prometheus' text: the jobs by queue and state, the jobs done, sheets, the printers' state, bytes sent, retries, the size of the spool), and /, the page of the state (it only reads; a token can be given on it).

From Go, print/server/client is the client of the API (Submit, Job, Jobs, Cancel …, Wait, Events, Accounting), and depends on nothing else of bdf. A program can also embed the server: print/server opens the spool of a configuration and gives its http.Handler (see the sample).

Encrypted documents

An encrypted .bdf stays encrypted in the spool. Its password, given with the job (bdf print sends $BDF_PASSWORD, checked first), is kept in memory only: after a restart the job waits (pending-held, job-password-wait) until it is given again (bdf jobs password <id>). A PDF or an Office document that needs a password is converted with it, and the converted .bdf is encrypted with the same password. bdf print does not send a password over a connection without TLS to another machine unless -allow-plaintext.

As an IPP printer

With ipp: in the configuration, each queue is also an IPP Everywhere printer that every system adds without a driver: Windows (its IPP class driver, as Windows Ready Print adds printers), macOS and iOS (AirPrint), Android (Mopria, the default print service), ChromeOS and Linux (CUPS). They send PDF (listed first, so vectors are sent), Apple Raster or PWG Raster, JPEG or PNG; the server converts what it takes into .bdf, as it does a submission, and prints it on the queue's printers. Raster pages become pictures of the paper they were drawn for.

ipp:
  listen: ":8631"              # the default; ipp and ipps on the one port
  host: print.example.com      # the name in the printers' URIs and the certificate: the machine's, in .local, by default
  auth: none                   # none (as the printers of a LAN) or basic (a token's name and secret): basic when there are tokens
  require-tls: false           # refuse ipp (HTTP 426): only ipps; so with basic
  tls: { cert: /etc/bdf/ipp.crt, key: /etc/bdf/ipp.key }   # without it, one the server signs itself (ipp-tls.pem next to the spool)
  queues: [office, warehouse]  # the queues shown: all by default
  default: office              # also at /ipp/print
  location: "2F"               # printer-location, under a queue's own
  organization: Example Inc.
  dns-sd: true                 # advertise on the local links (mDNS): the default
queues:
  office: { printers: [mfp1], info: "Office printer", location: "2F north" }

Adding the printers:

System
Windows 10 and 11 Settings → Bluetooth & devices → Printers & scanners → Add device: the queue is listed (mDNS). Elsewhere: "Add a printer using an IP address or hostname" → IPP device → https://host:8631/ipp/print/office (or http://). The IPP class driver is used; no driver is installed
macOS System Settings → Printers & Scanners → Add Printer: the queue is listed (AirPrint). Elsewhere: the IP tab, protocol IPP, address host:8631, queue ipp/print/office. From the shell: lpadmin -p office -E -v ipps://host:8631/ipp/print/office -m everywhere
iPhone, iPad The queue is listed when printing (AirPrint) on the same subnet; elsewhere the profile above
Android The default print service (Mopria) lists it; elsewhere add it by its address in the service's settings
ChromeOS Settings → Printers → Add printer: listed, or by address with IPP or IPPS and the path ipp/print/office
Linux lpadmin -p office -E -v ipps://host:8631/ipp/print/office -m everywhere, or the desktop's printer settings, which list it

Branches behind a NAT

A branch whose printers the head office's server does not reach (a NAT, no VPN) runs bdf printd of its own, which fetches the jobs of a queue of the head office and prints them on its printers: it connects out, the head office never connects in. The two speak IPP's Shared Infrastructure Extensions (PWG 5100.18), so another proxy of that standard can fetch the jobs too.

# the head office: a queue whose printer is the branch's proxy
printers:
  osaka: { proxy: true }
queues:
  osaka: { printers: [osaka] }
ipp: { listen: ":8631", host: print.example.com }
tokens:
  - { name: osaka-proxy, file: /etc/bdf/osaka.token, roles: [proxy], queues: [osaka] }
# the branch
printers:
  mfp: { url: "ipp://192.0.2.10/ipp/print" }
queues:
  local: { printers: [mfp] }
upstreams:
  hq:
    url: ipps://print.example.com:8631/ipp/print/osaka
    queue: local
    username: osaka-proxy
    password-file: /etc/bdf/osaka.token
    poll: 10s              # the default
    # fingerprint: "sha256:…"   pins the head office's certificate; without it the first one seen is kept

Labels, receipts and forms

A queue of a printer of labels, receipts or continuous forms prints what any other queue takes — a filled form, a PDF, an image — in the printer's own language (bdf print has the details):

Language Printers What is sent
ZPL Zebra, and the printers that take ZPL II each label one format: ^GFA of the dots in ZPL's ASCII compression, the QR codes and barcodes as ^BQ, ^BC, ^BE …, ^PQ copies
SBPL SATO each label one job <A> … <Z> between STX and ETX: <G>H graphics, <2D30>, <B>, <BG> symbols, <Q> copies
TPCL Toshiba TEC {D size, {SG; graphic in nibble mode, {XB/{RB symbols (not Code 128), {XS;I issue
ESC/POS Epson TM, receipt printers bands of GS v 0 as they are drawn, GS V cut, ESC p drawer
ESC/P Epson VP, dot-matrix printers 24-pin bit images (ESC *, 180 dpi), ESC C page length, ESC N perforation skip, form feed

The queue's defaults take the settings of such printers with the ticket's names: print-darkness, print-speed and media-tracking (IPP's), and bdf-label-gap, bdf-cut, bdf-cash-drawer, bdf-perforation-skip, bdf-no-printer-barcodes, and bdf-print-mode (raster or text: the characters of a form of a template of text in the printer's fonts; ESC/P prints such a form in the text mode by default). Copies are the printer's (^PQ, <Q>, {XS), or sent again for receipts and forms.

The commands are those of the makers' programming references (Zebra's ZPL II guide, SATO's CL4NX Plus and CT4-LX references, Toshiba TEC's B-EX4, B-FV4 and BX400 interface specifications, Epson's ESC/POS and ESC/P references). They have been checked against readers of our own that print them back to dots, not on printers: try a label before a batch, and if a printer prints a symbol wrongly, bdf-no-printer-barcodes: true draws it as dots instead (readable at 203 dpi). SBPL is wrapped in STX and ETX, as SATO's status protocols ask. Windows reaches USB printers through the queue of their driver (winspool:///Name, passed on as RAW), macOS through CUPS (ipp://localhost:631/printers/<queue>), Linux through either or usb:///dev/usb/lp0.

Running it as a service

bdf printd runs in the foreground, logs to the standard error (JSON; -log text for people), and stops on SIGINT or SIGTERM (Ctrl-C or Ctrl-Break on Windows): it stops taking requests, lets the documents being sent finish for 30 seconds, and closes the spool. What is cut is put right when it starts again. It does not install itself as a service: the system's service manager keeps it running.

Linux (systemd): /etc/systemd/system/bdf-printd.service, then systemctl enable --now bdf-printd.

[Unit]
Description=bdf print server
After=network-online.target
Wants=network-online.target

[Service]
# a user of its own (useradd --system bdf), who may read the token files
User=bdf
ExecStart=/usr/local/bin/bdf printd -config /etc/bdf/printd.yaml
Restart=on-failure
# spool: /var/lib/bdf/spool.db in printd.yaml
StateDirectory=bdf
TimeoutStopSec=45

[Install]
WantedBy=multi-user.target

macOS (launchd): /Library/LaunchDaemons/io.github.shibukawa.bdf-printd.plist, then sudo launchctl bootstrap system /Library/LaunchDaemons/io.github.shibukawa.bdf-printd.plist.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>io.github.shibukawa.bdf-printd</string>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/local/bin/bdf</string><string>printd</string>
    <string>-config</string><string>/usr/local/etc/bdf/printd.yaml</string>
  </array>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
  <key>StandardErrorPath</key><string>/usr/local/var/log/bdf-printd.log</string>
</dict>
</plist>

Windows: a service wrapper runs it, such as NSSM or WinSW.

nssm install bdf-printd "C:\Program Files\bdf\bdf.exe" printd -config "C:\ProgramData\bdf\printd.yaml"
nssm set bdf-printd AppStopMethodConsole 30000
nssm set bdf-printd AppStderr "C:\ProgramData\bdf\printd.log"
nssm start bdf-printd
<!-- bdf-printd.xml, next to WinSW renamed bdf-printd.exe: bdf-printd.exe install, then start -->
<service>
  <id>bdf-printd</id>
  <name>bdf print server</name>
  <executable>C:\Program Files\bdf\bdf.exe</executable>
  <arguments>printd -config "C:\ProgramData\bdf\printd.yaml"</arguments>
  <stoptimeout>30 sec</stoptimeout>
  <onfailure action="restart" delay="10 sec"/>
  <log mode="roll"/>
</service>

Without a wrapper, the Task Scheduler can start it at boot: schtasks /create /tn bdf-printd /sc onstart /ru SYSTEM /tr "\"C:\Program Files\bdf\bdf.exe\" printd -config C:\ProgramData\bdf\printd.yaml" (it is then not restarted when it fails).

Keeping the spool