# Print Deploy protocol research toolkit

Reverse-engineering and reproduction of the **PaperCut Print Deploy / Mobility
Print client** (observed client 1.10.3838), based on the mitmproxy capture
`convo6_reallyfull.dump`. The toolkit goes from extracting the client's
credentials to submitting a PDF with the Printer JWT, and documents the wire
protocol in three stages.

> **Authorized use only.** The capture contains live-looking Microsoft,
> PaperCut and printer credentials. Use this only on infrastructure you are
> authorized to test. Treat the dump and any extracted token files as
> sensitive, and rotate the credentials if the dump has been shared.

**Last updated:** 2026-11-11

---

## Files

| File | Kind | Purpose |
|---|---|---|
| `convo6_reallyfull.dump` | input capture | mitmproxy tnetstring flow dump: 32 HTTP flows, captured 2026-11-10 07:50:39–07:51:06 UTC, client `Print Deploy Client/1.10.3838/Debian`. Contains live-looking credentials; do not share. |
| `a3_option_tests.py` | tool | Submits eight `lp -d Papercut-Default` jobs that request A3 in different ways (no option, `media=A3`, PWG name forms, `media-col` collections, `PageSize=A3`), asks per job whether the release-device display showed A3, and writes `results-a3-portrait.txt`. Diagnostic for the default PPD path; no relay or JWT involved. |
| `color_option_tests.py` | tool | Submits eight `lp -d Papercut-Default` jobs with `media=A4` pinned and different color settings (`print-color-mode=color/auto/monochrome`, `ColorModel=RGB/CMYK/Gray`, and a conflict case), asks per job which mode the display showed, and writes `results-a4-color.txt`. Diagnostic for the default PPD path; no relay or JWT involved. |
| `duplex_option_tests.py` | tool | Submits eight `lp -d Papercut-Default` jobs with `media=A4` pinned and different duplex settings (`sides=two-sided-long-edge/short-edge`, `Duplex=DuplexNoTumble/Tumble/None`, `KMDuplex=2Sided`, and a conflict case), asks per job which binding the display showed, and writes `results-duplex-a4.txt`. Uses the two-page `2324-D.antwWIHM.pdf`; no relay or JWT involved. |
| `combined_option_tests.py` | tool | Sends 16 combined jobs: A4/A3 × 1-sided/2-sided long edge × color/monochrome × 2 repeats, using only the proven IPP attribute forms (`media=A4|A3`, `print-color-mode=color|monochrome`, `sides=two-sided-long-edge`). Asks per job whether the release-device display matched, and writes `results-stress-combined.txt`. Short-edge duplex is deliberately not tested. |
| `extract_tokens.py` | tool | Decodes the tnetstring dump and extracts every credential: session token, Microsoft access/refresh tokens, Printer JWT, OAuth code/state. Human summary or JSON (`--json`, `--out`). |
| `printdeploy_client.py` | tool | Interactive step-by-step reproduction of the protocol through driver downloads: fresh OAuth sign-in via the localhost:9176 callback, session exchange, printer list, token refresh, Printer JWT, printer URL, driver zip/catalog/capabilities. Stops on the first failure and asks before each next step. Optional captured-IPP replay. `--skip-redundant` drops the observational steps (2, 3, 7, 9–12) and `--skip-downloads` drops steps 15–18. |
| `acquire_queue_info.py` | tool | Minimal credential acquisition for a CUPS relay: OAuth sign-in → `/deploy/oauth/user` → `/deploy/printers` → `/token` → `/printer-url`. Writes the Printer JWT, full queue URL, Mobility base and derived IPP endpoint to a YAML file (atomic, mode 0600). Does not perform the observational steps, token refresh or driver downloads. |
| `ipp_relay.py` | tool | Loopback IPP relay (PROTOCOL3 option C): listens on `127.0.0.1:9123` by default, ignores the incoming URL/path, rewrites the IPP `printer-uri` attribute to the canonical value from the queue YAML, POSTs to the `ipp_endpoint` with `Authorization: Bearer <printer_jwt>`, relays the IPP response, logs each request/result and serves a non-secret health page. Refuses non-loopback binds unless overridden. |
| `papercut_relay_backend.py` | tool | Optional CUPS backend wrapper: maps PPD options (`ColorModel`, `PageSize`, `Duplex`, plus KM `SelectColor`/`KMDuplex`) to IPP `print-color-mode`/`media-col`/`sides`, then execs the stock `ipp` backend pointed at the relay. Makes CUPS UI/application PPD options work; explicit IPP options take precedence. |
| `papercut-relay.ppd` | PPD | Minimal PPD for the wrapper path: `PageSize` A4/A3, `ColorModel` RGB/Gray, `Duplex` None/DuplexNoTumble, PDF/URF pass-through; defaults A4, monochrome, 1-sided. Passes `cupstestppd`. A PPD alone cannot map these names to IPP — use it with `papercut_relay_backend.py`. |
| `lxc_papercut_backend.py` | tool | Host CUPS backend for a queue inside an LXC container: device URI `lxc-papercut://CONTAINER/QUEUE`; runs the container's `lp` via `lxc-attach` with the spool file on stdin and maps PPD options to IPP names like the relay wrapper. Needs no host↔container network. |
| `make_relay_ppd.py` | tool | Creates a relay-compatible variant of the Konica Minolta C750i/C751i multi-model PPD: sets `*DefaultModel` to C451i and replaces the `KMbeuEmpPS.pl` filter with the PDF/URF pass-through filter chain. Use with `papercut_relay_backend.py` if you want the vendor C451i option set. |
| `papercut-ipp-relay.service` | systemd unit | Hardened unit for `ipp_relay.py`; reads `/etc/papercut/queue.yaml`, logs to the journal, restarts on failure. Install to `/etc/systemd/system`. |
| `print_pdf_jwt.py` | tool | Submits a PDF to the Mobility Print queue using **only** the Printer JWT. Preflights the PDF/JWT, sends `Print-Job` (default) or `Create-Job` + `Send-Document`, parses the IPP response and reports job-id/state. |
| `PROTOCOL1.md` | protocol doc | Startup through the Printer JWT: topology, OAuth loopback flow, session token, Microsoft tokens, `/deploy/printers`, token refresh, `/token`, token formats and lifetimes. |
| `PROTOCOL2.txt` | protocol doc | From the Printer JWT to a ready-to-print queue: `/printer-url`, driver zip/PPD, catalog extras, `.cap` capabilities, static IPP attributes, `Validate-Job`. Stops before `Create-Job`. |
| `PROTOCOL3.md` | protocol doc | Actual job submission: IPP encoding, `Create-Job` + `Send-Document`, `Print-Job`, polling/job states, error codes, and CUPS integration (backend helper and loopback relay). |
| `PDDOPTIONS.md` | research note | Summary of the duplex/paper-size/color option research: CUPS option-mapping failure, PPD comparison (C750i vs C751i for C451i), the KM filter's actual role, manual IPP options, mapping backend, generated relay PPD, and what has/has not been tested. |
| `README.md` | index | This file. |
| `printdeploy_downloads/` | runtime output | Driver artifacts downloaded by `printdeploy_client.py`: `Follow-Me.zip`, `ColorSync.tar.zip`, `Follow-Me.cap`. Regenerated when the download steps run. |

