Files
blekin/doc/plan/implementation.md

20 KiB
Raw 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 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