Files
blekin/doc/plan/implementation.md

448 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (13 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 45 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, 13 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 111 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<Session>` 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<u8> }` (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 5774 — 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 192238
- [ ] 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 14 → palettes `C/x/L/I`, then 1-byte index)
- Subencodings 07 (line 380) — zlib-compressed pixel data, optional palette
filter (filter id 1 = palette mode, 2-color sub-palette from `C/x/L/I`)
- Subencodings 1013 (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<Decompress>; 4] }` with reset-on-flag
logic matching lines 336341
- [ ] Subencoding 8 (single-color fill)
- [ ] Subencoding 15 (palette-indexed fill)
- [ ] Subencoding 07 with optional filter byte: copy raw, palette-filtered
(1-bit packed when palette size = 2)
- [ ] Subencoding 1013: 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 691735
**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 `<param>`. 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`: `<canvas>` 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