20 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 are mixed: u8, u16-BE, varint (1–3 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 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 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/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 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, 1–3 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_u8h.try(),h.do(),h.char(),h.byte()— wrapped i8/u8/i16/string reads through theh.javaadapter class. Map toread_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 testsRectHeader { x: u32, y: u32, w: u32, h: u32, encoding: u8 }- Length-prefixed-string reader with
ISO-8859-1decode - 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 readeraw.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 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 244 (ofac.java) — server-msg dispatch loopac.f()line 213 →ac.e()line 165 — FramebufferUpdate readeraw.null()line 459 — FramebufferUpdate header (1 pad + u16 num_rects)aw.f()line 468 — per-rectangle headerByteColorRFBRenderer.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 bytes ignored + 1 byte payload)aw.if(int)line 636 — ping response writer
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), 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 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, int)line 612 — PointerEvent writer. Payload:[5, button_mask, x_u16, y_u16]. 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 }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→ 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 |
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:
- 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