`printer.jwt`, `queue.yaml` (or any extracted token/queue file) are **not**
checked in and are not part of the toolkit; create them locally with mode
`0600` when needed.

---

## Directory layout

```
.
├── convo6_reallyfull.dump
├── a3_option_tests.py
├── color_option_tests.py
├── duplex_option_tests.py
├── combined_option_tests.py
├── acquire_queue_info.py
├── extract_tokens.py
├── ipp_relay.py
├── make_relay_ppd.py
├── papercut_relay_backend.py
├── papercut-relay.ppd
├── lxc_papercut_backend.py
├── papercut-ipp-relay.service
├── PDDOPTIONS.md
├── printdeploy_client.py
├── print_pdf_jwt.py
├── PROTOCOL1.md
├── PROTOCOL2.txt
├── PROTOCOL3.md
├── README.md
└── printdeploy_downloads/
    ├── Follow-Me.zip
    ├── ColorSync.tar.zip
    └── Follow-Me.cap
```

---

## Quick start

All tools are standard-library Python 3; run them with `-I` so the working
directory cannot shadow imports.

### 1. Extract credentials from the capture

```bash
cd /home/rsnel/tmp/reseach
python3 -I extract_tokens.py convo6_reallyfull.dump
python3 -I extract_tokens.py convo6_reallyfull.dump --truncate 16   # preview
python3 -I extract_tokens.py convo6_reallyfull.dump --json --out tokens.json
```

