mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-25 19:56:46 +00:00
292 lines
12 KiB
HTML
292 lines
12 KiB
HTML
<!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">▶</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 = '▮▮';
|
|
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 = '▶';
|
|
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>
|