Compare commits

...

3 Commits

Author SHA1 Message Date
c31508f138 feat: phase 10 — reconnection, encoding 10, disconnect button
All checks were successful
Publish / frontend (push) Successful in 42s
CI / fmt (push) Successful in 44s
CI / check (push) Successful in 1m43s
CI / clippy (push) Successful in 1m43s
Publish / backend (push) Successful in 2m47s
Frontend reconnection:
- WebSocket auto-reconnects with exponential backoff (1s → 30s)
- Re-authenticates with OmniView to get fresh APPLET_ID on reconnect
- Credentials stored in sessionStorage for automatic re-login
- Status bar shows connection state and reconnect countdown
- Disconnect button returns to login screen

Encoding 10 (Raw with tile interleave):
- codec/raw_tile.rs: decodes encoding 10 per ByteColorRFBRenderer.for()
- Flag byte bit 0 selects plain raw vs 16x16 tile-interleaved data
- Deinterleave handles edge tiles smaller than 16x16
- Wired into session dispatch
- 2 unit tests

39 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 09:21:21 +03:00
865a08da17 chore: setup backend environment 2026-05-07 09:08:28 +03:00
3ba05bcb05 chore: setup hosting environment 2026-05-07 08:41:16 +03:00
9 changed files with 357 additions and 27 deletions

View File