### 2. Walk the client protocol

```bash
python3 -I printdeploy_client.py
python3 -I printdeploy_client.py --skip-redundant --skip-downloads
```

This performs a **fresh** Microsoft sign-in (it does not reuse the captured
tokens), then walks `/deploy/config` → OAuth → `/deploy/printers` → token
refresh → Printer JWT → printer URL → driver downloads. Every step is printed
and validated; the program stops on the first failure. Useful flags:

```bash
--server, --printer, --download-dir, --no-browser, --callback-port
--verify / --cacert, --verbose
--skip-redundant         # skip steps 2, 3, 7, 9, 10, 11, 12 (observational)
--skip-downloads         # skip steps 15-18 (driver downloads)
--verify-sha256          # enforce driver hashes (off by default)
--ipp-dump DUMP          # replay captured IPP requests after setup
--submit-print-job       # with --ipp-dump, include Create-Job/Send-Document
```

Skipped steps keep their number in the output. `--skip-redundant` also skips
the token refresh (step 10), which is safe only while the access token from
step 6 is still valid (about 74 minutes).

### 3. Acquire the queue config for a CUPS relay

```bash
python3 -I acquire_queue_info.py --out queue.yaml
# for the PROTOCOL3 option-C relay:
sudo python3 -I acquire_queue_info.py --out /etc/papercut/queue.yaml
```

This performs only the six steps needed for the Printer JWT and the queue
URL: `/deploy/oauth/url`, the browser callback, `/deploy/oauth/user`,
`/deploy/printers`, `/token` and `/printer-url`. It does not fetch branding,
translations or `/deploy/config` (except as a fallback when the printer has no
`connection.name`), does not run the legacy `/printer-urls` probes or the token
refresh, and does not download drivers.

The YAML file is written atomically with mode `0600` and contains
`printer_jwt`, `queue_url`, `mobility_base`, `ipp_endpoint`, `printer_uri`,
`printer`, `username` and the JWT expiry. The loopback relay must read the
upstream target and bearer token from this file; it must not derive them from
the CUPS request URL.

### 4. Run the CUPS loopback IPP relay

```bash
sudo install -D -m 0755 ipp_relay.py /usr/local/lib/papercut/ipp_relay.py
sudo install -D -m 0600 queue.yaml /etc/papercut/queue.yaml
sudo install -m 0644 papercut-ipp-relay.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now papercut-ipp-relay

journalctl -u papercut-ipp-relay -f     # request/result lines
curl -s http://127.0.0.1:9123/          # health page (no secrets)
```

For a one-off test, run the relay in the foreground instead of installing the
service (`python3 -I -B ipp_relay.py --config queue.yaml --debug`) and use a
throwaway CUPS queue (`-v ipp://127.0.0.1:9123/`; the path is ignored). The
exact commands are in the usage block at the top of `ipp_relay.py`. The relay
accepts only `application/ipp` POSTs, so it is not an `lp` destination by
itself: CUPS or another IPP client must send the job to it. If a queue already
points at the relay (`lpstat -v PaperCut-FollowMe`), starting the relay and
using `lp -d` on that queue is enough. `queue.yaml` is the relay's upstream
config (JWT + endpoint), not a CUPS queue. The relay rewrites the IPP
`printer-uri` attribute to the canonical `printer_uri` from the YAML, so a
placeholder device URI such as `papercut-relay:/` works; keep `printer_uri` in
the YAML (or let it default to `ipp_endpoint`).

