Files
blekin/doc/plan/implementation.md
rob thijssen a60cee3f23 fix: reconcile implementation plan with decompiled Java source
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>
2026-05-06 13:40:40 +03:00

22 KiB
Raw Permalink Blame History

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 (13 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 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 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, 13 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 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 (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 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 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 192238
  • 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 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) 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