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>
22 KiB
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/ericrfbcargo new --bin crates/ericrfb-proxy- Vite scaffold for
crates/ericrfb-frontend(npm create vite@latest -- --template react-swc-ts) - Workspace
Cargo.tomlpinningtokio = "1",axum = "0.7",bytes = "1",flate2 = "1",tracing = "0.1",anyhow,thiserror .envrc/ direnv withRUST_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 tohwith 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 useh.int()(u16-BE), encoding usesh.do()(i32-BE). Do not confusethis.w.int()(h.int = u16) withaw.int()(varint) — the obfuscated names collide.
Tasks:
read_u8,read_i8,read_u16_be,read_i16_be,read_i32_beprimitives matchingh.javaread_varint/write_varintwith property testsRectHeader { 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 fullaw.int(byte)line 395 — server-version banner parseraw.try()line 405 — client version reply ("e-RIC RFB 01.11\n", exactly 16 bytes)aw.char()line 425 — 2-byte port-init messageaw.k()line 435 — ServerInit readeraw.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 withtracingspans- Error type mapping the auth status code via
aw.a(int)'s table - Integration test gated behind
OMNIVIEW_TEST_HOSTenv 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 (ofac.java), switch at line 292 — server-msg dispatchac.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 viah.try()+ u16 num_rects viah.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)ByteColorRFBRendererconstructor lines 57–74 — RGB332 → 24bpp LUT construction (DirectColorModel(8, 7, 56, 192))
Tasks:
Framebufferstruct withapply_raw(rect, &[u8])- Static
RGB332_TO_RGBA: [u32; 256]table generated at compile time viaconst fnmatching the Java mask layout - Message-dispatch loop with
matchon type byte; only type 0 implemented, everything else returns "unhandled type N" error - Send
SetEncodings([0])+FramebufferUpdateRequest(full, incremental=false) examples/snapshot.rssaves first frame asframe.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 framebufferaw.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 fromaw.b()back.
Tasks:
- Hextile decoder, with subrect bit-flag handling matching lines 192–238
- CopyRect handler with overlap-safe
copy_withinsemantics - 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 peraw.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 toout/
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 byten13. 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_COLOR16ported 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.extrais always 0 in absolute mode. Withbl=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-byteaw.a(byte)writer suggests. ReadKeyEventHandlerto understand the multi-byte key sequences (HID-style, with separate press/release bytes per theKeyDef.javamodel).KbdLayout_104pc.java— US layout scancode tables, used as defaultKbdMapping_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→ JavaScriptKeyboardEvent.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 ofRemoteConsoleApplet.java— shows whereAPPLET_ID,PORT,HOST,PROTOCOL_VERSIONare 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:
axumserver:POST /loginproxies credentials to OmniView, scrapes cookies, reads/title_app.asp, extractsAPPLET_IDand other paramsGET /ws/consoleupgrades 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 onEvent::Resizedecoder.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 dispatcherByteColorRFBRenderer.if(...)line 490 + line 580 — the per-tile delta application using thet[]cachet.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.javafirst astile_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 anotheraj/acsibling 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_idfield 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:
- Browser tab on any device on the wireguard mesh shows the live OmniView KVM
- Mouse and keyboard work for BIOS, OS installer, and running OS
- Connection survives ≥ 8 hours uninterrupted
- No Java anywhere in the stack
- 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