Then create the CUPS queue. The relay injects the JWT, so CUPS itself does not
authenticate, and the path in the device URI is ignored by the relay:

```bash
sudo lpadmin -p PaperCut-FollowMe -E \
    -v ipp://127.0.0.1:9123/printers/Follow-Me \
    -P /usr/share/ppd/Follow-Me.ppd \
    -o printer-is-shared=false \
    -o auth-info-required=none
sudo cupsenable PaperCut-FollowMe
sudo cupsaccept PaperCut-FollowMe

lp -d PaperCut-FollowMe document.pdf
```

`ipp://127.0.0.1:9123` is sufficient as the device URI. After re-running
`acquire_queue_info.py` (for example when the JWT is near expiry), restart the
service with `sudo systemctl restart papercut-ipp-relay`.

No PaperCut CUPS filter or backend is needed for this queue: the stock `ipp`
backend talks to the relay, and the server's generic AirPrint PPD passes PDF
and URF through. See `PROTOCOL3.md` §8.4.1 before considering the
`pc-print-deploy-client*.deb`, which is the full client/Direct Print Monitor,
not a filter package.

For color and duplex use the **IPP option names**, not the PPD option names.
For A3, use `media=A3` or the `media-col` collection: the standard PWG names
(`iso_a3`, `iso_a3_297x420mm`) did **not** select A3 in testing.

```bash
lp -d PaperCut-FollowMe -o print-color-mode=color -o media=A3 \
    -o sides=two-sided-long-edge document.pdf

# standards-based form (the form the capture advertises):
lp -d PaperCut-FollowMe -o print-color-mode=color \
    -o 'media-col={media-size={x-dimension=29700 y-dimension=42000}}' \
    -o sides=two-sided-long-edge document.pdf
```

An eight-job test on 2026-11-11 with the default `Follow-Me.ppd` (no mapping
backend) showed A3 on the release-device display only for `media=A3` and the
`media-col` forms; no media option, `media=iso_a3`,
`media=iso_a3_297x420mm`, `PageSize=A3`, and `PageSize=A3` plus
`media=iso_a3` did not. The A3 test page's own page size did not select A3,
so set media explicitly. See `a3_option_tests.py` and `results-a3-portrait.txt`.

The queue's default color mode is **monochrome**: an eight-job color test on
2026-11-11 showed Color only for `print-color-mode=color` and
`print-color-mode=auto`; `ColorModel=RGB/CMYK/Gray` had no effect and the
baseline job printed mono. Always send `-o print-color-mode=color` when color
is wanted (`auto` also produced color for the color test page, but it follows
the document content). See `color_option_tests.py` and `results-a4-color.txt`.

The queue's default is also **one-sided**: an eight-job duplex test on
2026-11-11 showed `sides=two-sided-long-edge` (normal duplex) and
`sides=two-sided-short-edge` honoured, while `Duplex=DuplexNoTumble/Tumble`,
`Duplex=None` and `KMDuplex=2Sided` were inert; `sides` won the conflict.
See `duplex_option_tests.py` and `results-duplex-a4.txt`.

The three attributes combine: a 16-job stress test (A4/A3, 1-sided/2-sided
long edge, color/monochrome, two repeats) matched on every job's display. Use
all three options together:

```bash
lp -d PaperCut-FollowMe -o media=A3 -o print-color-mode=color \
    -o sides=two-sided-long-edge document.pdf
```

A4 content sent to A3 media is **not enlarged by default**: CUPS keeps it at
100% and centers it on the A3 page. Add `-o print-scaling=fit` (or
`-o fit-to-page`) to scale it up; `auto` only shrinks when the document is
larger than the page. See `PDDOPTIONS.md` §4.6.

