mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-26 04:06:38 +00:00
Add embedded VNC server with JWT auth, DXGI capture, and dashboard integration
This commit is contained in:
291
client/vnc/server/webplayer.html
Normal file
291
client/vnc/server/webplayer.html
Normal 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">▶</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>
|
||||
Reference in New Issue
Block a user