commit 6e1a6fc29d6323bf58d7c533706aaaa5f9bf0ab4 Author: rob thijssen Date: Wed May 6 13:36:07 2026 +0300 doc: implementation plan diff --git a/doc/plan/implementation.md b/doc/plan/implementation.md new file mode 100644 index 0000000..acc1fec --- /dev/null +++ b/doc/plan/implementation.md @@ -0,0 +1,447 @@ +# blekin — implementation plan + +A Rust proxy that translates the Belkin OmniView Remote IP Manager's e-RIC RFB +protocol (Peppercon LARA, served over a Java applet) into a modern HTML5 KVM +console. The proxy speaks plain TCP to the OmniView, decodes the proprietary +8bpp protocol to RGBA, and bridges to a Vite/React frontend over WebSocket. + +All Java references are paths under `~/rc-src/nn/pp/rc/` from the CFR-decompiled +`rc.jar`. Line numbers reference the files as uploaded. + +## Target architecture + +``` +Browser (Vite + React + TS) + │ WebSocket (binary, RGBA blits + input events) + ▼ +blekin (Rust, tokio + axum) + │ HTTP session (cookie + APPLET_ID extraction) + │ TCP to OmniView:443 (e-RIC RFB) + ▼ +Belkin OmniView Remote IP Manager +``` + +Workspace layout: + +``` +blekin/ +├── Cargo.toml # workspace +├── crates/ +│ ├── ericrfb/ # pure protocol library, no IO +│ ├── ericrfb-proxy/ # tokio service: HTTP login + TCP + WS bridge +│ └── ericrfb-frontend/ # Vite/React/TS canvas client +└── PLAN.md +``` + +## Protocol reference (one-page summary) + +Every byte derives from `aw.java`, `ac.java`, and `ByteColorRFBRenderer.java`. + +**Wire facts:** +- Read primitives are mixed: u8, u16-BE, varint (1–3 bytes, per `aw.int()` at + line 484). Write primitives are u8 and u16-BE only. +- Pixel format is **8bpp RGB332** (mask 7 / 56 / 192). One byte per pixel on + the wire. Decoded to 24-bit RGB via a 256-entry lookup table. +- Four hardcoded sub-palettes referenced by Hextile/Tight: 1bpp B/W, 2bpp gray, + 4bpp gray, 4bpp 16-color. See `ByteColorRFBRenderer.case()` at line 691. +- Four parallel zlib streams (`Inflater[4]`) for Tight + encoding 9, indexed by + bits 4–5 of the encoding control byte. State persists across rectangles — + rectangles **must not be skipped**. + +**Handshake** (`aw.g()` at line 226): + +| Step | Direction | Bytes | Source | +|------|-----------|-------|--------| +| 1 | C→S | 75 bytes: `"e-RIC AUTH=" + APPLET_ID`, zero-padded, ISO-8859-1 | line 259 | +| 2 | S→C | 1 byte status. `101 ('e')` = OK. `3` = error (read 4 more, see `aw.a(int)`) | line 264 | +| 3 | S→C | 15 bytes: `"-RIC RFB MM.NN\n"` (digits ASCII) | `int(by)` line 395 | +| 4 | S→C | 1 byte sync (`w.new()`) | line 270 | +| 5 | S→C | varint length + N bytes server name | `l()` line 413 | +| 6 | S→C | 1 byte sync | line 272 | +| 7 | S→C | 25 bytes pixel-format-ish struct | `i()` line 519 | +| 8 | C→S | 16 bytes: `"e-RIC RFB " + PROTOCOL_VERSION + "\n"` | `try()` line 405 | +| 9 | C→S | 2 bytes: `[shared_flag, port_id]` | `char()` line 425 | +| 10 | S→C | 1 byte sync | line 276 | +| 11 | S→C | 16 bytes ServerInit: `[bX, w_u16, h_u16, bE, a, try, a3, x_u16, ao_u16, v_u16, bM, bW, bU, 3 pad]` | `k()` line 435 | + +After step 11, the client sends `SetEncodings` and a non-incremental +`FramebufferUpdateRequest`, then enters the message dispatch loop. + +**Server-to-client message types** (from `ac.char()` switch at line 244 of +`ac.java`): + +| Type | Reader | Purpose | +|------|--------|---------| +| 0 | `ac.f()` → `ac.e()` | FramebufferUpdate | +| 1 | — | SetColourMapEntries (rejected) | +| 2 | — | Bell | +| 3 | `aw.goto()` | ServerCutText | +| 7 | `aw.l()` | server-name update | +| 8 | `aw.e()` | pixel-format change (reads 25-byte struct) | +| 9 | `aw.else()` | layout/locale string | +| 16 | `aw.i()` | desktop resize + name | +| 17 | `aw.for()` | 2-byte ack (no-op) | +| 128 | `aw.k()` | mode change / framebuffer reinit | +| 131 | `aw.d()` | debug string | +| 132 | `aw.long()` | RFB-command channel: two strings (key, value) | +| 148 | `aw.b()` | **ping** — must respond with `aw.if(0)` (msg 149) | +| 150 | `aw.do()` | bandwidth measurement probe | +| 161 | `aw.case()` | RDP/Host-Direct mode events | + +**Per-rectangle encoding dispatch** (`ac.e()` at line 233 of `ac.java`): + +| Encoding | Renderer method | Notes | +|----------|-----------------|-------| +| 0 | `ByteColorRFBRenderer.if()` line 98 | Raw 8bpp, w×h bytes | +| 1 | `ByteColorRFBRenderer.a()` line 165 | CopyRect | +| 5 | `ByteColorRFBRenderer.int()` line 169 | Hextile (16×16, palette refs) | +| 7 | `ByteColorRFBRenderer.a()` line 244 → 324 | Tight-derived | +| 9 | `ByteColorRFBRenderer.do()` line 248 | IIP — defer | +| 10 | `ByteColorRFBRenderer.for()` line 109 | Raw with tile-interleave flag | + +**Client-to-server messages** (writers in `aw.java`): + +| Byte | Method | Format | +|------|--------|--------| +| 1 | `a(...)` line 572 | SetPixelFormat | +| 2 | `a(int[], int)` line 597 | SetEncodings | +| 3 | `a(...)` line 562 | FramebufferUpdateRequest | +| 4 | `a(byte)` line 655 | KeyEvent (partial — see Phase 5) | +| 5 | `a(false,...)` line 612 | PointerEvent: `[5, mask, x_u16, y_u16]` | +| 7 | `a(String)` line 418 | ClientCutText | +| 149 | `if(int)` line 636 | PingResponse `[149, 0,0,0, n_u32]` | + +--- + +## Phase 0 — Repo & scaffolding + +**Goal:** Empty workspace compiles. CI runs `cargo test`. + +- [ ] `cargo new --lib crates/ericrfb` +- [ ] `cargo new --bin crates/ericrfb-proxy` +- [ ] Vite scaffold for `crates/ericrfb-frontend` (`npm create vite@latest -- --template react-swc-ts`) +- [ ] Workspace `Cargo.toml` pinning `tokio = "1"`, `axum = "0.7"`, `bytes = "1"`, + `flate2 = "1"`, `tracing = "0.1"`, `anyhow`, `thiserror` +- [ ] `.envrc` / direnv with `RUST_LOG=ericrfb=debug,ericrfb_proxy=debug` +- [ ] Forgejo Actions workflow on git.lair.cafe: build + test + clippy + +**Deliverable:** `cargo test` green on a stub test in each crate. + +--- + +## Phase 1 — Protocol primitives (`ericrfb/src/proto.rs`) + +**Goal:** Faithful Rust port of `aw.java`'s read/write helpers. No I/O — all +operations on `&mut impl AsyncRead` / `&mut impl AsyncWrite` (or `Buf`/`BufMut` +for unit tests). + +**Java references:** +- `aw.int()` line 484 — varint, 1–3 bytes, top bit = continuation. **The single + most important primitive; document it in a code comment with the exact byte + examples.** +- `aw.new()` line 454 — `read_u8` +- `h.try()`, `h.do()`, `h.char()`, `h.byte()` — wrapped i8/u8/i16/string reads + through the `h.java` adapter class. Map to `read_u8`, `read_u16_be`, + read-length-prefixed-string. +- `aw.f()` line 468 — rectangle header reader (varint x, varint y, varint w, + varint h, u8 encoding). **Critical — these coords are varints, not u16.** + +**Tasks:** +- [ ] `read_varint` / `write_*` primitives with property tests +- [ ] `RectHeader { x: u32, y: u32, w: u32, h: u32, encoding: u8 }` +- [ ] Length-prefixed-string reader with `ISO-8859-1` decode +- [ ] Unit tests using fixed byte vectors — at least one for each primitive + +**Deliverable:** `cargo test -p ericrfb proto::` covers every primitive used +later, with a property test ensuring `write_varint(n) → read_varint = n` +roundtrips for n ∈ [0, 2²²). + +--- + +## Phase 2 — Handshake (`ericrfb/src/handshake.rs`) + +**Goal:** Open a TCP socket to the OmniView, complete steps 1–11 of the table +above, return a `Session` containing `(width, height, pixel_format, name, +read_half, write_half)`. + +**Java references:** +- `aw.g()` line 226 — the connect-and-handshake sequence in full +- `aw.int(byte)` line 395 — server-version banner parser +- `aw.try()` line 405 — client version reply (`"e-RIC RFB 01.11\n"`, exactly 16 + bytes) +- `aw.char()` line 425 — 2-byte port-init message +- `aw.k()` line 435 — ServerInit reader +- `aw.i()` line 519 — pixel-format struct reader +- `aw.a(int)` line 350 — error code → string (1=no perm, 2=exclusive, 6=auth + failed, etc.) + +**Tasks:** +- [ ] `Config { host, port, applet_id, protocol_version, port_id, shared }` +- [ ] `connect(cfg) -> Result` walking all 11 steps with `tracing` + spans +- [ ] Error type mapping the auth status code via `aw.a(int)`'s table +- [ ] Integration test gated behind `OMNIVIEW_TEST_HOST` env var that exercises + the real device — skipped in normal CI + +**Deliverable:** `cargo run --example handshake -- --host 10.3.0.130 --applet-id $TOKEN` +prints `Connected: name="Remote IP Manager", 640×480, RGB332` and exits cleanly. +First real-protocol milestone. + +--- + +## Phase 3 — Session pump + Raw decoder (`ericrfb/src/codec/raw.rs`) + +**Goal:** Receive a `FramebufferUpdate` containing only Raw rectangles, apply +to a `Framebuffer { width, height, pixels: Vec }` (8bpp), expand to RGBA via +LUT, write a PNG to disk. + +**Java references:** +- `ac.char()` line 244 (of `ac.java`) — server-msg dispatch loop +- `ac.f()` line 213 → `ac.e()` line 165 — FramebufferUpdate reader +- `aw.null()` line 459 — FramebufferUpdate header (1 pad + u16 num_rects) +- `aw.f()` line 468 — per-rectangle header +- `ByteColorRFBRenderer.if(x,y,w,h)` line 98 — Raw decoder (read w*h bytes, + blit at offset) +- `ByteColorRFBRenderer` constructor lines 57–74 — RGB332 → 24bpp LUT + construction (`DirectColorModel(8, 7, 56, 192)`) + +**Tasks:** +- [ ] `Framebuffer` struct with `apply_raw(rect, &[u8])` +- [ ] Static `RGB332_TO_RGBA: [u32; 256]` table generated at compile time via + `const fn` matching the Java mask layout +- [ ] Message-dispatch loop with `match` on type byte; only type 0 implemented, + everything else returns "unhandled type N" error +- [ ] Send `SetEncodings([0])` + `FramebufferUpdateRequest(full, incremental=false)` +- [ ] `examples/snapshot.rs` saves first frame as `frame.png` + +**Deliverable:** `cargo run --example snapshot` produces a PNG of whatever the +KVM is showing — POST screen, BIOS, OS console, whatever. **This is the moment +the project stops being theoretical.** + +--- + +## Phase 4 — Hextile + ping + extension messages (`ericrfb/src/codec/hextile.rs`) + +**Goal:** Long-running session that stays connected for hours and renders a +sequence of frames. Tight not yet supported, but the simpler encodings carry +us a long way. + +**Java references:** +- `ByteColorRFBRenderer.int()` line 169 — Hextile decoder. Walk the 16×16 grid; + for each tile read subencoding byte, then optionally bg/fg colors, optionally + subrect count + per-subrect coords. Pre-defined palettes don't apply here — + Hextile uses 1-byte direct color tokens only. +- `ByteColorRFBRenderer.a(srcX, srcY, x, y, w, h)` line 165 — CopyRect: trivial + source→dest blit within the framebuffer +- `aw.b()` line 629 — ping reader (3 bytes ignored + 1 byte payload) +- `aw.if(int)` line 636 — ping response writer + +**Tasks:** +- [ ] Hextile decoder, with subrect bit-flag handling matching lines 192–238 +- [ ] CopyRect handler with overlap-safe `copy_within` semantics +- [ ] Wire up message types 2 (Bell, log only), 17 (no-op ack), 131 (debug string, + log), 132 (RFB command, log key=value), 148 (ping → respond), 150 + (bandwidth probe — echo per `aw.do()` line 642) +- [ ] Type 16 (resize) + type 128 (mode change) trigger framebuffer + reallocation; emit a `Event::Resize(w, h)` to consumers +- [ ] `examples/record.rs`: 30-second session, save 1 PNG/sec to `out/` + +**Deliverable:** A folder of timestamped PNGs from a real session showing +boot/login/whatever activity. Connection survives ≥ 1 hour without dropping +(verifies ping handling). + +--- + +## Phase 5 — Tight decoder (`ericrfb/src/codec/tight.rs`) + +**Goal:** Server defaults to Tight when bandwidth matters; without this, full +desktop refreshes are painfully slow. With Tight, video is fluid. + +**Java references:** +- `ByteColorRFBRenderer.a(x,y,w,h)` line 244 → `a(... null, 0, 0, 0)` line 324 + — Tight dispatcher. Read control byte `n13`. Top 4 bits = stream-reset flags + + stream-id; bottom 4 bits = subencoding type. +- Subencoding 8 (line 348) — fill rect with 1-byte color +- Subencoding 15 (line 351) — fill rect with palette-indexed color (1-byte + palette selector 1–4 → palettes `C/x/L/I`, then 1-byte index) +- Subencodings 0–7 (line 380) — zlib-compressed pixel data, optional palette + filter (filter id 1 = palette mode, 2-color sub-palette from `C/x/L/I`) +- Subencodings 10–13 (line 432) — reduced bit-depth packed (1/2/4 bpp) +- `aw.int()` is reused for compressed-stream length (line 297, 457) +- `case()` at line 691 of the renderer — the four hardcoded sub-palettes, + ported verbatim + +**Tasks:** +- [ ] `ZlibStreams { streams: [Option; 4] }` with reset-on-flag + logic matching lines 336–341 +- [ ] Subencoding 8 (single-color fill) +- [ ] Subencoding 15 (palette-indexed fill) +- [ ] Subencoding 0–7 with optional filter byte: copy raw, palette-filtered + (1-bit packed when palette size = 2) +- [ ] Subencoding 10–13: bit-unpacking via the predefined LUTs + (`ByteColorRFBRenderer.if()` at line 580 is the reference) +- [ ] Constants module with `PALETTE_2`, `PALETTE_4`, `PALETTE_GRAY16`, + `PALETTE_COLOR16` ported from lines 691–735 + +**Deliverable:** SetEncodings advertises `[7, 5, 1, 0, -250]`. The example from +Phase 4 runs at recognizable framerate. Bandwidth on tcpdump drops by ≥5× vs. +Phase 4. Visual diff between Tight-decoded and Raw-snapshot frames is byte- +identical for static screens. + +--- + +## Phase 6 — Input: keyboard and mouse (`ericrfb/src/input.rs`) + +**Goal:** Send pointer events and keystrokes to the OmniView so the console is +actually usable. + +**Java references:** +- `aw.a(boolean, int, int, int, int, int)` line 612 — PointerEvent writer. + Payload: `[5, button_mask, x_u16, y_u16]`. With `bl=true`, byte 0 becomes 147 + for "exclusive/relative" mode — ignore for v1. +- `MouseHandlerAbsolute.java` — confirms button-mask bit layout matches RFB + (bit 0 = left, bit 1 = middle, bit 2 = right, bit 3/4 = wheel up/down) +- `KeyEventHandler.java` + `nn.pp.rckbd.*` — the keyboard handler is more + involved than the 2-byte `aw.a(byte)` writer suggests. Read `KeyEventHandler` + to understand the multi-byte key sequences (HID-style, with separate + press/release bytes per the `KeyDef.java` model). +- `KbdLayout_104pc.java` — US layout scancode tables, used as default +- `KbdMapping_en.java` (in rcsoftkbd) — alternate mapping path + +**Tasks:** +- [ ] `PointerEvent { mask: u8, x: u16, y: u16 }` writer +- [ ] Browser-side: capture `mousemove`/`mousedown`/`mouseup` → WS frames +- [ ] Initial keyboard: send raw scancodes via msg type 4 with the 104pc layout + table verbatim from `KbdLayout_104pc.java` +- [ ] Browser-side: `keydown`/`keyup` → JavaScript `KeyboardEvent.code` → + lookup → scancode → WS frame +- [ ] CtrlAltDel button in UI sends the hotkey sequence from the HTML param + `HOTKEYCODE_0` (`"36 f0 37 f0 4e "` = Ctrl down, Alt down, Del down, + Del up, Alt up, Ctrl up) + +**Deliverable:** Type into BIOS, click in OS installer. The OmniView is +genuinely usable. + +--- + +## Phase 7 — Proxy daemon (`crates/ericrfb-proxy`) + +**Goal:** Long-running tokio service that holds a session per browser +connection. HTTP login flow extracts the APPLET_ID; WebSocket upgrade hands off +to the protocol pump. + +**Java references:** +- `RemoteConsoleApplet.if(boolean)` line 312 of `RemoteConsoleApplet.java` — + shows where `APPLET_ID`, `PORT`, `HOST`, `PROTOCOL_VERSION` are pulled from + HTML ``. Replicate by HTTP GET-ing `/title_app.asp` (or + whatever the login flow lands on) and parsing the params from the returned + HTML. +- The login HTTP flow itself is **not in the jar** — it's web UI. Capture it + with browser DevTools' Network tab. + +**Tasks:** +- [ ] `axum` server: `POST /login` proxies credentials to OmniView, scrapes + cookies, reads `/title_app.asp`, extracts `APPLET_ID` and other params +- [ ] `GET /ws/console` upgrades to WebSocket, opens TCP to OmniView, runs + handshake, then runs a bidirectional pump: + - OmniView → decoder → RGBA blit messages → WS + - WS → input event → PointerEvent/KeyEvent → OmniView +- [ ] Static-files handler serving the built Vite frontend from `dist/` +- [ ] Configurable via `config.toml`: bind addr, OmniView host, Step CA cert + paths for mTLS-protecting the proxy itself +- [ ] systemd quadlet manifest for Podman deployment on whichever homelab host + cichlid eventually places it + +**Deliverable:** `podman run blekin`, navigate to its URL, log in, +see the OmniView KVM in a browser tab. Works on any machine on the wireguard +mesh, no Java anywhere. + +--- + +## Phase 8 — Frontend (`crates/ericrfb-frontend`) + +**Goal:** Minimal, fast canvas-based renderer matching your stack preferences +(Vite, React, SWC, TS). + +**No Java references** — the frontend is pure WS-protocol consumer. + +**Tasks:** +- [ ] `Console.tsx`: `` sized to framebuffer dimensions; resize handler + reallocates on `Event::Resize` +- [ ] `decoder.ts`: receive WS messages, dispatch on tag: + - `blit`: `ctx.putImageData(new ImageData(rgba, w, h), x, y)` + - `copy`: `ctx.drawImage(canvas, srcX, srcY, w, h, x, y, w, h)` + - `resize`: reallocate canvas + - `ping`: ignored (handled in proxy) +- [ ] `input.ts`: pointer + keyboard event capture, send as binary WS frames +- [ ] Toolbar: CtrlAltDel, full-screen, mouse-mode toggle +- [ ] Light/dark themes matching your other tools' aesthetic + +**Deliverable:** A polished console UI that doesn't look like a 2005 Java +applet. + +--- + +## Phase 9 — Optional: encoding 9 (IIP) and encoding 10 + +**Goal:** Performance parity with the original Java applet, only worth doing if +encoding 7 (Tight) doesn't cut it in practice. + +**Java references:** +- `ByteColorRFBRenderer.do()` line 248 — encoding 9 dispatcher +- `ByteColorRFBRenderer.if(...)` line 490 + line 580 — the per-tile delta + application using the `t[]` cache +- `t.java` — the per-tile state class. Read this in full; it's stateful across + rectangles in a way the other encodings aren't. +- `ByteColorRFBRenderer.for(x,y,w,h)` line 109 — encoding 10 (Raw with tile + interleave); much simpler than encoding 9 + +**Approach:** +- [ ] Port `t.java` first as `tile_cache.rs` +- [ ] Encoding 10 is a quick win — basically Raw with a 16×16 deinterleave loop +- [ ] Encoding 9 needs the cache plumbed end-to-end. Get a known-good capture + from a real Java session as ground truth. Compare frame-by-frame to + validate. + +**Deliverable:** Bandwidth and latency matching the original applet. May never +be needed. + +--- + +## Phase 10 — Polish + +- [ ] Virtual media (the Floppy/CD-ROM image redirection in the Belkin UI) — + separate sub-protocol, not in `ac.java`. Probably documented in another + `aj`/`ac` sibling class. Defer until everything else works. +- [ ] Reconnection logic: the OmniView drops sessions on reboot; reconnect with + backoff +- [ ] Multi-port switching: the OmniView fronts an Avocent KVM — the `port_id` + field in the ServerInit selects between attached HP servers +- [ ] mTLS between proxy and browser using your Step CA +- [ ] Quantum-safe TLS for the proxy's frontend listener (matches your + preferences); the OmniView side stays plain TCP because the device can't + do anything modern + +--- + +## Risk register + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| Encoding 9 needed for usable framerates | Low | SetEncodings doesn't advertise 9 unless user picks "advanced" mode in `RemoteConsoleApplet.if(d2)` line 423; default `[255, 7, -250]` works fine | +| Keyboard wire format more complex than `aw.a(byte)` suggests | Medium | `KeyEventHandler.java` is small; read it before Phase 6 | +| HTTP login flow has CSRF / session-binding quirks | Medium | Capture with DevTools first; any irregularities are scriptable | +| Varint vs u16 confusion in `aw` | High during development | Strict types on `proto.rs` primitives; never use raw integers | +| Zlib stream desync from dropped/skipped rectangles | High if architecture wrong | Decoder owns the stream; consumers can drop output but never input | + +## Success criteria + +A working v1 ships when: + +1. Browser tab on any device on the wireguard mesh shows the live OmniView KVM +2. Mouse and keyboard work for BIOS, OS installer, and running OS +3. Connection survives ≥ 8 hours uninterrupted +4. No Java anywhere in the stack +5. Source on git.lair.cafe under a permissive license, with a README that + names the Peppercon e-RIC heritage so the next person searching for this + has a chance of finding it