Add embedded VNC server with JWT auth, DXGI capture, and dashboard integration

This commit is contained in:
Viktor Liu
2026-04-14 12:31:00 +02:00
parent 3098f48b25
commit b754df1171
85 changed files with 10457 additions and 2011 deletions

View File

@@ -0,0 +1,291 @@
<!DOCTYPE html>
<html>
<head>
<title>NetBird - VNC Session Recording</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; flex-direction: column; height: 100vh; }
#header { background: #161b22; padding: 10px 20px; display: flex; align-items: center; gap: 16px; border-bottom: 1px solid #30363d; flex-wrap: wrap; }
.logo { display: flex; align-items: center; gap: 8px; }
.logo svg { width: 20px; height: 20px; }
.logo-text { font-weight: 600; font-size: 14px; color: #f0f6fc; }
.logo-badge { font-size: 11px; background: #f4722b; color: #fff; padding: 1px 7px; border-radius: 10px; font-weight: 500; }
#rec-info { font-size: 12px; color: #8b949e; display: flex; gap: 6px; flex-wrap: wrap; }
#rec-info span { background: #21262d; padding: 2px 8px; border-radius: 4px; }
#rec-info .label { color: #6e7681; }
#controls { background: #161b22; padding: 6px 20px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid #30363d; }
#controls button { background: #21262d; color: #e6edf3; border: 1px solid #30363d; padding: 4px 14px; border-radius: 6px; cursor: pointer; font-size: 14px; min-width: 36px; }
#controls button:hover { background: #30363d; border-color: #8b949e; }
#controls button.active { background: #f4722b; border-color: #f4722b; color: #fff; }
#seek { flex: 1; cursor: pointer; accent-color: #f4722b; height: 4px; margin: 0; padding: 0; }
#speed-select { background: #21262d; color: #e6edf3; border: 1px solid #30363d; padding: 3px 6px; border-radius: 4px; font-size: 12px; }
#time { font-size: 12px; font-variant-numeric: tabular-nums; min-width: 90px; text-align: center; color: #8b949e; }
#frame-info { font-size: 11px; color: #6e7681; }
#canvas-wrap { flex: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; background: #010409; }
canvas { max-width: 100%; max-height: 100%; }
#footer { background: #161b22; padding: 5px 20px; font-size: 11px; color: #484f58; border-top: 1px solid #30363d; display: flex; justify-content: space-between; }
#status { font-size: 12px; color: #8b949e; }
</style>
</head>
<body>
<div id="header">
<div class="logo">
<svg width="24" height="18" viewBox="0 0 31 23" fill="none"><path d="M21.4631 0.523438C17.8173 0.857913 16.0028 2.95675 15.3171 4.01871L4.66406 22.4734H17.5163L30.1929 0.523438H21.4631Z" fill="#F68330"/><path d="M17.5265 22.4737L0 3.88525C0 3.88525 19.8177 -1.44128 21.7493 15.1738L17.5265 22.4737Z" fill="#F68330"/><path d="M14.9236 4.70563L9.54688 14.0208L17.5158 22.4747L21.7385 15.158C21.0696 9.44682 18.2851 6.32784 14.9236 4.69727" fill="#F05252"/></svg>
<span class="logo-text">NetBird</span>
<span class="logo-badge">VNC Session Recording</span>
</div>
<div id="rec-info"></div>
</div>
<div id="controls">
<button id="playBtn" onclick="togglePlay()" title="Space">&#9654;</button>
<input type="range" id="seek" min="0" max="1000" value="0" oninput="seekTo(this.value)">
<span id="time">0:00 / 0:00</span>
<select id="speed-select" onchange="setSpeed(this.value)" title="Playback speed">
<option value="0.25">0.25x</option>
<option value="0.5">0.5x</option>
<option value="1" selected>1x</option>
<option value="2">2x</option>
<option value="4">4x</option>
<option value="8">8x</option>
</select>
<span id="frame-info"></span>
<span id="status">Loading...</span>
</div>
<div id="canvas-wrap"><canvas id="canvas"></canvas></div>
<div id="footer">
<span>Space: play/pause | Left/Right: seek 5s | Scroll: speed</span>
<span id="file-info"></span>
</div>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const seekBar = document.getElementById('seek');
const timeEl = document.getElementById('time');
const statusEl = document.getElementById('status');
const recInfoEl = document.getElementById('rec-info');
const frameInfoEl = document.getElementById('frame-info');
const fileInfoEl = document.getElementById('file-info');
const playBtn = document.getElementById('playBtn');
let frames = []; // { offsetMs, bitmap }
let header = null;
let playing = false;
let speed = 1;
let startTime = 0;
let pauseOffset = 0;
let currentFrame = 0;
let animId = null;
let durationMs = 0;
function fmt(ms) {
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
if (h > 0) return `${h}:${String(m % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
return `${m}:${String(s % 60).padStart(2, '0')}`;
}
function fmtSize(bytes) {
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
return bytes + ' B';
}
async function load() {
statusEl.textContent = 'Fetching...';
const resp = await fetch('/recording.rec');
const buf = await resp.arrayBuffer();
const view = new DataView(buf);
const magic = new TextDecoder().decode(new Uint8Array(buf, 0, 6));
if (magic !== 'NBVNC\x01') { statusEl.textContent = 'Invalid recording file'; return; }
const width = view.getUint16(6);
const height = view.getUint16(8);
const startMs = Number(view.getBigUint64(10));
const metaLen = view.getUint32(18);
const metaJSON = new TextDecoder().decode(new Uint8Array(buf, 22, metaLen));
const meta = JSON.parse(metaJSON);
header = { width, height, startMs, meta };
canvas.width = width;
canvas.height = height;
const dateStr = new Date(startMs).toLocaleString();
const parts = [];
if (meta.mode) parts.push(`<span><span class="label">Type:</span> vnc (${meta.mode})</span>`);
if (meta.remote_addr) parts.push(`<span><span class="label">Remote:</span> ${meta.remote_addr}</span>`);
if (meta.jwt_user) parts.push(`<span><span class="label">JWT:</span> ${meta.jwt_user}</span>`);
if (meta.user) parts.push(`<span><span class="label">User:</span> ${meta.user}</span>`);
parts.push(`<span><span class="label">Date:</span> ${dateStr}</span>`);
parts.push(`<span>${width}x${height}</span>`);
recInfoEl.innerHTML = parts.join('');
fileInfoEl.textContent = fmtSize(buf.byteLength);
document.title = `NetBird - VNC Session Recording - ${meta.remote_addr || ''}`;
// Parse frame offsets (fast pass, no decoding)
const rawFrames = [];
let offset = 22 + metaLen;
while (offset + 8 <= buf.byteLength) {
const offsetMs = view.getUint32(offset);
const pngLen = view.getUint32(offset + 4);
offset += 8;
if (offset + pngLen > buf.byteLength) break;
rawFrames.push({ offsetMs, start: offset, length: pngLen });
offset += pngLen;
}
if (rawFrames.length === 0) { statusEl.textContent = 'No frames in recording'; return; }
// Handle encrypted recordings
let decryptKey = null;
if (meta.encrypted) {
const privKeyB64 = prompt('This recording is encrypted.\nPaste your base64-encoded X25519 private key:');
if (!privKeyB64) { statusEl.textContent = 'Decryption key required'; return; }
try {
decryptKey = await deriveDecryptKey(privKeyB64, meta.ephemeral_key);
} catch (e) {
statusEl.textContent = 'Decryption key error: ' + e.message;
return;
}
}
// Decode PNG frames to ImageData. We decode bitmaps in parallel batches,
// then draw them sequentially to avoid OffscreenCanvas races.
const offscreen = new OffscreenCanvas(width, height);
const offCtx = offscreen.getContext('2d');
const batchSize = 20;
for (let i = 0; i < rawFrames.length; i += batchSize) {
const batch = rawFrames.slice(i, i + batchSize);
const bitmaps = await Promise.all(batch.map(async (f, batchIdx) => {
let pngData = new Uint8Array(buf, f.start, f.length);
if (decryptKey) {
const frameIdx = i + batchIdx;
pngData = await decryptFrame(decryptKey, pngData, frameIdx);
}
const blob = new Blob([pngData], { type: 'image/png' });
return createImageBitmap(blob);
}));
for (let j = 0; j < bitmaps.length; j++) {
offCtx.drawImage(bitmaps[j], 0, 0);
bitmaps[j].close();
frames.push({ offsetMs: batch[j].offsetMs, imgData: offCtx.getImageData(0, 0, width, height) });
}
statusEl.textContent = `Loading ${frames.length}/${rawFrames.length}`;
if (i % (batchSize * 3) === 0) await new Promise(r => setTimeout(r, 0));
}
const firstMs = frames[0].offsetMs;
durationMs = frames[frames.length - 1].offsetMs;
seekBar.min = firstMs;
seekBar.max = durationMs;
timeEl.textContent = `0:00 / ${fmt(durationMs)}`;
statusEl.textContent = `${frames.length} frames, ${fmt(durationMs)}`;
renderFrame(0);
}
function renderFrame(idx) {
if (idx < 0 || idx >= frames.length) return;
currentFrame = idx;
const frame = frames[idx];
ctx.putImageData(frame.imgData, 0, 0);
seekBar.value = frame.offsetMs;
timeEl.textContent = `${fmt(frame.offsetMs)} / ${fmt(durationMs)}`;
frameInfoEl.textContent = `${idx + 1}/${frames.length}`;
}
function togglePlay() { playing ? pause() : play(); }
function play() {
if (frames.length === 0) return;
playing = true;
playBtn.innerHTML = '&#9646;&#9646;';
playBtn.classList.add('active');
if (currentFrame >= frames.length - 1) { currentFrame = 0; pauseOffset = 0; }
startTime = performance.now() - pauseOffset / speed;
tick();
}
function pause() {
playing = false;
playBtn.innerHTML = '&#9654;';
playBtn.classList.remove('active');
if (animId) { cancelAnimationFrame(animId); animId = null; }
pauseOffset = frames[currentFrame].offsetMs;
}
function tick() {
if (!playing) return;
const targetMs = (performance.now() - startTime) * speed;
while (currentFrame < frames.length - 1 && frames[currentFrame + 1].offsetMs <= targetMs) currentFrame++;
renderFrame(currentFrame);
if (currentFrame >= frames.length - 1) { pause(); return; }
animId = requestAnimationFrame(tick);
}
function seekTo(val) {
const ms = parseInt(val);
let idx = 0;
for (let i = 0; i < frames.length; i++) {
if (frames[i].offsetMs <= ms) idx = i; else break;
}
renderFrame(idx);
pauseOffset = frames[idx].offsetMs;
if (playing) startTime = performance.now() - pauseOffset / speed;
}
function setSpeed(val) {
const old = speed;
speed = parseFloat(val);
if (playing) startTime = performance.now() - (performance.now() - startTime) * old / speed;
}
document.addEventListener('keydown', e => {
if (e.code === 'Space') { e.preventDefault(); togglePlay(); }
if (e.code === 'ArrowRight') seekTo(Math.min(durationMs, frames[currentFrame].offsetMs + 5000));
if (e.code === 'ArrowLeft') seekTo(Math.max(0, frames[currentFrame].offsetMs - 5000));
});
document.addEventListener('wheel', e => {
const sel = document.getElementById('speed-select');
const idx = sel.selectedIndex + (e.deltaY > 0 ? -1 : 1);
if (idx >= 0 && idx < sel.options.length) { sel.selectedIndex = idx; setSpeed(sel.value); }
}, { passive: true });
// Crypto helpers for encrypted recordings (X25519 ECDH + HKDF + AES-256-GCM)
async function deriveDecryptKey(privKeyB64, ephPubB64) {
const privBytes = Uint8Array.from(atob(privKeyB64), c => c.charCodeAt(0));
const ephPubBytes = Uint8Array.from(atob(ephPubB64), c => c.charCodeAt(0));
const privKey = await crypto.subtle.importKey('raw', privBytes, { name: 'X25519' }, false, ['deriveBits']);
const ephPub = await crypto.subtle.importKey('raw', ephPubBytes, { name: 'X25519' }, false, []);
const shared = await crypto.subtle.deriveBits({ name: 'X25519', public: ephPub }, privKey, 256);
// HKDF-SHA256 with salt=ephemeralPub, info="netbird-recording" (matches Go side)
const hkdfKey = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']);
const aesKey = await crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt: ephPubBytes, info: new TextEncoder().encode('netbird-recording') },
hkdfKey,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt'],
);
return aesKey;
}
async function decryptFrame(key, ciphertext, frameIndex) {
const nonce = new Uint8Array(12);
new DataView(nonce.buffer).setUint32(0, frameIndex, true); // little-endian u64, upper 4 bytes zero
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ciphertext);
return new Uint8Array(plain);
}
load().catch(e => { statusEl.textContent = 'Error: ' + e.message; console.error(e); });
</script>
</body>
</html>