`-o ColorModel=RGB`, `-o PageSize=A3` and `-o Duplex=...` are sent as literal
`ColorModel` / `PageSize` / `Duplex` attributes, which Mobility Print ignores;
the relay journal's `IPP request:` line shows exactly what CUPS sent. The
capture did not request `sides-supported`, so duplex support is not
established from it; if the relay logs `sides=...` and the job is still
one-sided, the release device/queue does not support duplex. See
`PROTOCOL3.md` §8.4.2.

To make **print dialogs and the CUPS web UI** work, install the mapping backend
and point the queue at it:

```bash
sudo install -m 0755 papercut_relay_backend.py \
    /usr/lib/cups/backend/papercut-relay
sudo lpadmin -p PaperCut-FollowMe -v papercut-relay:/

# optional: a minimal PPD exposing only A4/A3, colour/mono and simplex/duplex
sudo install -m 0644 papercut-relay.ppd /usr/share/ppd/
sudo lpadmin -p PaperCut-FollowMe -P /usr/share/ppd/papercut-relay.ppd
```

It maps `ColorModel`/`SelectColor` → `print-color-mode`, `PageSize` →
`media-col` dimensions, and `Duplex`/`KMDuplex` → `sides`, then execs the stock
`ipp` backend with `DEVICE_URI=ipp://127.0.0.1:9123/` (override with
`PAPERCUT_RELAY_URI`). Explicit `-o print-color-mode=...`, `-o media=...` and
`-o sides=...` still win. The stock `ipp` backend alone cannot do this under
CUPS 2.4.10 because it skips its PPD mapping when the server's
`media-col-supported` is a collection rather than the keyword list CUPS
expects. See `PROTOCOL3.md` §8.4.3.

`papercut-relay.ppd` passes `cupstestppd` and exposes exactly the values the
queue honours through the wrapper: PageSize A4/A3, ColorModel RGB/Gray,
Duplex None/DuplexNoTumble, defaulting to A4, monochrome and 1-sided. It does
**not** replace the wrapper: a PPD can only name PPD options, and CUPS is the
layer that skips translating those names to IPP for this server.

The Konica Minolta `KMbeuC750iux`/`KMbeuC751iux` PPDs are multi-model
C750i/C751i-series drivers that include `*Model C451i` (the default is
`C750i`), so they are applicable to the 451i. They are direct-print
PostScript/PJL drivers: `*cupsFilter` invokes `KMbeuEmpPS.pl`, which wraps the
PostScript stream in a `@PJL` header/footer and adds KM job control. The
capture advertises only `application/pdf` and `image/urf` for the Mobility
Print queue, so the unmodified vendor PPD is **not** compatible with the
PDF/URF path by default.

The filter does not set paper size, duplex or color; those are PPD
`setpagedevice` snippets in the PostScript stream, and the filter passes them
through. If a test job through the relay printed A3 correctly, the endpoint is
accepting the vendor PJL/PostScript stream despite the advertised formats.
Check the relay journal for the job's `document-format` (for example
`application/postscript` or `application/octet-stream`) and
`upstream result: HTTP 200 OK, IPP successful-ok`. In that case the unmodified
vendor PPD + KM filter is the simplest path for applications and vendor
features; no mapping backend is needed. If the endpoint rejects PS/PJL, or you
want the standard PDF/IPP path, use the generated relay PPD + mapping backend
below.

For the standard PDF/IPP path with the vendor C451i option set, generate a
relay-compatible variant of the vendor PPD (C451i selected, `KMbeuEmpPS.pl`
replaced by the PDF/URF pass-through lines) and use it with the mapping
backend. For a C451i prefer
`KMbeuC750iux.ppd`: both PPDs expose the same A3/color/duplex options, but the
C750i PPD is the later BEU v1.2 revision and its C451i constraints explicitly
exclude the C750i-series FS-536/PK-520 accessories. The C751i PPD adds the
newer C751i/C651i/C551i models and FS-542/PK-527/PFP13T234 accessories, which
are not relevant to a C451i (FS-542/PK-527 are not even constrained off for
C451i there).

