# PROTOCOL3 — Sending a print job with the Printer JWT

This document continues [PROTOCOL2](PROTOCOL2.txt). It documents how to submit
an actual print job to the PaperCut Mobility Print queue using only the
**Printer JWT**, and how to make CUPS perform the same steps.

- **Prerequisite:** the Printer JWT and queue endpoint from PROTOCOL1/PROTOCOL2.
- **Reference implementation:** `print_pdf_jwt.py` in this directory.
- **Capture provenance:** `convo6_reallyfull.dump`, flows 17–32,
  2026-11-10 07:51:04–07:51:06 UTC, printer `Follow-Me`, job-id 21.
- Secrets (JWT, UPN, per-user URL hash) are placeholders here.

---

## 1. Scope

Covered:

- IPP endpoint, authentication and headers for job submission.
- IPP encoding essentials (operation, attribute groups, tags, document data).
- `Create-Job` (0x0005) + `Send-Document` (0x0006), the method used by the
  captured Print Deploy client.
- `Print-Job` (0x0002), the single-request alternative advertised by the server.
- Job-id handling, response parsing, job-state polling, and job states.
- Error status handling.
- Making CUPS submit the job: a small CUPS backend helper is required because
  CUPS cannot inject a Bearer token; an alternative loopback IPP relay is
  described.

Not covered: the printer's physical release workflow (badge/card release on a
Follow-Me queue), PaperCut server administration, or the Microsoft/session
credential chain (PROTOCOL1).

---

## 2. Endpoint, authentication, headers

All job operations go to the same IPP endpoint:

```
https://172.25.1.14:9164/ipp/172.25.1.14_9164/466f6c6c6f772d4d65/printers/Follow-Me
```

Derived as:

```
/ipp/{host}_{port}/{hex(UTF-8 printer name)}/printers/{printer name}
hex("Follow-Me") = 466f6c6c6f772d4d65
```

Request headers observed in the capture (flows 20, 21, 23, …):

```
POST /ipp/172.25.1.14_9164/466f6c6c6f772d4d65/printers/Follow-Me HTTP/1.1
Host: 172.25.1.14:9164
User-Agent: Print Deploy Client/1.10.3838/Debian
Authorization: Bearer <Printer JWT>
Content-Type: application/ipp
X-Forwarded-For: 127.0.0.1
Date: <HTTP date>
Accept-Encoding: gzip
```

Body framing:

- `Create-Job`, `Get-Printer-Attributes`, `Get-Job-Attributes`: normal
  `Content-Length`.
- `Send-Document`: the captured client used `Transfer-Encoding: chunked` and no
  `Content-Length`. A `Content-Length` body works too; the reference tool uses
  one.
- The document bytes follow the IPP attributes directly (no multipart, no
  base64).

The **only** credential is the Printer JWT. No PaperCut session token, no
Microsoft access token, and no cookies are sent for these operations.

TLS: the captured client talks to a PaperCut-issued certificate. The reference
tool disables verification by default and supports `--verify` / `--cacert`.

---

## 3. IPP encoding essentials

IPP is a binary TLV protocol. Every request begins with an 8-byte header:

```
offset  size  field
0       1     major version   (0x02)
1       1     minor version   (0x00)  -> IPP/2.0
2       2     operation-id    (big endian)
4       4     request-id      (big endian, client-chosen)
8       ...   attribute groups, then optional document data
```

Attribute groups are separated by one-byte delimiter tags:

```
0x01  operation-attributes-tag
0x02  job-attributes-tag
0x03  end-of-attributes-tag
```

Each attribute is:

```
1 byte   value-tag
2 bytes  name length
N bytes  name (ASCII, case-insensitive)
2 bytes  value length
M bytes  value
```

Value tags used in this protocol:

```
0x21  integer            4-byte big endian
0x22  boolean            1 byte: 0x01 true / 0x00 false
0x41  textWithoutLanguage
0x42  nameWithoutLanguage
0x44  keyword
0x45  uri
0x47  charset
0x48  naturalLanguage
0x49  mimeMediaType
```

Multiple values of the same attribute may repeat the name, or use a zero-length
name for all values after the first. The captured client uses the zero-length
form for `requested-attributes`.

End-of-attributes (`0x03`) is followed immediately by the document data for
`Print-Job` and `Send-Document`, and by nothing for the other operations.

Minimal encoder:

```python
def attr(tag, name, value: bytes):
    n = name.encode("ascii")
    return bytes([tag]) + len(n).to_bytes(2, "big") + n \
         + len(value).to_bytes(2, "big") + value
```

---

## 4. Job submission patterns

A client may first validate the same job template with Validate-Job (0x0004);
that operation creates no job and is documented in PROTOCOL2. The two ways to
actually submit are below.

### 4.1 Create-Job + Send-Document (the captured method)

This is a two-request job: create a job object, then attach and finish the
document with `Send-Document`.

**Request 1 — Create-Job (operation 0x0005, request-id 4 in the capture):**

```
header: 02 00 00 05 00 00 00 04
0x01 (operation group)
  0x47 attributes-charset            utf-8
  0x48 attributes-natural-language   en-us
  0x45 printer-uri                   <printer URI, see note>
  0x42 requesting-user-name          anonymous
  0x42 job-name                      Test Page
0x02 (job group)
  0x42 job-originating-host-name     localhost
  0x45 job-uuid                      urn:uuid:99ca6116-d724-3b05-5a8b-b054f9f65c99
0x03 (end)
```

