Browser-based remote control (noVNC) with invite links, per-user access control, garagedoor SSO and a persisted client list. The hub proxies RFB rather than pointing the browser at a VNC server. That is what lets it authenticate upstream with a stored password the browser never sees, and enforce view-only by dropping input messages on the client->server stream instead of hiding buttons. Machines are reachable two ways: direct TCP for LAN hosts, or an outbound agent tunnel for anything behind NAT. Node 22's global WebSocket keeps the agent dependency-free, and node:sqlite keeps the image free of native builds. Ships with an end-to-end suite that boots the real server against a fake VNC server and a fake auth service (72 assertions), plus Gitea Actions CI/CD to Portainer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
166 lines
5.1 KiB
HTML
166 lines
5.1 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="color-scheme" content="dark">
|
|
<title>Remote Support</title>
|
|
<link rel="stylesheet" href="/styles.css">
|
|
</head>
|
|
<body>
|
|
|
|
<!-- ambient particle field: login screen full strength, console dimmed ------ -->
|
|
<div class="bg-field" id="bg-field" aria-hidden="true"></div>
|
|
|
|
<!-- login ---------------------------------------------------------------- -->
|
|
<div id="login" class="login-shell hidden">
|
|
|
|
<section class="login-aside">
|
|
<div class="brand">
|
|
<div class="brand-mark">RC</div>
|
|
<div>
|
|
<h1>Remote Support</h1>
|
|
<span class="brand-sub">Operator console</span>
|
|
</div>
|
|
</div>
|
|
|
|
<h2 class="login-headline">Sit down at any desk<br>on the <em>network</em>.</h2>
|
|
<p class="login-lede">
|
|
Screen sharing and remote control for the machines you look after — running
|
|
on your own hardware, on your own wire. Nothing leaves the building.
|
|
</p>
|
|
|
|
<ul class="login-facts">
|
|
<li>Self-hosted · no cloud relay</li>
|
|
<li>Consent prompt on the remote desk</li>
|
|
<li>Every session recorded in the audit log</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<form class="login-card" id="login-form">
|
|
<div class="brand card-brand">
|
|
<div class="brand-mark">RC</div>
|
|
<div>
|
|
<h1>Remote Support</h1>
|
|
<span class="brand-sub">Operator console</span>
|
|
</div>
|
|
</div>
|
|
|
|
<h2 class="form-title">Sign in</h2>
|
|
<p class="faint form-note">Use your usual account.</p>
|
|
|
|
<div id="login-error" class="notice error hidden"></div>
|
|
|
|
<div class="field">
|
|
<label for="username">Username</label>
|
|
<input id="username" name="username" autocomplete="username" required autofocus>
|
|
</div>
|
|
<div class="field">
|
|
<label for="password">Password</label>
|
|
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
|
</div>
|
|
|
|
<button class="primary" type="submit" id="login-button">Sign in</button>
|
|
|
|
<p class="login-foot">Sessions expire after one hour</p>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- app ------------------------------------------------------------------ -->
|
|
<div id="app" class="hidden">
|
|
<header class="topbar">
|
|
<div class="brand">
|
|
<div class="brand-mark">RC</div>
|
|
<div>
|
|
<h1>Remote Support</h1>
|
|
<span class="brand-sub">Operator console</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="topbar-divider"></div>
|
|
|
|
<nav class="tabs" id="tabs">
|
|
<button data-tab="machines" class="active">Machines</button>
|
|
<button data-tab="invites">Invites</button>
|
|
<button data-tab="sessions">Sessions</button>
|
|
<button data-tab="audit">Audit</button>
|
|
</nav>
|
|
|
|
<div class="spacer"></div>
|
|
<span class="faint" id="whoami"></span>
|
|
<button class="ghost small" id="logout">Sign out</button>
|
|
</header>
|
|
|
|
<main>
|
|
<!-- machines -->
|
|
<section id="tab-machines">
|
|
<div class="panel-head">
|
|
<h2>Machines</h2>
|
|
<span class="faint" id="machines-count"></span>
|
|
<div class="spacer"></div>
|
|
<button class="small" id="btn-enroll-invite">Invite a machine</button>
|
|
<button class="primary small" id="btn-add-client">Add machine</button>
|
|
</div>
|
|
<div id="machines" class="grid"></div>
|
|
</section>
|
|
|
|
<!-- invites -->
|
|
<section id="tab-invites" class="hidden">
|
|
<div class="panel-head">
|
|
<h2>Invite links</h2>
|
|
<div class="spacer"></div>
|
|
<button class="small" id="btn-new-enroll">New enrollment link</button>
|
|
<button class="primary small" id="btn-new-share">New support link</button>
|
|
</div>
|
|
<div id="invites"></div>
|
|
</section>
|
|
|
|
<!-- sessions -->
|
|
<section id="tab-sessions" class="hidden">
|
|
<div class="panel-head">
|
|
<h2>Live now</h2>
|
|
<div class="spacer"></div>
|
|
<button class="ghost small" id="btn-refresh-sessions">Refresh</button>
|
|
</div>
|
|
<div id="live-sessions"></div>
|
|
|
|
<div class="panel-head" style="margin-top:34px">
|
|
<h2>History</h2>
|
|
</div>
|
|
<div id="session-history"></div>
|
|
</section>
|
|
|
|
<!-- audit -->
|
|
<section id="tab-audit" class="hidden">
|
|
<div class="panel-head"><h2>Audit log</h2></div>
|
|
<div id="audit"></div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
|
|
<div id="modal-root"></div>
|
|
|
|
<script src="/app.js"></script>
|
|
|
|
<!-- Particle field. Purely decorative; app.js is untouched by this. -->
|
|
<script type="module">
|
|
import { mountParticles } from '/particles.js';
|
|
|
|
const layer = document.getElementById('bg-field');
|
|
const field = mountParticles(layer);
|
|
|
|
// app.js flips .hidden on #login / #app. Mirror that onto <body data-screen>
|
|
// so the field can dim itself, and freeze the animation inside the console.
|
|
const app = document.getElementById('app');
|
|
const sync = () => {
|
|
const inConsole = !app.classList.contains('hidden');
|
|
document.body.dataset.screen = inConsole ? 'console' : 'login';
|
|
field.setMode(inConsole ? 'static' : 'animate');
|
|
};
|
|
|
|
new MutationObserver(sync).observe(app, { attributes: true, attributeFilter: ['class'] });
|
|
sync();
|
|
</script>
|
|
</body>
|
|
</html>
|