```bash
python3 -I make_relay_ppd.py KMbeuC750iux.ppd -o KMbeuC451i-relay.ppd
sudo install -m 0644 KMbeuC451i-relay.ppd /usr/share/ppd/
sudo lpadmin -p PaperCut-FollowMe \
    -P /usr/share/ppd/KMbeuC451i-relay.ppd \
    -v papercut-relay:/ \
    -o Model=C451i
```

Only color, paper size and duplex are mapped to Mobility Print IPP attributes;
vendor-only options such as trays, media type and finishing may appear in the
UI but are not advertised by the Mobility Print queue and will not take
effect. Use the server's generic PPD (with the same wrapper) if you prefer a
minimal, honest option set.

### 5. Print from the host when the queue runs in an LXC container

If host CUPS cannot reach the container over TCP (the container is on the
work WiFi and the host is not), install `lxc_papercut_backend.py` as a CUPS
backend. It runs the container's `lp` through `lxc-attach` and feeds the
spool file on stdin, so the container's CUPS queue and relay do the JWT work;
nothing has to listen on the work network.

```bash
sudo install -m 0755 lxc_papercut_backend.py \
    /usr/lib/cups/backend/lxc-papercut
sudo systemctl restart cups

sudo lpadmin -p Papercut-LXC -E \
    -v lxc-papercut://CONTAINER/Papercut-Default \
    -P /usr/share/ppd/papercut-relay.ppd \
    -o printer-is-shared=false -o auth-info-required=none
sudo cupsenable Papercut-LXC
sudo cupsaccept Papercut-LXC

lp -d Papercut-LXC -o media=A3 -o print-color-mode=color \
    -o sides=two-sided-long-edge document.pdf
```

Replace `CONTAINER` with the LXC container name and `Papercut-Default` with
the queue inside it. The backend maps `PageSize`/`ColorModel`/`Duplex` (and
the KM `SelectColor`/`KMDuplex`) to the IPP names, so the same PPD choices
work from print dialogs; explicit `-o print-color-mode=...`, `-o media=...`
and `-o sides=...` still win. CUPS must run the backend as root so
`lxc-attach` can enter the container; if the CUPS service is sandboxed
against that, the CUPS error log shows the `lxc-attach` error. The Printer
JWT stays in the container.

The URI host is the container name and the URI path is the queue name;
`LXC_PAPERCUT_CONTAINER`, `LXC_PAPERCUT_QUEUE`, `LXC_PAPERCUT_ATTACH`,
`LXC_PAPERCUT_LP` and `LXC_PAPERCUT_TIMEOUT` override them for unusual names
or for testing. The container's `lp` is `/usr/bin/lp` unless
`LXC_PAPERCUT_LP` says otherwise.

### 6. Extract the Printer JWT and print a PDF

```bash
# The Printer JWT is the <200-char JWT with exp/username claims.
python3 -I -c 'import json; d=json.load(open("tokens.json")); \
print(next(t["value"] for t in d["tokens"] \
if t["value"].count(".")==2 and len(t["value"])<200))' > printer.jwt
chmod 600 printer.jwt

python3 -I print_pdf_jwt.py document.pdf --jwt-file printer.jwt
python3 -I print_pdf_jwt.py document.pdf --jwt-file printer.jwt --method create-send
```

The print tool talks directly to the Mobility Print IPP endpoint and needs no
session token, Microsoft token or CUPS. See `PROTOCOL3.md` for making CUPS do
the same through a backend helper.

---

## Protocol in one paragraph

The client fetches `/deploy/config`, starts a Microsoft OAuth code flow with a
loopback callback on `localhost:9176`, exchanges the code at
`/deploy/oauth/user` for a PaperCut session token plus Microsoft access/refresh
tokens, lists printers with the session token, refreshes server-side, and uses
HTTP Basic `upn:access_token` at `/token` to mint a 141-char HS256 **Printer
JWT** (claims `exp` + `username`, 365-day lifetime in the capture). The JWT
then resolves a per-user queue URL via `/printer-url`, authorizes the static
IPP capabilities and the job operations (driver downloads themselves are
unauthenticated), and finally authenticates `Create-Job` + `Send-Document`
(or one `Print-Job`) carrying the PDF. The
Microsoft access token lives ~74 minutes; the Printer JWT is the long-lived
printing credential.