@@ -1,6 +1,12 @@
server {
listen 80;
server_name blekin.kosherinata.internal;
listen 443 ssl;
http2 on;
ssl_certificate /etc/nginx/tls/cert/blekin.kosherinata.internal.pem;
ssl_certificate_key /etc/nginx/tls/key/blekin.kosherinata.internal.pem;
#ssl_trusted_certificate /etc/pki/ca-trust/source/anchors/root-internal.pem;
ssl_protocols TLSv1.3;
root /var/www/blekin.kosherinata.internal;
index index.html;

View File

@@ -5,6 +5,8 @@ Wants=network-online.target
[Service]
Type=simple
User=blekin
Group=blekin
ExecStart=/usr/local/bin/ericrfb-proxy
WorkingDirectory=/var/lib/blekin
Environment=RUST_LOG=ericrfb_proxy=info

View File

@@ -0,0 +1,15 @@
[Unit]
Description=step cert renew for %i.kosherinata.internal
Documentation=https://smallstep.com/docs/step-ca/renewal
[Service]
Type=oneshot
ExecCondition=/usr/bin/step certificate needs-renewal \
/etc/nginx/tls/cert/%i.kosherinata.internal.pem
ExecStart=/usr/bin/step ca renew \
--force \
--ca-url https://ca.internal \
--root /etc/pki/ca-trust/source/anchors/root-internal.pem \
/etc/nginx/tls/cert/%i.kosherinata.internal.pem \
/etc/nginx/tls/key/%i.kosherinata.internal.pem
ExecStartPost=/usr/bin/systemctl reload nginx.service

View File

@@ -3,6 +3,7 @@ import {
TAG_BLIT, TAG_RESIZE,
makeKeyPress, makeKeyRelease, makePointer, makeCtrlAltDel,
} from './protocol'
import { showLogin } from './login'
export function startConsole(
app: HTMLElement,
@@ -15,6 +16,7 @@ export function startConsole(
<span>${boardName}</span>
<button id="btn-cad">Ctrl+Alt+Del</button>
<button id="btn-fs">Fullscreen</button>
<button id="btn-disconnect">Disconnect</button>
<span class="status" id="status">connecting...</span>
</div>
<div class="console-wrap">
@@ -26,29 +28,74 @@ export function startConsole(
const ctx = canvas.getContext('2d')!
const statusEl = document.getElementById('status')!
// WebSocket
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${wsProto}//${location.host}/api/ws?applet_id=${encodeURIComponent(appletId)}&port=${port}`
const ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer'
ws.onopen = () => {
statusEl.textContent = 'connected'
statusEl.classList.add('connected')
canvas.focus()
function setStatus(text: string, connected: boolean) {
statusEl.textContent = text
statusEl.classList.toggle('connected', connected)
}
ws.onclose = () => {
statusEl.textContent = 'disconnected'
statusEl.classList.remove('connected')
// WebSocket with reconnect
let ws: WebSocket | null = null
let reconnectTimer: number | undefined
let reconnectDelay = 1000
function connect() {
if (reconnectTimer) clearTimeout(reconnectTimer)
setStatus('connecting...', false)
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${wsProto}//${location.host}/api/ws?applet_id=${encodeURIComponent(appletId)}&port=${port}`
ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer'
ws.onopen = () => {
setStatus('connected', true)
reconnectDelay = 1000
canvas.focus()
}
ws.onclose = () => {
setStatus(`disconnected — reconnecting in ${reconnectDelay / 1000}s...`, false)
scheduleReconnect()
}
ws.onerror = () => {
setStatus('connection error', false)
}
ws.onmessage = handleMessage
}
ws.onerror = () => {
statusEl.textContent = 'error'
statusEl.classList.remove('connected')
function scheduleReconnect() {
reconnectTimer = window.setTimeout(() => {
// Re-login to get a fresh APPLET_ID, then reconnect
relogin()
}, reconnectDelay)
reconnectDelay = Math.min(reconnectDelay * 2, 30000)
}
ws.onmessage = (ev: MessageEvent) => {
async function relogin() {
setStatus('re-authenticating...', false)
try {
const resp = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: sessionStorage.getItem('blekin_user') || 'administrator',
password: sessionStorage.getItem('blekin_pass') || '',
}),
})
if (!resp.ok) throw new Error('re-auth failed')
const data = await resp.json()
appletId = data.applet_id
port = data.port
connect()
} catch {
setStatus(`re-auth failed — retry in ${reconnectDelay / 1000}s...`, false)
scheduleReconnect()
}
}
function handleMessage(ev: MessageEvent) {
if (!(ev.data instanceof ArrayBuffer)) return
const view = new DataView(ev.data)
const tag = view.getUint8(0)
@@ -80,7 +127,7 @@ export function startConsole(
canvas.addEventListener('keydown', (e) => {
e.preventDefault()
const sc = codeToScancode(e.code)
if (sc !== undefined && ws.readyState === WebSocket.OPEN) {
if (sc !== undefined && ws?.readyState === WebSocket.OPEN) {
ws.send(makeKeyPress(sc))
}
})
@@ -88,14 +135,14 @@ export function startConsole(
canvas.addEventListener('keyup', (e) => {
e.preventDefault()
const sc = codeToScancode(e.code)
if (sc !== undefined && ws.readyState === WebSocket.OPEN) {
if (sc !== undefined && ws?.readyState === WebSocket.OPEN) {
ws.send(makeKeyRelease(sc))
}
})
// Mouse input
function sendPointer(e: MouseEvent) {
if (ws.readyState !== WebSocket.OPEN) return
if (ws?.readyState !== WebSocket.OPEN) return
const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
@@ -131,22 +178,20 @@ export function startConsole(
canvas.addEventListener('wheel', (e) => {
e.preventDefault()
if (ws.readyState !== WebSocket.OPEN) return
if (ws?.readyState !== WebSocket.OPEN) return
const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
const x = Math.round((e.clientX - rect.left) * scaleX)
const y = Math.round((e.clientY - rect.top) * scaleY)
// Scroll up = button 4 (bit 3), scroll down = button 5 (bit 4)
const scrollMask = e.deltaY < 0 ? 8 : 16
ws.send(makePointer(x, y, buttonMask | scrollMask))
// Release scroll button immediately
ws.send(makePointer(x, y, buttonMask))
})
// Toolbar
document.getElementById('btn-cad')!.addEventListener('click', () => {
if (ws.readyState === WebSocket.OPEN) {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(makeCtrlAltDel())
}
canvas.focus()
@@ -160,4 +205,14 @@ export function startConsole(
}
canvas.focus()
})
document.getElementById('btn-disconnect')!.addEventListener('click', () => {
if (reconnectTimer) clearTimeout(reconnectTimer)
ws?.close()
ws = null
showLogin(app)
})
// Start connection
connect()
}

View File

@@ -46,6 +46,9 @@ export function showLogin(app: HTMLElement) {
}
const data: LoginResponse = await resp.json()
// Store credentials for automatic reconnect
sessionStorage.setItem('blekin_user', username)
sessionStorage.setItem('blekin_pass', password)
startConsole(app, data.applet_id, data.port, data.board_name)
} catch (err) {
errorDiv.textContent = (err as Error).message

View File

@@ -1,2 +1,3 @@
pub mod hextile;
pub mod raw_tile;
pub mod tight;

View File

@@ -0,0 +1,103 @@
use std::io::Read;
use crate::framebuffer::Framebuffer;
use crate::proto::{self, read_exact, read_i8};
/// Decode encoding 10 (Raw with tile interleave).
///
/// Reads 1 flag byte. If bit 0 is clear, falls back to plain Raw.
/// If bit 0 is set, reads w*h bytes of 16x16 tile-interleaved data
/// and deinterleaves to row-major before blitting.
///
/// Reference: ByteColorRFBRenderer.for() line 109.
pub fn decode_raw_tile(
r: &mut impl Read,
fb: &mut Framebuffer,
rx: u16,
ry: u16,
rw: u16,
rh: u16,
) -> proto::Result<()> {
let flag = read_i8(r)?;
let w = rw as usize;
let h = rh as usize;
let size = w * h;
if flag & 1 == 0 {
// Plain raw — no interleave
let data = read_exact(r, size)?;
fb.apply_raw(rx, ry, rw, rh, &data);
return Ok(());
}
// Read tile-interleaved data
let interleaved = read_exact(r, size)?;
// Clamp to framebuffer bounds
let w = w.min(fb.width as usize - rx as usize);
let h = h.min(fb.height as usize - ry as usize);
// Deinterleave 16x16 tiles to row-major.
// Input is stored tile-by-tile: all pixels of tile (0,0), then tile (1,0), etc.
// Each tile is row-major within itself.
let tile = 16usize;
let tiles_x = w.div_ceil(tile);
let mut output = vec![0u8; w * h];
for row in 0..h {
let tile_row = row / tile;
let row_in_tile = row % tile;
for col in 0..w {
let tile_col = col / tile;
let col_in_tile = col % tile;
// Tile index in raster order
let tile_idx = tile_row * tiles_x + tile_col;
// Tile dimensions (edge tiles may be smaller)
let tw = tile.min(w - tile_col * tile);
// Offset within tile data: preceding full tiles + row offset + col offset
let mut tile_data_start = 0usize;
// Sum sizes of all preceding tiles
for t in 0..tile_idx {
let tr = t / tiles_x;
let tc = t % tiles_x;
let this_tw = tile.min(w - tc * tile);
let this_th = tile.min(h - tr * tile);
tile_data_start += this_tw * this_th;
}
let src = tile_data_start + row_in_tile * tw + col_in_tile;
output[row * w + col] = interleaved[src];
}
}
fb.apply_raw(rx, ry, w as u16, h as u16, &output);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn test_raw_tile_plain_fallback() {
let mut fb = Framebuffer::new(4, 4);
// Flag byte with bit 0 clear → plain raw
let mut data = vec![0x00i8 as u8]; // flag
data.extend_from_slice(&[0x42; 16]); // 4x4 pixels
let mut c = Cursor::new(data);
decode_raw_tile(&mut c, &mut fb, 0, 0, 4, 4).unwrap();
assert!(fb.pixels.iter().all(|&p| p == 0x42));
}
#[test]
fn test_raw_tile_small_no_tile_boundary() {
let mut fb = Framebuffer::new(4, 4);
// Flag byte with bit 0 set, but 4x4 < 16x16 so no tile wrap occurs
let mut data = vec![0x01u8]; // flag with interleave
data.extend_from_slice(&[0xAA; 16]); // 4x4 pixels (all same, no deinterleave effect)
let mut c = Cursor::new(data);
decode_raw_tile(&mut c, &mut fb, 0, 0, 4, 4).unwrap();
assert!(fb.pixels.iter().all(|&p| p == 0xAA));
}
}

View File

@@ -1,7 +1,7 @@
use std::io::{BufReader, BufWriter};
use std::net::TcpStream;
use crate::codec::{hextile, tight};
use crate::codec::{hextile, raw_tile, tight};
use crate::framebuffer::Framebuffer;
use crate::handshake::{self, Config, ServerInit};
use crate::msg::{self, ServerMsg};
@@ -200,7 +200,6 @@ impl ActiveSession {
.copy_rect(src_x, src_y, hdr.x, hdr.y, hdr.w, hdr.h);
}
5 => {
// Hextile
hextile::decode_hextile(
&mut self.reader,
&mut self.framebuffer,
@@ -222,6 +221,16 @@ impl ActiveSession {
hdr.h,
)?;
}
10 => {
raw_tile::decode_raw_tile(
&mut self.reader,
&mut self.framebuffer,
hdr.x,
hdr.y,
hdr.w,
hdr.h,
)?;
}
other => {
return Err(SessionError::UnsupportedEncoding(other));
}

136
script/setup.sh Executable file
View File

@@ -0,0 +1,136 @@
#!/usr/bin/env bash
ui_host=oolon.kosherinata.internal
ws_host=frootmig.kosherinata.internal
app_fqdn=blekin.kosherinata.internal
repo_path=~/git/grenade/blekin
fedora_trusted_root_path=/etc/pki/ca-trust/source/anchors/root-internal.pem
fedora_intermediate_path=/etc/pki/ca-trust/source/anchors/intermediate-internal.pem
if ssh ${ws_host} 'id blekin 2> /dev/null || sudo useradd --system --create-home --home-dir /var/lib/blekin --user-group blekin'; then
echo "blekin system user created or observed on ${ws_host}"
else
echo "failed to create blekin system user on ${ws_host}"
exit 1
fi
if rsync \
--archive \
--compress \
--rsync-path 'sudo rsync' \
--chown root:root \
${repo_path}/asset/systemd/blekin.service \
${ws_host}:/etc/systemd/system/blekin.service \
&& ssh ${ws_host} sudo systemctl daemon-reload; then
echo "blekin.service synced to ${ws_host}"
else
echo "failed to sync blekin.service to ${ws_host}"
exit 1
fi
if ssh ${ws_host} systemctl is-active --quiet blekin.service; then
if ssh ${ws_host} sudo systemctl restart blekin.service; then
echo "blekin.service restarted on ${ws_host}"
else
echo "failed to restart blekin.service on ${ws_host}"
exit 1
fi
else
if ssh ${ws_host} sudo systemctl start blekin.service; then
echo "blekin.service started on ${ws_host}"
else
echo "failed to start blekin.service on ${ws_host}"
exit 1
fi
fi
app_cert_is_valid=false
app_cert_remote_path=/etc/nginx/tls/cert/${app_fqdn}.pem
app_key_remote_path=/etc/nginx/tls/key/${app_fqdn}.pem
app_cert_local_path=/tmp/${app_fqdn}.pem
if rsync \
--archive \
--compress \
--rsync-path 'sudo rsync' \
${ui_host}:${app_cert_remote_path} \
${app_cert_local_path} 2> /dev/null; then
if openssl verify \
-trusted ${fedora_trusted_root_path} \
-untrusted ${fedora_intermediate_path} \
${app_cert_local_path}; then
echo "verified ${app_fqdn} cert from ${ui_host}"
app_cert_is_valid=true
else
echo "failed to verify ${app_fqdn} cert from ${ui_host}"
exit 1
fi
else
echo "observed missing ${app_fqdn} cert on ${ui_host}"
fi
if [ "${app_cert_is_valid}" = "true" ]; then
echo "observed valid cert for ${app_fqdn} on ${ui_host}"
else
if rsync \
--archive \
--compress \
--rsync-path 'sudo rsync' \
--chmod 600 \
--chown root:root \
~/.step/secrets/provisioner \
${ui_host}:/tmp/provisioner; then
echo "provisioner secret synced to ${ui_host}"
else
echo "failed to sync provisioner secret to ${ui_host}"
exit 1
fi
if ssh ${ui_host} sudo step ca certificate \
--force \
--provisioner lair \
--provisioner-password-file /tmp/provisioner \
--ca-url https://ca.internal \
--root /etc/pki/ca-trust/source/anchors/root-internal.pem \
--san ${app_fqdn} \
${app_fqdn} \
${app_cert_remote_path} \
${app_key_remote_path}; then
echo "acquired ${app_fqdn} cert on ${ui_host}"
else
echo "failed to acquire ${app_fqdn} cert on ${ui_host}"
fi
ssh ${ui_host} sudo rm -f /tmp/provisioner
fi
if rsync \
--archive \
--compress \
--rsync-path 'sudo rsync' \
--chown root:root \
${repo_path}/asset/nginx/${app_fqdn}.conf \
${ui_host}:/etc/nginx/sites-available/${app_fqdn}.conf; then
echo "${app_fqdn}.conf synced to ${ui_host}"
else
echo "failed to sync ${app_fqdn}.conf to ${ui_host}"
fi
if ssh ${ui_host} sudo ln -sf /etc/nginx/sites-available/${app_fqdn}.conf /etc/nginx/sites-enabled/${app_fqdn}.conf; then
echo "${app_fqdn} enabled on ${ui_host}"
else
echo "failed to enable ${app_fqdn} on ${ui_host}"
fi
if ssh ${ui_host} 'sudo nginx -t && sudo systemctl reload nginx.service'; then
echo "nginx reloaded on ${ui_host}"
else
echo "failed to reload nginx on ${ui_host}"
fi
# todo:
# frootmig:
# sudo useradd --system --create-home --home-dir /var/lib/blekin --user-group blekin
# sync asset/sudoers.d/ws_gitea_ci to /etc/sudoers.d/gitea_ci
# oolon:
# ssh ${ui_host} sudo mkdir -p /etc/nginx/tls/${app_fqdn}
# sudo semanage fcontext -a -t httpd_sys_content_t "/var/www/blekin.kosherinata.internal(/.*)?"
# sudo restorecon -Rv /var/www/blekin.kosherinata.internal/
# sync asset/sudoers.d/ui_gitea_ci to /etc/sudoers.d/gitea_ci