Cross-referenced every wire-format claim against rc-src and corrected several errors that would have caused stream desync during implementation: - rect header is 4×u16 + i32 (12 bytes), not varints + u8 - ping payload is i32 (4 bytes), not 1 byte - PointerEvent is 8 bytes (includes trailing extra_u16) - ServerInit is 19 bytes, not 16 - pixel-format struct is variable-length, not fixed 25 bytes - string primitive uses modified-UTF-8, not ISO-8859-1 - bandwidth probe is read-only (no echo response) - Phase 1 uses sync Read traits (async deferred to Phase 2) - corrected all stale line-number references in ac.java/aw.java Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
468 lines
22 KiB
Markdown
468 lines
22 KiB
Markdown
# 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 from `h.java`: `h.new()` = u8, `h.try()` = i8, `h.int()` =
|
||
u16-BE, `h.char()` = i16-BE, `h.do()` = i32-BE, `h.byte()` = u16-length-
|
||
prefixed modified-UTF-8 string. Write primitives are u8 and u16-BE only.
|
||
- Varint (1–3 bytes) is defined in `aw.int()` at line 484. Used for Tight
|
||
compressed-stream lengths and a few other places — **not** for rectangle
|
||
headers.
|
||
- 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 i32 error code, see `aw.a(int)` line 350) | line 264 |
|
||
| 3 | S→C | 15 bytes: `"-RIC RFB MM.NN\n"` (digits ASCII). Combined with the `e` from step 2, the full banner is `"e-RIC RFB MM.NN\n"` | `int(by)` line 395 |
|
||
| 4 | S→C | 1 byte sync (`w.new()`) | line 270 |
|
||
| 5 | S→C | 1 pad byte + modified-UTF-8 string (u16 length prefix + N bytes) = server name | `l()` line 413 |
|
||
| 6 | S→C | 1 byte sync | line 272 |
|
||
| 7 | S→C | Variable-length pixel-format struct: 1 byte flag + u16 int + u16 string-length + N bytes string | `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 | 19 bytes ServerInit: `[bX(u8), w(u16), h(u16), bE(u8), a(u8), try(u8), a3(u8), x(u16), ao(u16), v(u16), bM(u8), bW(u8), bU(u8), 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 292 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()` line 537 | pixel-format change (1 pad + 4×u8 + 8×u16 = 21 bytes) |
|
||
| 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** — read 3 pad bytes + i32 payload, must respond with `aw.if(n)` (msg 149) |
|
||
| 150 | `aw.do()` | bandwidth measurement probe (1 pad + u16 length + N bytes, read-only, no response) |
|
||
| 161 | `aw.case()` | RDP/Host-Direct mode events |
|
||
|
||
**Per-rectangle encoding dispatch** (`ac.e()` at line 218 of `ac.java`, switch at line 226):
|
||
|
||
| 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, extra_u16]` (8 bytes; extra=0 in absolute mode) |
|
||
| 7 | `a(String)` line 418 | ClientCutText |
|
||
| 149 | `if(int)` line 636 | PingResponse `[-107(=149 unsigned), 0,0,0, n_i32]` (8 bytes) |
|
||
|
||
---
|
||
|
||
## 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 `h.java` + `aw.java` read/write helpers. No
|
||
network I/O — all operations on `&[u8]` / `Cursor<&[u8]>` (sync `Read`/`Write`
|
||
traits) so the codec is testable without tokio. Async wrappers added in Phase 2.
|
||
|
||
**Java references:**
|
||
- `h.java` — the low-level I/O adapter. Key methods:
|
||
- `h.new()` line 106 — read 1 byte as unsigned int (u8)
|
||
- `h.try()` line 91 — read 1 byte as signed (i8)
|
||
- `h.int()` line 138 — read 2 bytes big-endian as unsigned int (u16)
|
||
- `h.char()` line 121 — read 2 bytes big-endian as signed short (i16)
|
||
- `h.do()` line 172 — read 4 bytes big-endian as signed int (i32)
|
||
- `h.byte()` line 188 — read u16 length + N bytes of modified-UTF-8 string
|
||
- `aw.int()` line 484 — varint, 1–3 bytes, top bit = continuation. Used for
|
||
Tight compressed-stream lengths. Document with exact byte examples.
|
||
- `aw.new()` line 454 — `read_u8` (delegates to `h` with a byte-count update)
|
||
- `aw.f()` line 468 — rectangle header reader: `[x(u16), y(u16), w(u16),
|
||
h(u16), encoding(i32)]` = **12 bytes fixed**. Coords use `h.int()` (u16-BE),
|
||
encoding uses `h.do()` (i32-BE). **Do not confuse `this.w.int()` (h.int =
|
||
u16) with `aw.int()` (varint) — the obfuscated names collide.**
|
||
|
||
**Tasks:**
|
||
- [ ] `read_u8`, `read_i8`, `read_u16_be`, `read_i16_be`, `read_i32_be`
|
||
primitives matching `h.java`
|
||
- [ ] `read_varint` / `write_varint` with property tests
|
||
- [ ] `RectHeader { x: u16, y: u16, w: u16, h: u16, encoding: i32 }` (12 bytes)
|
||
- [ ] Modified-UTF-8 string reader (u16 length prefix + Java modified-UTF-8
|
||
body, per `h.byte()` line 188)
|
||
- [ ] 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 (variable-length: 1 byte flag +
|
||
u16 int + u16 string-length + N bytes)
|
||
- `aw.a(int)` line 350 — error code → string (1=no perm, 2=exclusive, 3=manually
|
||
rejected, 4=password disabled, 5=loopback, 6=auth failed, 7=port denied)
|
||
|
||
**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 283 (of `ac.java`), switch at line 292 — server-msg dispatch
|
||
- `ac.f()` line 273 → `ac.e()` line 218 — FramebufferUpdate handler → per-rect
|
||
encoding dispatch (switch at line 226)
|
||
- `aw.null()` line 459 — FramebufferUpdate header (1 pad byte via `h.try()` +
|
||
u16 num_rects via `h.int()`)
|
||
- `aw.f()` line 468 — per-rectangle header (4×u16 + i32 = 12 bytes)
|
||
- `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 pad bytes (`h.try()` × 3) + i32 payload
|
||
(`h.do()`). Total 7 bytes.
|
||
- `aw.if(int)` line 636 — ping response writer: `[-107, 0, 0, 0, n_i32]` = 8
|
||
bytes. Echo the i32 payload from `aw.b()` back.
|
||
|
||
**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, reads 2 bytes per
|
||
`aw.for()` line 553), 131 (debug string, log), 132 (RFB command, log
|
||
key=value), 148 (ping → echo i32 payload back as msg 149), 150 (bandwidth
|
||
probe — read and discard per `aw.do()` line 642, no response needed)
|
||
- [ ] 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<Decompress>; 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)` line 612 — PointerEvent writer.
|
||
Payload: `[5, button_mask, x_u16, y_u16, extra_u16]` = 8 bytes. `extra` is
|
||
always 0 in absolute mode. 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, extra: u16 }` writer (8 bytes;
|
||
extra=0 for absolute mode)
|
||
- [ ] 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 |
|
||
| `h.int()` (u16) vs `aw.int()` (varint) name collision | High during development | `aw.f()` rect headers use `h.int()` = u16; `aw.int()` varint is only for Tight stream lengths. Strict newtypes 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
|