In the capture `printer-uri` is
`http://127.0.0.1:9177/ipp/172.25.1.14_9164/466f6c6c6f772d4d65/printers/Follow-Me`
(the client's local CUPS/proxy URI). The HTTP request itself goes to the
mobility server on 9164. For direct submission, the derived endpoint URI can be
used; the server routes on the request path.

Response (200, `application/ipp`, 224 bytes):

```
IPP status 0x0000 successful-ok
job-id      21
job-uri     http://127.0.0.1:9177/ipp/.../jobs/21
job-state   3 (pending)
job-state-reasons  job-incoming
```

The client must remember `job-id` (and optionally `job-uri`).

**Request 2 — Send-Document (operation 0x0006, request-id 5):**

```
header: 02 00 00 06 00 00 00 05
0x01 (operation group; everything is in this group in the capture)
  0x47 attributes-charset            utf-8
  0x48 attributes-natural-language   en-us
  0x45 printer-uri                   <same printer URI>
  0x21 job-id                        21
  0x42 requesting-user-name          anonymous
  0x22 last-document                 true
  0x49 document-format               application/pdf
0x03 (end)
<PDF bytes>
```

Response (200, 216 bytes):

```
IPP status 0x0000 successful-ok
job-id      21
job-uri     http://127.0.0.1:9177/ipp/.../jobs/21
job-state   3 (pending)
job-state-reasons  none
```

The captured document was a 215,901-byte PDF ("Test Page"); the whole
Send-Document request was 216,175 bytes (274 bytes of attributes + document).

### 4.2 Print-Job (single request)

The captured server advertises `Print-Job` (0x0002) in
`operations-supported`, so the document can be submitted in one request:

```
header: 02 00 00 02 00 00 00 01
0x01 (operation group; putting every attribute here is accepted by
     CUPS-family servers)
  0x47 attributes-charset            utf-8
  0x48 attributes-natural-language   en-us
  0x45 printer-uri                   <printer URI>
  0x42 requesting-user-name          anonymous
  0x42 job-name                      document.pdf
  0x49 document-format               application/pdf
0x03 (end)
<PDF bytes>
```

The response contains the assigned `job-id`, `job-uri` and initial `job-state`,
exactly like Create-Job. `print_pdf_jwt.py` uses this method by default
(`--method print-job`); `--method create-send` implements the captured
two-step flow. Both were validated against a local IPP stub.

### 4.3 Job attributes worth setting

| Attribute | Tag | Value in capture |
|---|---|---|
| `job-name` | 0x42 | `Test Page` (or the PDF file name) |
| `requesting-user-name` | 0x42 | `anonymous` |
| `job-originating-host-name` | 0x42 | `localhost` |
| `job-uuid` | 0x45 | `urn:uuid:99ca6116-d724-3b05-5a8b-b054f9f65c99` |
| `document-format` | 0x49 | `application/pdf` |
| `copies` | 0x21 | not set in capture (server default); supported range 1–999 |
| `print-color-mode` | 0x44 | not set; server supports `color`, `monochrome` |
| `sides` | 0x44 | not returned by this server; do not assume duplex |

The captured job used printer defaults. The server's static capabilities
(PROTOCOL2) say PDF and URF are supported, compression is `none`, copies are
1–999, and color modes are color/monochrome. No `media-supported` or
`sides-supported` attribute was returned by this instance; the `media-col`
collection carries media sizes.

---

## 5. Observed exchange and timing

Timeline from the capture (UTC, 2026-11-10), job-id 21:

```
07:51:04.148  Create-Job            req 4   -> job-id 21, state 3, reason job-incoming
07:51:04.189  Send-Document         req 5   -> state 3, reason none (215,901-byte PDF)
07:51:04.281  Get-Printer-Attributes req 6  -> printer-state 3, queued-job-count 0
07:51:04.305  Get-Job-Attributes    req 7   -> state 3, reason job-incoming, impressions 0
07:51:05.159  Get-Printer-Attributes req 8
07:51:05.183  Get-Job-Attributes    req 9   -> state 3, reason job-incoming
07:51:05.349  Get-Printer-Attributes req 10
07:51:05.371  Get-Job-Attributes    req 11  -> state 3, reason job-incoming
07:51:06.226  Get-Printer-Attributes req 12
07:51:06.249  Get-Job-Attributes    req 13  -> state 9, job-completed-with-errors,
                                                impressions 2, media sheets 2
07:51:06.403  Get-Printer-Attributes req 14
07:51:06.440  Get-Job-Attributes    req 15  -> state 9, job-completed-with-errors
07:51:06.481  Get-Printer-Attributes req 16
```

Observations:

- The job object becomes available immediately after Create-Job with
  `job-state=3` (pending) and `job-state-reasons=job-incoming`.
- After Send-Document completes, the reasons become `none` while the state
  remains pending.
- The client interleaves **Get-Printer-Attributes** (even request-ids) with
  **Get-Job-Attributes** (odd request-ids), polling roughly once per second,
  then settling after the job reaches a terminal state.
- The captured job reached `job-state=9` (completed) with
  `job-state-reasons=job-completed-with-errors` and
  `job-impressions-completed=2`, `job-media-sheets-completed=2` in about
  2.1 seconds. The capture environment's "errors" reason may not indicate a
  real print failure; it is the server's reported reason.
- `printer-state` stayed 3 (idle) and `queued-job-count` 0 throughout; do not
  rely on those to track an individual job.
- This was a Follow-Me queue. A real deployment may hold the job until release
  at the device; the protocol is the same, only the state/reasons differ.

Get-Job-Attributes request attributes (operation 0x0009):

```
0x01
  0x47 attributes-charset            utf-8
  0x48 attributes-natural-language   en-us
  0x45 printer-uri                   <printer URI>
  0x21 job-id                        21
  0x42 requesting-user-name          anonymous
  0x44 requested-attributes          job-id
  0x44                               job-impressions-completed
  0x44                               job-media-sheets-completed
  0x44                               job-name
  0x44                               job-originating-user-name
  0x44                               job-state
  0x44                               job-state-reasons
0x03
```

`requested-attributes` is multi-valued: the capture repeats the tag with a
zero-length name for all values after the first.

Job-state values:

```
3  pending
4  pending-held
5  processing
6  processing-stopped
7  canceled
8  aborted
9  completed
```

Terminal states are 7, 8, 9. Stop polling there; use a timeout for held jobs.

---

## 6. Polling strategy

A practical client:

1. Create the job and record `job-id`.
2. Send the document.
3. Poll `Get-Job-Attributes` every 1 s until `job-state` is terminal, or until a
   configured timeout (e.g. 60 s). Do not treat `pending-held` as an error; it
   is normal for Follow-Me queues.
4. Optionally fetch `Get-Printer-Attributes` between polls, as the captured
   client does, to refresh printer state.
5. If needed, cancel with `Cancel-Job` (0x0008) using the same `job-id`.

The supply of request IDs is client-local; the capture uses 1…16 across the
operations. Request-id is echoed in the response and is not a job ID.

`Get-Jobs` (0x000A) can list jobs, and `Cancel-Job` (0x0008) is advertised. Both
use the same endpoint and Bearer JWT.

---

## 7. Error handling

IPP responses carry an HTTP status (normally 200) and an IPP status code in
bytes 2–3. Check the IPP status, not just HTTP:

```
0x0000  successful-ok
0x0001  successful-ok-ignored-or-substituted-attributes
0x0002  successful-ok-conflicting-attributes
0x0400  client-error-bad-request
0x0401  client-error-forbidden
0x0402  client-error-not-authenticated
0x0403  client-error-not-authorized
0x0404  client-error-not-possible
0x0405  client-error-timeout
0x0406  client-error-not-found
0x0407  client-error-gone
0x0408  client-error-request-entity-too-large
0x040A  client-error-document-format-not-supported
0x040B  client-error-attributes-or-values-not-supported
0x0500  server-error-internal-error
0x0501  server-error-operation-not-supported
```

Common causes:

- `0x0403`: expired/invalid Printer JWT, or wrong printer/queue.
- `0x0406`: job-id does not exist (e.g. wrong job, job expired) or bad queue.
- `0x040A`: document-format not supported (use `application/pdf` or
  `image/urf` for this server).
- `0x040B`: an attribute or value the server does not accept.

Treat 0x0000–0x0002 as success. The Printer JWT is valid for a long time
(365 days in the capture), but check its `exp` before sending.

---

## 8. Making CUPS perform these steps

### 8.1 Why a helper is needed

The captured client authenticates IPP with:

```
Authorization: Bearer <Printer JWT>
```

CUPS's built-in `ipp` backend supports Basic/Digest HTTP authentication and
client certificates, but it cannot attach an arbitrary `Authorization: Bearer`
header. The real Print Deploy client solves this by running a **local IPP proxy**
on `127.0.0.1:9177`: CUPS talks to that proxy, and the proxy injects the Bearer
token and forwards to `https://172.25.1.14:9164`. The capture's IPP
`printer-uri` and `job-uri` values point at `127.0.0.1:9177`, which is
consistent with the client running such a proxy.

Therefore at least one small helper is required for CUPS. Two workable designs:

- **Option B (recommended): a CUPS backend wrapper** that hands the spool file
  to `print_pdf_jwt.py`. Small, one-shot, no long-running service.
- **Option C (high fidelity): a loopback IPP relay** that replicates the
  captured 127.0.0.1:9177 proxy. More moving parts, but preserves CUPS's native
  IPP backend and remote job state.

**Option A (no CUPS):** submit directly with `print_pdf_jwt.py`. This is the
simplest way to test the protocol.

### 8.2 Option A — direct submission (no CUPS)

Use the reference tool; it performs the IPP job submission with only the JWT:

```bash
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
```

It preflights the PDF and JWT expiry, sends `Print-Job` (default) or
`Create-Job` + `Send-Document`, parses the IPP response, and reports the job id,
job URI and job state. This is the best first test and is also the engine used
by the CUPS backend below.

### 8.3 Option B — CUPS backend wrapper (recommended)

CUPS backends are small executables in the backend directory (usually
`$(cups-config --serverbin)/backend`, often `/usr/lib/cups/backend` or
`/usr/libexec/cups/backend`). CUPS invokes them as:

```
backend job-id user title copies options [file]
```

with the spool file as argument 6, or on stdin if no file is given (some
versions pass "-"). `DEVICE_URI` is in the environment. Exit 0 means success;
write errors to stderr.

**Step 1 — install the PPD and the JWT**

```bash
# PPD extracted from the driver zip (PROTOCOL2)
sudo install -m 0644 -o root -g root Follow-Me.ppd /usr/share/ppd/Follow-Me.ppd

# Printer JWT, root-readable only; do not put it in argv or shell history
sudo install -m 0600 -o root -g root printer.jwt /etc/papercut/printer.jwt

# The submitter (already in this directory)
sudo install -m 0755 -o root -g root print_pdf_jwt.py /usr/local/lib/papercut/print_pdf_jwt.py
```

**Step 2 — install the backend**

Create `/usr/lib/cups/backend/papercut-jwt` (adjust the backend directory for
your distro) with root ownership and mode 0755:

```sh
#!/bin/sh
# CUPS backend: submit the spool file to PaperCut Mobility Print with a JWT.
set -u
jobid="${1:-}"; user="${2:-}"; title="${3:-}"; copies="${4:-1}"
options="${5:-}"; file="${6:-}"
log=/var/log/papercut-jwt.log

if [ -z "$file" ]; then
    echo "ERROR: papercut-jwt: no spool file" >&2
    exit 1
fi
if [ "$file" = "-" ]; then
    tmp=$(mktemp /tmp/papercut-jwt.XXXXXX.pdf) || exit 1
    trap 'rm -f "$tmp"' EXIT
    cat > "$tmp" || exit 1
    file="$tmp"
fi

{
    printf '%s job=%s user=%s title=%s copies=%s options=%s file=%s\n' \
        "$(date -u +%FT%TZ)" "$jobid" "$user" "$title" "$copies" "$options" "$file"
    /usr/bin/python3 -I /usr/local/lib/papercut/print_pdf_jwt.py \
        --jwt-file /etc/papercut/printer.jwt \
        --server https://172.25.1.14:9164 \
        --printer Follow-Me \
        --requesting-user-name "${user:-anonymous}" \
        --job-name "${title:-cups-$jobid}" \
        "$file"
} >>"$log" 2>&1
status=$?

if [ "$status" -ne 0 ]; then
    echo "ERROR: papercut-jwt submission failed; see $log" >&2
fi
exit "$status"
```

Install it and make it executable:

```bash
sudo install -m 0755 -o root -g root papercut-jwt /usr/lib/cups/backend/papercut-jwt
sudo systemctl restart cups      # CUPS discovers backends at startup
```

Notes:

- The backend must be root-owned and not group/world-writable, or CUPS refuses
  to run it.
- CUPS may run backends as root; if your `cups-files.conf` uses a different
  user, make `/etc/papercut/printer.jwt` readable by that user.
- The helper output goes to `/var/log/papercut-jwt.log`; CUPS sees only the
  exit status and the short stderr message. No JWT is logged.
- The spool file is expected to be PDF (the PPD's filter chain produces PDF for
  this queue). `print_pdf_jwt.py` rejects non-PDF input.
- This wrapper submits one copy and does not map CUPS options (copies, media,
  color, duplex) to IPP attributes. The captured job used printer defaults; for
  full option support, extend the submitter with IPP `copies`, `media`,
  `print-color-mode`, `sides`, etc. (see §4.3).

**Step 3 — create the CUPS queue**

```bash
sudo lpadmin -p PaperCut-FollowMe -E \
    -v papercut-jwt:/ \
    -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
```

The device URI scheme selects the backend; `papercut-jwt:/` is sufficient. The
queue does not authenticate to CUPS itself; the backend injects the JWT
upstream.

**Step 4 — submit and observe**

```bash
lp -d PaperCut-FollowMe /path/document.pdf
lpstat -o PaperCut-FollowMe
sudo tail -f /var/log/papercut-jwt.log
sudo tail -f /var/log/cups/error_log
```

The backend log contains the full `print_pdf_jwt.py` output, including the
remote `job-id`, `job-uri` and `job-state`. CUPS considers the local job done
when the backend exits 0 (i.e. after the remote server accepts the submission).
The remote job may still be pending/held.

### 8.4 Option C — loopback IPP relay (captured architecture)

This reproduces the client's 127.0.0.1:9177 proxy design and keeps CUPS's
native `ipp` backend. The reference implementation is `ipp_relay.py` with the
`papercut-ipp-relay.service` systemd unit; it defaults to `127.0.0.1:9123`
(the captured client's proxy used 9177).

A relay process listens on a loopback port and:

1. accepts `POST` requests with `Content-Type: application/ipp` from CUPS;
2. forwards the body to the **configured** upstream URL from the queue YAML
   (`ipp_endpoint`, normally `https://172.25.1.14:9164/ipp/...`), ignoring the
   incoming request path;
3. adds `Authorization: Bearer <Printer JWT>` and an upstream `Host`;
4. relays the HTTP status, headers and IPP response body back to CUPS;
5. handles `Expect: 100-continue`, chunked request bodies, and TLS to the
   upstream;
6. takes the upstream endpoint, canonical `printer-uri` and bearer token from
   the YAML written by `acquire_queue_info.py` (`ipp_endpoint`, `printer_uri`,
   `printer_jwt`), never from the CUPS request URL. The incoming URL is only
   the local trigger and may be a placeholder such as `papercut-relay:/`.

Reference implementation notes:

- `ipp_relay.py --config /etc/papercut/queue.yaml` validates the config (also
  available as `--check`), logs each request and upstream result to stderr
  (the journal under systemd), and serves a non-secret health page on `GET`.
- It uses the JWT until its `exp`; re-run `acquire_queue_info.py` and restart
  `papercut-ipp-relay` to load a refreshed JWT or a changed queue.
- It refuses non-loopback listen addresses unless `--allow-non-loopback` is
  given. The unit drops all capabilities and runs as root by default so it can
  read the root-owned 0600 YAML; uncomment `User=`/`Group=` and chown the YAML
  to run unprivileged.

CUPS queue (the path is ignored by the relay; any path works):

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

Requirements and caveats:

- Bind only to loopback; the relay holds the JWT in memory.
- Handle 100-continue and chunked bodies explicitly; a naive `http.server`
  handler can deadlock on CUPS uploads.
- Do not follow redirects or leak the JWT to another host; pin the upstream
  host from the config.
- The captured client's proxy also rewrote `printer-uri` to the local proxy
  URI. The mobility server accepted that local `printer-uri` in the capture,
  but a CUPS queue whose device URI is a placeholder such as
  `papercut-relay:/` makes the stock `ipp` backend emit a bare
  `ipp://127.0.0.1:9123/`, which the server rejects for the queue lookup (the
  backend then retries Get-Printer-Attributes and reports "Unable to get
  printer status"). `ipp_relay.py` therefore rewrites the `printer-uri`
  operation attribute to the canonical `printer_uri` from the queue YAML
  before forwarding. Responses, including `job-uri`, are relayed unchanged.
- The per-user `queue_url` from `/printer-url` is not used by the relay; the
  relay posts to the 9164 `ipp_endpoint`.
- Unlike the backend wrapper, the relay lets CUPS see the remote job
  ID/state, because responses pass through.

### 8.4.1 Do you need the PaperCut CUPS filter or backend?

No, not for the relay design above. The CUPS queue uses the stock `ipp`
backend to reach the relay, and the relay injects the Bearer token. The PPD
delivered by `/deploy/printers` is a generic AirPrint PPD whose filter entries
are pass-through:

```
*cupsFilter2: "application/vnd.cups-pdf application/pdf 0 maxsize(0) -"
*cupsFilter:  "application/vnd.cups-pdf 0 -"
*cupsFilter2: "image/urf image/urf 10 -"
*cupsFilter:  "image/urf 10 -"
```

The `-` is the filter program: no filter, send the data unchanged. Stock
`cups-filters` supplies the rest (`application/pdf` → `application/vnd.cups-pdf`
via `pdftopdf`, `text/plain` → PDF via `texttopdf`, and so on), and the upstream
queue advertises `application/pdf` and `image/urf`. Checked locally with
`cupsfilter` against the captured `Follow-Me.ppd`: a PDF job runs only
`gziptoany`, and a `text/plain` job runs `texttopdf`; no PaperCut filter is
involved.

The `pc-print-deploy-client*.deb` is not a filter package; it is the whole
Print Deploy client. Its relevant pieces are:

- `print-deploy-client` (Go) contains `mobilitylib/ippproxy`, a local IPP proxy
  server — the same role as `ipp_relay.py` (the capture's `127.0.0.1:9177`).
- `direct-print-monitor/.../cups-print-provider` is installed as the CUPS
  backend `papercut`, and `cups-filter-print-provider` as the CUPS filter
  `papercut` (see `roottasks`). `configure-cups` inserts the filter into PPDs
  (`*cupsFilter: "... 0 papercut"`) and the backend is used through
  `DeviceURI papercut:/...`.
- Both binaries contain `Authorization: Bearer %s`, but they are the Direct
  Print Monitor components: they expect the Print Provider configuration and
  master token and are aimed at PaperCut NG/MF direct printing/accounting. The
  package also creates a `papercut` user, installs
  `pc-print-deploy-client.service` and an event monitor, and modifies CUPS
  configuration/queues. It contains no PPD; the PPD comes from the server's
  driver zip.

Do not install the `.deb` just to obtain a filter, and do not let both the real
client and this relay manage the same CUPS queue. If CUPS cannot convert a
particular input format, install `cups-filters` or add the specific filter —
not the PaperCut client package. Diagnose with
`/var/log/cups/error_log` and `journalctl -u papercut-ipp-relay`.

### 8.4.2 Color, A3 and PPD option names

The CUPS `ipp` backend sends options essentially verbatim to the relay, so the
option names matter. Use IPP names, and for A3 use `media=A3` or the media-col
collection:

```bash
lp -d PaperCut-FollowMe -o print-color-mode=color -o media=A3 document.pdf
```

- `print-color-mode=color` is sent as the IPP keyword `print-color-mode=color`
  in the job-attributes group. The captured server advertises
  `print-color-mode-supported: color, monochrome`. In the 2026-11-11 color test
  the queue default was monochrome; `print-color-mode=color` and `auto`
  produced color, `monochrome` produced mono, and `ColorModel=RGB/CMYK/Gray`
  had no effect (`PDDOPTIONS.md` §5.6).
- The capture requested `media-col-supported` but not `media-supported`, so A3
  is definitely present as a `media-col` size of 29700 x 42000 (hundredths of a
  millimetre). In the 2026-11-11 test `media=A3` selected A3 on the
  release-device display, while `media=iso_a3` and `media=iso_a3_297x420mm` did
  not (see `PDDOPTIONS.md` §5.6). The collection form is the portable choice:

```bash
lp -d PaperCut-FollowMe -o print-color-mode=color \
    -o 'media-col={media-size={x-dimension=29700 y-dimension=42000}}' document.pdf
```

The CUPS `ipp` backend encodes that as a `media-col` collection (verified
against the captured PPD); the dimensions match the captured
`media-col-supported`.

For duplex, the IPP attribute is `sides`, with values `one-sided`,
`two-sided-long-edge` (portrait) and `two-sided-short-edge` (landscape):

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

The capture's `Get-Printer-Attributes` request did not ask for
`sides-supported`, so the capture does not establish whether the queue
supports duplex. In the 2026-11-11 duplex test both `sides=two-sided-long-edge`
and `sides=two-sided-short-edge` were honoured, while the queue default was
one-sided and `Duplex=...`/`KMDuplex=2Sided` had no effect (`PDDOPTIONS.md`
§5.6). The server-provided PPD does contain `Duplex` options, and the backend
forwards `sides=...`; check the relay log if a job is still one-sided. If the
log shows `sides=two-sided-long-edge` but the job is one-sided, the release
device or the queue does not support duplex.

Do **not** use the PPD option names `ColorModel=RGB`, `PageSize=A3` or
`Duplex=DuplexNoTumble`: the backend forwards them as literal `ColorModel`,
`PageSize` or `Duplex` attributes (`nameWithoutLanguage`), which Mobility
Print does not use for the IPP job, so it falls back to its defaults. The
generic AirPrint PPD has no `cupsUIResolver` entries to translate them. The
same applies to selections made in the CUPS web interface, which exposes the
PPD options.

`media=A3` is forwarded as the non-standard keyword `A3`; this instance honours
it, while the standard PWG name `iso_a3` was not honoured in testing. Prefer
the `media-col` form when portability matters.

A 16-job combined test on 2026-11-11 (A4/A3, 1-sided/2-sided long edge,
color/monochrome, two repeats) matched on every job's release display, so the
three attributes combine without interference (`PDDOPTIONS.md` §5.6).

To confirm what CUPS actually sent, watch the relay journal. Each request now
logs the relevant job attributes:

```
IPP request: Print-Job request-id=1 media=A3 print-color-mode=color sides=two-sided-long-edge
```

For a `media-col` job it logs the extracted dimensions, for example
`media-col=297x420mm`. If the relay shows the correct attributes but the
printer still uses mono/A4 or one-sided, check the queue defaults
(`lpoptions -p PaperCut-FollowMe -l`), the PPD on the queue
(`/etc/cups/ppd/PaperCut-FollowMe.ppd`), and the physical device the Follow-Me
job is released on (the server's capabilities are not a guarantee for every
release printer).

### 8.4.3 Making CUPS UI/application PPD options work

With CUPS 2.4.10 (the version tested here), the stock `ipp` backend normally
converts PPD options to IPP attributes with `_cupsConvertOptions()`. It only
does that when it can build the PPD cache, which requires `media_col_sup` to be
found. The Mobility Print server encodes `media-col-supported` as a collection,
while the CUPS backend looks it up as a keyword list (`IPP_TAG_KEYWORD`), so
the lookup fails and the backend takes the fallback path: it sends the PPD
option names verbatim. That is why
`ColorModel=RGB`, `PageSize=A3` and `Duplex=...` reach the server as literal
attributes and are ignored, while `print-color-mode`, `media` and `sides`
work.

`papercut_relay_backend.py` is a small CUPS backend wrapper that fixes this
before the stock backend runs. Install it under the scheme name 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:/
```

The queue can keep the server's generic PPD or use the minimal
`papercut-relay.ppd` (PageSize A4/A3, ColorModel RGB/Gray, Duplex
None/DuplexNoTumble, defaults A4/mono/1-sided; passes `cupstestppd`). A PPD
alone cannot replace the wrapper: it only names PPD options, and this CUPS
version is what fails to translate those names to IPP for this server. The
queue keeps PDF/URF pass-through either way. The wrapper maps:

| PPD option/value | IPP attribute |
|---|---|
| `ColorModel=RGB/CMYK/CMY` | `print-color-mode=color` |
| `ColorModel=Gray/FastGray/DeviceGray` | `print-color-mode=monochrome` |
| `SelectColor=Color` | `print-color-mode=color` |
| `SelectColor=Grayscale` | `print-color-mode=monochrome` |
| `SelectColor=Auto` | left to the printer default |
| `PageSize=<PPD size>` | `media-col` with the size's dimensions |
| `PageSize=<W>x<H>mm` | `media-col` with those dimensions |
| `Duplex=None` / `1Sided` | `sides=one-sided` |
| `Duplex=DuplexNoTumble` / `2Sided` | `sides=two-sided-long-edge` |
| `Duplex=DuplexTumble` | `sides=two-sided-short-edge` |
| `KMDuplex=2Sided` + `Binding=TopBinding` | `sides=two-sided-short-edge` |

Common sizes (A3/A4/A5/A6/Letter/Legal/Tabloid/Executive/Statement) use the
canonical PWG dimensions in hundredths of a millimetre, e.g. A3 becomes
`media-col={media-size={x-dimension=29700 y-dimension=42000}}`. Other PPD
sizes are read from the PPD's `*PaperDimension` values and converted from
points. Explicit `print-color-mode`, `media`, `media-col` and `sides` options
are left untouched and win over the PPD names. The wrapper then sets
`DEVICE_URI` to the relay (`ipp://127.0.0.1:9123/` by default, override with
`PAPERCUT_RELAY_URI`) and execs the stock `ipp` backend, so the relay still
injects the Printer JWT and the rest of the IPP path is unchanged.

With this installed, print dialogs and the CUPS web interface can set the PPD
options normally; the relay journal should show the resulting
`print-color-mode` / `media-col` / `sides` attributes.

This wrapper is a mapping shim for the generic AirPrint PPD. It is not a
replacement for the Konica Minolta PostScript driver:

- `KMbeuC750iux.ppd` and `KMbeuC751iux.ppd` are multi-model
  C750i/C751i-series PostScript PPDs. They include `*Model C451i/C451i` and
  the C451i `*UIConstraints` entries, so they are applicable to the 451i, but
  the default is `*DefaultModel: C750i`; select `Model=C451i` for a 451i.
  They use `*cupsFilter: "application/vnd.cups-postscript 0 KMbeuEmpPS.pl"`.
  The KM filter does **not** parse or rewrite `PageSize`, `PageRegion`,
  `KMDuplex`, `SelectColor`, `InputSlot` or `MediaType`: paper size, color and
  duplex are in the PPD's PostScript `setpagedevice` snippets, which the
  filter passes through unchanged after wrapping the stream in a `@PJL`
  header/footer. It parses only KM job-control options (OutputMethod, secure
  print/Box/ID&Print/SafeQ, authentication, account track, copy security,
  stamps, encryption) and copies (`@PJL SET QTY`).
- 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. If a test through the relay prints A3
  correctly, the endpoint is accepting the vendor PJL/PostScript stream
  anyway; check the relay journal for the job's `document-format` (e.g.
  `application/postscript` or `application/octet-stream`) and
  `upstream result: HTTP 200 OK, IPP successful-ok`. In that case the
  unmodified PPD + KM filter is the simplest path and carries A3/duplex/color
  in the PostScript; no mapping backend is needed. This is outside the
  captured protocol, so verify it after server changes.
- Their options are `SelectColor`, `KMDuplex`, `InputSlot`, `MediaType`,
  finishing and so on; the PPDs have no `cupsUIResolver`/`cupsIPPAttributes`
  and do not map options to IPP attributes.

For a C451i, prefer `KMbeuC750iux.ppd`. Both PPDs contain the C451i choice and
the same 52 page sizes, `SelectColor` and `KMDuplex` options, so A3/color/duplex
are identical. The C750i PPD is the later BEU v1.2 revision and explicitly
constrains the C750i-series FS-536/PK-520 accessories off for C451i. The
C751i PPD adds the newer C751i/C651i/C551i models plus FS-542/PK-527/PFP13T234
accessories; it constrains PFP13T234 off for C451i but leaves FS-542/PK-527
unconstrained for C451i, so it is the less suitable base for a 451i.

To use the vendor C451i option set with the relay, make a derived PPD rather
than installing the vendor PPD unchanged. `make_relay_ppd.py`:

- sets `*DefaultModel: C451i` (the vendor default is `C750i`);
- replaces `*cupsFilter: "application/vnd.cups-postscript 0 KMbeuEmpPS.pl"`
  with the same PDF/URF pass-through lines as the server PPD, so CUPS sends
  PDF/URF to the relay and never runs the KM filter.

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

Use it together with `papercut_relay_backend.py`; the wrapper maps
`SelectColor`, `KMDuplex` and `PageSize`. Vendor-only options (trays, media
type, finishing) are preserved in the PPD/UI but are not mapped to IPP
attributes because the Mobility Print queue does not advertise them. Check the
filter chain with:

```bash
cupsfilter --list-filters -p /usr/share/ppd/KMbeuC451i-relay.ppd \
    -i application/pdf -m application/pdf test.pdf   # should show gziptoany
```

`cupstestppd` on the vendor PPD reports a pre-existing duplicate
`KMAuthentication` choice (and mixed line endings); the generated PPD inherits
those warnings. If `lpadmin` or CUPS refuses the derived PPD, fall back to the
server's generic PPD with the wrapper.

The wrapper was tested with the captured PPD and capabilities plus a stub
upstream; it has not been run against a live CUPS queue or the live Mobility
Print server. Install it root-owned and non-writable, like any CUPS backend.

### 8.5 Direct CUPS IPP with Basic auth (experimental)

The `.cap` capabilities file says `AuthInfoRequired username,password`, and the
server advertises `uri-authentication-supported: requesting-user-name, basic`.
CUPS can therefore be pointed directly at:

```
ipps://172.25.1.14:9164/printers/Follow-Me
```

with `-o auth-info-required=username,password` and credentials supplied to
CUPS. However, the captured client used **Bearer**, not Basic, on the IPP
endpoint; whether the Printer JWT is accepted as a Basic password is not
established by the capture. Treat this as an experiment; use Option B or C if
CUPS gets 401/403. The per-user URL from `/printer-url` (`http://…:9163/...`) is
another possible direct target, also untested in the capture.

### 8.6 Local versus remote job state

- The local CUPS job state reflects the backend/relay, not the PaperCut job.
  With Option B, CUPS reports the job completed once submission succeeds.
- The remote state is in the backend log (Option B) or in the relayed IPP
  responses (Option C).
- For Follow-Me queues the remote job may be `pending-held` until released at
  the printer; polling stops at a terminal state or times out.
- The captured job reached `completed` with `job-completed-with-errors` and 2
  media sheets after ~2 s; do not assume that exact outcome for a real queue.

### 8.7 Troubleshooting

| Symptom | Likely cause |
|---|---|
| `401` / IPP `0x0403` | JWT expired/invalid, or wrong printer/queue |
| IPP `0x0406` | wrong job-id or printer name |
| IPP `0x040A` | document format not `application/pdf`/`image/urf` |
| CUPS job aborted immediately | backend not executable/root-owned, or JWT file unreadable |
| Backend log shows PDF rejection | CUPS passed URF or a non-PDF spool file |
| CUPS prompts for password | local queue `auth-info-required` not `none` |
| Relay hangs on upload | 100-continue or chunked body not handled |
| Relay exits during start | config missing/invalid, expired JWT, or a non-loopback `--listen` without `--allow-non-loopback` (see `journalctl -u papercut-ipp-relay`) |
| Relay logs upstream `401`/`403` | JWT expired/revoked or the queue changed; re-run `acquire_queue_info.py` and restart the relay |
| CUPS job retries, "Unable to get printer status" | an older relay forwards the bare `printer-uri` from a placeholder device URI (`papercut-relay:/`); update to the current `ipp_relay.py` (it rewrites `printer-uri` from the YAML) and restart the relay |
| CUPS cannot connect to the relay | wrong port in the device URI, relay not running, or `localhost` resolving to IPv6 while the relay is bound to `127.0.0.1` |
| Job accepted but nothing prints | Follow-Me job held for release, or printer offline |

Enable CUPS debug logging when diagnosing:

```bash
sudo cupsctl --debug-logging
sudo tail -f /var/log/cups/error_log
```

---

## 9. Security notes

- The Printer JWT is a user-level bearer token valid for up to 365 days in the
  capture; anyone holding it can submit jobs as that user to the Mobility Print
  instance. Store it in a 0600 file, never in argv or logs.
- The CUPS backend runs as root; keep it root-owned and non-writable, and keep
  the JWT file root-readable only.
- The loopback relay must bind to 127.0.0.1 only and must not forward the JWT
  to any host other than the pinned Mobility Print server.
- TLS verification: the captured certificate is PaperCut-issued; verify it with
  `--cacert`/`--verify` where possible. The reference tool defaults to no
  verification for compatibility.
- The per-user queue URL from PROTOCOL2 contains a 64-hex secret; treat it as a
  credential.
- Do not include real JWTs or job data in protocol documentation or logs.

---

## Appendix A — Attribute tags used

```
0x21 integer        job-id, copies
0x22 boolean        last-document
0x41 text           status-message (responses)
0x42 name           requesting-user-name, job-name,
                    job-originating-host-name
0x44 keyword        requested-attributes values, print-color-mode, sides
0x45 uri            printer-uri, job-uri, job-uuid
0x47 charset        attributes-charset
0x48 naturalLanguage attributes-natural-language
0x49 mimeMediaType  document-format
```

Group delimiters: `0x01` operation, `0x02` job, `0x03` end.

Operation codes used:

```
0x0002 Print-Job
0x0004 Validate-Job
0x0005 Create-Job
0x0006 Send-Document
0x0008 Cancel-Job
0x0009 Get-Job-Attributes
0x000A Get-Jobs
0x000B Get-Printer-Attributes
```

---

## Appendix B — Request layout summary

```
Create-Job (0x0005)
  header | 0x01 | charset, language, printer-uri,
           requesting-user-name, job-name | 0x02 |
           job-originating-host-name, job-uuid | 0x03

Send-Document (0x0006)
  header | 0x01 | charset, language, printer-uri,
           job-id, requesting-user-name,
           last-document, document-format | 0x03 | PDF bytes

Print-Job (0x0002)
  header | 0x01 | charset, language, printer-uri,
           requesting-user-name, job-name,
           document-format | 0x03 | PDF bytes

Get-Job-Attributes (0x0009)
  header | 0x01 | charset, language, printer-uri,
           job-id, requesting-user-name,
           requested-attributes[...] | 0x03

Get-Printer-Attributes (0x000B)
  header | 0x01 | charset, language, printer-uri,
           requesting-user-name,
           requested-attributes[...] | 0x03
```

---

## Appendix C — Capture flow map (job phase)

```
flow 17  Get-Printer-Attributes  req 1   200  10597 bytes  (static info)
flow 18  Validate-Job            req 2   200  75 bytes     (no job created)
flow 19  Get-Printer-Attributes  req 3   200  10597 bytes
flow 20  Create-Job              req 4   200  224 bytes    job-id 21
flow 21  Send-Document           req 5   200  216 bytes    215901-byte PDF
flow 22  Get-Printer-Attributes  req 6   200  10597 bytes
flow 23  Get-Job-Attributes      req 7   200  277 bytes    state 3
flow 24  Get-Printer-Attributes  req 8   200  10597 bytes
flow 25  Get-Job-Attributes      req 9   200  277 bytes    state 3
flow 26  Get-Printer-Attributes  req 10  200  10597 bytes
flow 27  Get-Job-Attributes      req 11  200  277 bytes    state 3
flow 28  Get-Printer-Attributes  req 12  200  10597 bytes
flow 29  Get-Job-Attributes      req 13  200  290 bytes    state 9, 2 sheets
flow 30  Get-Printer-Attributes  req 14  200  10597 bytes
flow 31  Get-Job-Attributes      req 15  200  290 bytes    state 9
flow 32  Get-Printer-Attributes  req 16  200  10597 bytes
```

All job-phase requests use the endpoint and Bearer JWT from §2.

---

## References

- [PROTOCOL1](PROTOCOL1.md) — OAuth, session credential, Printer JWT issuance.
- [PROTOCOL2](PROTOCOL2.txt) — queue URL, drivers, .cap, static IPP capabilities.
- `print_pdf_jwt.py` — reference IPP submitter using only the Printer JWT.
- `printdeploy_client.py` — interactive client through setup, with optional IPP
  capture replay.
- `extract_tokens.py` — credential extraction from the capture.