---

## Environment

- Python **3.8+** (tested on 3.11), standard library only.
- Live steps need network access to the Print Deploy server
  (`printdeploy.asg.nl:9174`) and the Mobility Print server
  (`172.25.1.14:9164`), plus a browser on the same host for the `localhost:9176`
  OAuth callback.
- The tools disable TLS verification by default (PaperCut certificate);
  use `--verify` or `--cacert` when possible.
- The CUPS section in `PROTOCOL3.md` assumes CUPS 2.x and a standard backend
  directory; the backend helper is documentation, not an installed service.
- `ipp_relay.py` needs Python 3.8+ only; the systemd unit assumes Python 3 at
  `/usr/bin/python3`, the script under `/usr/local/lib/papercut/`, and the
  queue YAML at `/etc/papercut/queue.yaml`.

---

## Security notes

- The capture contains bearer credentials. Do not commit or share the dump,
  `tokens.json`, `printer.jwt`, or screenshots containing token values.
- Keep the Printer JWT in a `0600` file; pass it via `--jwt-file` (or stdin) so
  it does not appear in shell history or process arguments.
- The tools mask token values in their normal output; `--json` from
  `extract_tokens.py` intentionally prints full values.
- The Printer JWT is a user-level credential valid for up to 365 days in the
  capture and is not bound to a printer; treat it like a password.
- The per-user queue URL returned by `/printer-url` contains a 64-hex secret.
- The queue config written by `acquire_queue_info.py` embeds the Printer JWT
  and the per-user queue URL. Keep it mode `0600` and readable only by the
  relay user; do not put it in a world-readable directory.
- `ipp_relay.py` holds the JWT in memory and injects it into every upstream
  request. Keep it on loopback: it refuses non-loopback bind addresses unless
  `--allow-non-loopback` is given. It never logs the JWT or the per-user queue
  URL, and the health page contains only non-secret status.
- Test only against systems you own or are authorized to test.

---

## Status and known limitations

- **No live server traffic was sent during development.** Validation was local:
  step functions against captured response shapes, the full flow over a PTY
  with a stub server, the OAuth listener and failure paths in-process, the IPP
  submitter against a local IPP stub, and the CUPS backend script with
  `sh -n` only.
- **A3 option tests were run live on 2026-11-11** (`a3_option_tests.py`,
  queue `Papercut-Default`, default `Follow-Me.ppd`, no mapping backend).
  Verdicts came from the release-device display and the jobs were cancelled,
  not printed: `media=A3`, `media-col={media-size={x-dimension=29700
  y-dimension=42000}}` and that collection plus `media-size-name=iso_a3`
  showed A3; no media option, `media=iso_a3`, `media=iso_a3_297x420mm`,
  `PageSize=A3` and `PageSize=A3` with `media=iso_a3` did not. The test page
  itself is A3, so the document size alone does not select A3. The results are
  in `results-a3-portrait.txt`; whether CUPS or the Mobility Print server
  discarded the PWG names was not determined from the display.
- **Color option tests were run live on 2026-11-11** (`color_option_tests.py`,
  same queue and PPD, `media=A4` pinned, jobs cancelled). The queue default is
  monochrome; `print-color-mode=color` and `print-color-mode=auto` showed
  Color, `print-color-mode=monochrome` showed Mono, and `ColorModel=RGB/CMYK`
  did not produce color. `print-color-mode=color` won over `ColorModel=Gray`.
  The answers were given as y/n and are stored in the notes of
  `results-a4-color.txt`.
- **Duplex option tests were run live on 2026-11-11**
  (`duplex_option_tests.py`, same queue and PPD, `media=A4` pinned, two-page
  A4 source, jobs cancelled). The queue default is one-sided;
  `sides=two-sided-long-edge` and `sides=two-sided-short-edge` were honoured,
  while `Duplex=DuplexNoTumble`, `Duplex=DuplexTumble`, `Duplex=None` and
  `KMDuplex=2Sided` all left the job one-sided. `sides=two-sided-long-edge`
  won over `Duplex=DuplexTumble`. Results in `results-duplex-a4.txt`.
- **Combined option stress test** (2026-11-11, `combined_option_tests.py`):
  all 16 jobs (A4/A3 × 1-sided/2-sided long edge × color/monochrome × 2
  repeats) showed the expected paper size, binding and color mode on the
  release-device display. The attributes combine without interference; the
  jobs were cancelled, so this confirms acceptance, not physical rendering.
  Results in `results-stress-combined.txt`.
- `lxc_papercut_backend.py` was tested with a stub `lxc-attach` (option
  mapping, argument construction, stdin spooling, discovery and error paths),
  not against a live LXC container or a live host CUPS. It needs CUPS to run
  backends as root and to be allowed to use `lxc-attach`.
- `acquire_queue_info.py` was validated against a local HTTP stub: the happy
  path and generated YAML, atomic mode-0600 writes, an unknown printer (no
  file written), and the `/deploy/config` fallback when `connection.name` is
  absent. It has not been run against the live servers.
- `printdeploy_client.py` performs a fresh login; it does not replay the
  captured OAuth code (single-use) or reuse the captured session token.
- Driver `sha256` values are advisory by default, matching the captured
  client's observed behaviour; use `--verify-sha256` to enforce them.
- `print_pdf_jwt.py` submits one copy and does not map CUPS options (media,
  color, duplex, copies) to IPP attributes; the captured job used printer
  defaults.
- The option-B CUPS backend helper in `PROTOCOL3.md` is not installed or
  tested against a live CUPS. The option-C loopback relay is implemented in
  `ipp_relay.py`; it was tested against a stub upstream and raw HTTP clients
  (Content-Length, chunked bodies, `Expect: 100-continue`, health page,
  rejection paths, and JWT/queue-secret absence in logs), but not against a
  live CUPS instance or the live Mobility Print server.
- `papercut_relay_backend.py` was tested by replaying the captured PPD and
  printer capabilities through the stock CUPS `ipp` backend: PPD
  `ColorModel`/`PageSize`/`Duplex` and KM `SelectColor`/`KMDuplex` options
  became the expected `print-color-mode`/`media-col`/`sides` attributes, and
  explicit IPP options took precedence. It has not been installed in a live
  CUPS queue.
- `make_relay_ppd.py` generated C451i variants of both vendor PPDs; the filter
  line was replaced and `cupsfilter --list-filters` shows only `gziptoany` for
  PDF, so the KM filter is no longer in the chain. `cupstestppd` reports a
  pre-existing duplicate `KMAuthentication` choice and mixed line endings in
  the vendor PPD; the derived PPD inherits those validation warnings.
- The Konica Minolta `KMbeuC750iux.ppd`/`KMbeuC751iux.ppd` and
  `KMbeuEmpPS.pl` in this directory are multi-model C750i/C751i-series
  PostScript/PJL drivers that include `*Model C451i` (default `C750i`). They
  are applicable to the 451i but are direct-print drivers; they are not used by
  the relay tooling and are not part of the toolkit.
- The captured PaperCut session-token lifetime is encrypted and unknown; the
  observed `/deploy/oauth/token` refresh was a no-op while the access token was
  still valid.
- Capture-specific names/IPs (`printdeploy.asg.nl`, `172.25.1.14`, `Follow-Me`,
  the `mac/26.6-arm64` driver tree) are defaults, not guarantees.

---

## Keeping this README up to date

When adding, removing, renaming or splitting a file in this directory:

1. Update the **Files** table and the **Directory layout** tree.
2. Update **Quick start** if the file is user-facing or a command changed.
3. Update **Status and known limitations** if tests, scope or behaviour changed.
4. Keep the **Last updated** date current.
5. Never add token values, UPNs, per-user URL hashes or other secrets.

This README is the single index for the directory; the three `PROTOCOL*`
documents are the authoritative protocol references.
