feat: self-hosted remote support over VNC

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>
This commit is contained in:
2026-08-11 23:37:35 -07:00
co-authored by Claude Opus 5
commit 999717f77b
34 changed files with 7057 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
data
test
.git
.env
*.md
!PLAN.md
+32
View File
@@ -0,0 +1,32 @@
# HTTP listener
PORT=8080
HOST=0.0.0.0
# Central auth (garagedoor-node-ws)
AUTH_URL=http://192.168.4.208:8000
# Who counts as an admin. Leave BOTH unset and every authenticated user is an
# admin — fine for a single operator, not for a shared install.
ADMIN_USERS=rmancinas
# ADMIN_LEVEL=10
# Where the SQLite database lives (the encryption key is written next to it)
DB_PATH=/data/rcs.db
# 32+ random chars. Encrypts stored VNC passwords. Generate with:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# If unset, one is generated next to the database on first boot.
ENCRYPTION_KEY=
# Base URL used when rendering invite links. Set this once it is behind a proxy.
PUBLIC_URL=https://remote.mancinas.dev
# Turn off to disable no-login support links entirely
ALLOW_SESSION_INVITES=true
# Tuning
TICKET_TTL_MS=30000
INVITE_TTL_MS=86400000
CONSENT_TIMEOUT_MS=45000
AGENT_OFFLINE_AFTER_MS=90000
TRUST_PROXY=true
+94
View File
@@ -0,0 +1,94 @@
name: Build and Deploy Remote Control Support
on:
push:
branches: [main]
paths:
- "server/**"
- "public/**"
- "agent/**"
- "Dockerfile"
- "docker-compose.yml"
- "package.json"
- "pnpm-lock.yaml"
- ".gitea/workflows/**"
workflow_dispatch:
env:
REGISTRY: git.mancinas.io
jobs:
test:
name: Test
runs-on: docker
container:
image: node:22-alpine
steps:
- uses: actions/checkout@v4
- run: corepack enable
- run: pnpm install --frozen-lockfile
# Boots the real server against a fake VNC server and a fake auth service.
- run: pnpm test
build:
name: Build Image
needs: [test]
runs-on: docker
container:
image: docker:27-dind
options: --privileged
permissions:
contents: read
packages: write
steps:
- name: Install Node.js for actions
run: apk add --no-cache nodejs npm
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/remote-control-support-webapp
tags: |
type=ref,event=branch
type=raw,value=latest,enable={{is_default_branch}}
- uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
deploy:
name: Deploy to Portainer
needs: [build]
runs-on: docker
container:
image: node:18-alpine
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
steps:
- uses: actions/checkout@v4
- uses: cssnr/portainer-stack-deploy-action@v1
with:
url: ${{ secrets.PORTAINER_URL }}
token: ${{ secrets.PORTAINER_API_KEY }}
name: ${{ secrets.PORTAINER_STACK_NAME }}
file: docker-compose.yml
type: file
pull_image: true
endpoint_id: ${{ secrets.PORTAINER_ENDPOINT_ID }}
env_data: |
{
"IMAGE": "${{ env.REGISTRY }}/${{ github.repository_owner }}/remote-control-support-webapp:latest",
"AUTH_URL": "${{ secrets.AUTH_URL }}",
"ADMIN_USERS": "${{ secrets.ADMIN_USERS }}",
"ENCRYPTION_KEY": "${{ secrets.ENCRYPTION_KEY }}",
"PUBLIC_URL": "${{ secrets.PUBLIC_URL }}"
}
+5
View File
@@ -0,0 +1,5 @@
node_modules/
data/
*.log
.env
.DS_Store
+25
View File
@@ -0,0 +1,25 @@
FROM node:22-alpine
# node:sqlite is built into Node 22, so there are no native modules to compile
# and no build stage to carry around.
ENV NODE_ENV=production \
PORT=8080 \
DB_PATH=/data/rcs.db
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml* ./
RUN pnpm install --prod --frozen-lockfile
COPY server ./server
COPY public ./public
COPY agent ./agent
VOLUME ["/data"]
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD wget -qO- http://127.0.0.1:8080/api/health || exit 1
CMD ["node", "server/index.js"]
+170
View File
@@ -0,0 +1,170 @@
# Remote Control Support Webapp — Plan
Self-hosted TeamViewer-style remote support tool built on **VNC/RFB**, browser-based
(noVNC), with invite links, access control, garagedoor SSO, and a persisted client list.
Status legend: `[ ]` todo · `[~]` in progress · `[x]` done
---
## 1. Architecture
```
browser (noVNC) hub (this app) client machine
┌───────────────────────┐ ┌────────────────────────┐ ┌───────────────────┐
│ viewer.html │ wss │ express + ws │ │ VNC server :5900 │
│ RFB over WebSocket │◄──────►│ /vnc?ticket=… │ │ │
└───────────────────────┘ │ │ │ │
│ bridge: │ tcp │ │
│ direct mode ─────────┼───────►│ │
│ │ │ │
│ agent mode │ wss │ agent.js │
│ /agent (control) ◄───┼────────┤ outbound only │
│ /tunnel (data) ◄───┼────────┤ pipes to :5900 │
└────────────────────────┘ └───────────────────┘
SQLite (clients, grants,
invites, sessions, audit)
```
**Two connection modes** — a client row is one or the other:
| Mode | How | Use for |
|---|---|---|
| `direct` | Hub opens TCP to `host:port`. | LAN machines with a reachable VNC server. |
| `agent` | Client runs `agent/agent.js`, dials **out** to the hub over WSS and holds it open. Hub asks it to open a data tunnel per session. | NAT'd / remote machines. The TeamViewer-shaped path. |
**Why a proxy and not raw noVNC:** the hub terminates the RFB handshake itself. That
lets it (a) authenticate to the real VNC server with a **server-side stored password**
the browser never sees, and (b) enforce **view-only** by dropping input messages on the
client→server stream. Both are required for invite links to be safe.
## 2. Tech stack
- Node 22, CommonJS, Express 4 — matches `stash-ex-webapp`.
- Deployed by Gitea Actions to Portainer's Swarm endpoint; single replica, pinned
placement, named volume (see README).
- `ws` for all WebSocket endpoints; Node's built-in `node:sqlite` for persistence
(no native modules, so the container has no build stage).
- `@novnc/novnc` vendored and served as browser ES modules.
- Vanilla JS frontend in `public/` (no build step).
- Auth delegated to **garagedoor-node-ws** (`http://192.168.4.208:8000`) — proxy pattern,
never hold the JWT secret locally.
## 3. Data model (SQLite)
- `clients` — id, name, mode, host/port, encrypted VNC password, agent key hash,
`require_consent`, tags, os, agent_version, last_seen_at, status, created_by.
- `grants` — per-user access to a client with a role and optional expiry.
- `invites` — hashed token, kind (`enroll` | `session`), target client, role, max_uses,
uses, expiry, revocation.
- `sessions` — audit trail of every connection: who, what client, role, bytes, duration,
source (`web` | `invite`), end reason.
- `audit` — admin actions (client created/deleted, invite issued/revoked, grant changed).
Secrets at rest (VNC passwords, agent keys) are AES-256-GCM encrypted with a key derived
from `ENCRYPTION_KEY`. Invite and agent tokens are stored **hashed**, never plaintext.
## 4. Access control model
Roles resolved per (user, client) at connect time:
| Role | Can |
|---|---|
| `admin` | Everything: CRUD clients, issue/revoke invites, manage grants, view audit. |
| `operator` | Connect with full keyboard/mouse control to granted clients. |
| `viewer` | Connect **view-only** (input filtered at the proxy) to granted clients. |
- Admin = garagedoor username in `ADMIN_USERS`, or garagedoor `level``ADMIN_LEVEL`.
- Non-admins see only clients they hold a grant for.
- `require_consent` on a client makes the agent prompt the local user before each session.
- Every WS connect uses a **one-time, 30-second ticket** minted by `POST /api/sessions`,
so long-lived JWTs never appear in URLs or proxy logs.
## 5. Invite links — two kinds
1. **Enrollment invite** `/enroll/<token>` — hand to a machine you want to manage. Page
shows the install one-liner with a single-use token baked in; running it registers the
machine as a client and it appears in the list.
2. **Session invite** `/s/<token>` — hand to a person. Time-limited, use-limited access to
**one** client at a fixed role (`viewer` or `operator`), no login required. This is the
"send the customer a link" flow.
Both are revocable, expiring, and logged.
---
## 6. Feature checklist
### Phase 1 — Foundation
- [x] Project scaffold, package.json, env config, .gitignore
- [x] SQLite schema + migrations on boot
- [x] Secret encryption helper (AES-256-GCM)
- [x] garagedoor auth proxy + `requireAuth` / `requireAdmin` middleware
- [x] Login screen, token in localStorage, 401 → re-login
### Phase 2 — Clients & persistence
- [x] `GET/POST/PATCH/DELETE /api/clients`
- [x] Client list UI with status, tags, last-seen
- [x] Add/edit client form (direct mode: host/port/password)
- [x] Online/offline status tracking for agent clients
### Phase 3 — VNC bridge
- [x] WS↔TCP bridge for direct mode
- [x] Server-side RFB handshake + VNC Authentication (password never reaches browser)
- [x] View-only enforcement by filtering client→server RFB messages
- [x] noVNC viewer page: scaling, fullscreen, clipboard, Ctrl-Alt-Del
- [x] One-time session tickets
### Phase 4 — Agent (NAT traversal)
- [x] Agent control channel `/agent` with heartbeat + reconnect
- [x] Data tunnel `/tunnel` paired to a waiting browser socket
- [x] `agent/agent.js` — zero npm dependencies, uses Node 22's global WebSocket
- [x] Enrollment via invite token → agent key issued once
- [x] Local consent prompt when `require_consent` is set
### Phase 5 — Invites & access control
- [x] Issue/list/revoke invites (both kinds)
- [x] `/enroll/<token>` enrollment page
- [x] `/s/<token>` session invite page (no login)
- [x] Per-user grants CRUD
- [x] Role resolution + enforcement at connect
### Phase 6 — Audit & operations
- [x] Session history table + live "who is connected now"
- [x] Audit log of admin actions
- [x] Admin can force-disconnect an active session
- [x] Dockerfile + docker-compose + Gitea Actions CI/CD to Portainer
- [x] README with deploy + client setup instructions
- [x] End-to-end test suite (`pnpm test`) — fake VNC server + fake auth service,
72 assertions covering auth, RBAC, VNC auth, view-only, invites, agent tunnel
### Phase 7 — Front end polish
- [~] Modern visual design pass across all five pages
- [~] Particle background (network-of-machines motif), reduced-motion aware,
never rendered behind a live VNC canvas
### Phase 8 — Later / nice to have
- [ ] File transfer between operator and client
- [ ] Clipboard sync toggle per session
- [ ] Multi-monitor selection
- [ ] Session recording (RFB stream capture + replay)
- [ ] Chat sidebar during a support session
- [ ] Wake-on-LAN integration (sibling `wol-fleet-webapp`)
- [ ] TOTP step-up before controlling a flagged client
- [ ] Agent auto-update
- [ ] RDP backend alongside VNC
---
## 7. Security notes
- The hub is the only thing that knows VNC passwords; browsers get an RFB stream that has
already cleared authentication.
- View-only is enforced **server-side**, not by hiding UI.
- Invite tokens: 32 bytes of `crypto.randomBytes`, stored as SHA-256, compared in constant
time, single-use by default.
- Deploy behind HTTPS (see the `lan-https-host` setup) — RFB over plain `ws://` is
cleartext framebuffer data.
- Known upstream debt in garagedoor (SQL injection, hardcoded secret) is *not* inherited:
this app never touches that DB or that secret.
+209
View File
@@ -0,0 +1,209 @@
# Remote Control Support
Self-hosted remote support over VNC. A TeamViewer-shaped console that runs in the
browser: pick a machine, get its screen, take over the mouse and keyboard.
- **noVNC in the browser** — nothing to install for the person giving support.
- **Two ways to reach a machine** — connect straight to a VNC server on the LAN, or
have the machine run a small agent that dials *out*, so NAT and firewalls stop
mattering.
- **Invite links** — one kind adds a machine, the other hands someone time-limited
access to a single machine with no account at all.
- **Access control that is actually enforced** — view-only is applied at the proxy by
dropping input messages, not by hiding buttons. Stored VNC passwords never reach
the browser.
- **Persisted** — machines, grants, invites, session history and an audit log live in
SQLite.
- Login delegated to the existing **garagedoor-node-ws** auth service.
See [PLAN.md](PLAN.md) for the architecture and feature checklist.
---
## Quick start
```bash
pnpm install
cp .env.example .env # set ADMIN_USERS at minimum
pnpm start # http://localhost:8080
```
Run the test suite (boots the real server against a fake VNC server and a fake auth
service, then drives it as a browser would):
```bash
pnpm test
```
## Deploy
CI/CD is Gitea Actions → registry → Portainer, same shape as the other services here.
Pushing to `main` runs the test suite, builds `git.mancinas.io/rmancinas/remote-control-support-webapp:latest`,
and deploys the stack (`.gitea/workflows/deploy.yml`).
Gitea repo secrets required:
| Secret | Value |
|---|---|
| `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` | git.mancinas.io login |
| `PORTAINER_URL` | `https://192.168.4.212:9443` |
| `PORTAINER_API_KEY` | Portainer → user icon → Access tokens |
| `PORTAINER_ENDPOINT_ID` | `2` (the local Swarm endpoint) |
| `PORTAINER_STACK_NAME` | e.g. `remote-control-support` |
| `AUTH_URL` | `http://192.168.4.208:8000` |
| `ADMIN_USERS` | e.g. `rmancinas` |
| `ENCRYPTION_KEY` | `openssl rand -hex 32`**set this**, see below |
| `PUBLIC_URL` | e.g. `https://remote.mancinas.dev` |
Two things the compose file must keep, both because state lives in the process and on
one node:
- **`replicas: 1`.** Agent control sockets and live sessions are held in memory. A
second replica would not see the first one's agents, and connects would fail at
random depending on which task the browser landed on.
- **`node.role == manager` placement + named volume.** The SQLite volume is node-local.
If the task reschedules elsewhere it comes up with an empty database.
`ENCRYPTION_KEY` is worth setting explicitly rather than letting the container generate
one: the generated key lives in the same volume as the database, so losing the volume
loses both, and the stored VNC passwords with them.
Locally:
```bash
docker build -t rcs . && docker run --rm -p 8080:8080 -v rcs_data:/data rcs
```
Put it behind HTTPS before using it for real: RFB is a raw framebuffer stream and
`ws://` sends it in the clear. The `lan-https-host` setup covers this — and Nginx Proxy
Manager in front is also what supplies the real client IP, since the app runs with
`TRUST_PROXY=true`. **Enable WebSocket support on the proxy host** or nothing connects.
---
## Adding a machine
### Direct (the hub can reach its VNC port)
**Add machine** → host, port, VNC password. Good for servers and desktops on the same
LAN as the hub.
### Agent (the machine dials out)
**Invite a machine** produces a link. Open it on the target machine and it shows a
one-liner:
```bash
curl -fsSL https://your-hub/download/agent.js -o rcs-agent.js \
&& node rcs-agent.js enroll https://your-hub/enroll/TOKEN \
&& node rcs-agent.js run
```
The agent needs Node 22+ and a VNC server listening on `127.0.0.1:5900`:
| OS | VNC server |
|---|---|
| macOS | System Settings → General → Sharing → Screen Sharing |
| Windows | TightVNC or UltraVNC |
| Linux | `x11vnc -localhost -rfbport 5900` |
Enrolment issues an agent key, stored hashed on the hub and written to
`~/.rcs-agent.json` (mode 600) on the machine. To keep it running, wrap
`node rcs-agent.js run` in a systemd unit, a launchd plist, or a scheduled task.
Agent commands:
```
node agent.js enroll <link|token> [--hub URL] [--vnc-host H] [--vnc-port N] [--name NAME]
node agent.js run [--vnc-host H] [--vnc-port N]
node agent.js status
```
---
## Access control
| Role | Gets |
|---|---|
| admin | Everything: machines, invites, grants, audit, force-disconnect |
| operator | Full keyboard and mouse on machines they were granted |
| viewer | Screen only — input is dropped by the proxy |
Admins are set by `ADMIN_USERS` (comma-separated usernames) or `ADMIN_LEVEL`
(garagedoor `level` threshold). **With neither set, every authenticated user is an
admin** — fine for a single operator, wrong for a shared install.
Everyone else sees only machines they hold a grant for (**⋯ → Who has access**).
Turning on *ask first* for a machine makes its agent prompt whoever is sitting there
before each session, and the session does not start until they accept.
### Support links
**Support link** creates a URL that grants one machine, one role, until it expires. The
person opening it types a name and connects — no account. Revoke it from the Invites
tab at any time; live sessions can be cut from the Sessions tab.
---
## How a session actually works
```
browser ──wss /ws/vnc?ticket=…──► hub ──tcp──► VNC server (direct)
browser ──wss /ws/vnc?ticket=…──► hub ◄─wss /ws/tunnel── agent ──tcp──► VNC server (agent)
```
The hub is a deliberate man-in-the-middle. It completes the RFB handshake with the real
VNC server itself — including VNC Authentication, using the password it holds
encrypted — and then presents the browser a handshake that needs no password. Because
it sits in the middle of the message stream it can also parse the browser→server
direction and drop `KeyEvent`, `PointerEvent`, `ClientCutText`, `SetDesktopSize` and
`xvp` for view-only sessions.
WebSockets cannot carry an `Authorization` header, so `POST /api/sessions` mints a
**single-use ticket that expires in 30 seconds** and the socket carries only that.
Node's OpenSSL 3 build dropped `des-ecb` from the default provider, so
`server/vnc/des.js` carries a small DES implementation purely to answer the VNC auth
challenge. It is verified against the standard test vectors in the test suite.
---
## Configuration
Everything is environment variables — see [.env.example](.env.example).
| Variable | Default | Notes |
|---|---|---|
| `PORT` / `HOST` | `8080` / `0.0.0.0` | |
| `AUTH_URL` | `http://192.168.4.208:8000` | garagedoor-node-ws |
| `ADMIN_USERS` | *(empty)* | Comma-separated. Empty + no `ADMIN_LEVEL` = everyone is admin |
| `ADMIN_LEVEL` | *(unset)* | garagedoor `level` at or above this is admin |
| `DB_PATH` | `./data/rcs.db` | |
| `ENCRYPTION_KEY` | *(generated)* | Encrypts stored VNC passwords. Back it up |
| `PUBLIC_URL` | *(request host)* | Base URL used when rendering invite links |
| `ALLOW_SESSION_INVITES` | `true` | `false` disables no-login support links |
| `TICKET_TTL_MS` | `30000` | |
| `INVITE_TTL_MS` | `86400000` | Default invite lifetime |
| `CONSENT_TIMEOUT_MS` | `45000` | How long to wait for someone to accept |
## API sketch
| Method | Path | |
|---|---|---|
| `POST` | `/api/login` | → token |
| `GET` | `/api/clients` | machines you can see |
| `POST` | `/api/clients` | admin |
| `POST` | `/api/clients/:id/grants` | admin |
| `POST` | `/api/clients/:id/agent-key` | admin, returns the key once |
| `POST` | `/api/invites` | admin, `kind: enroll \| session` |
| `POST` | `/api/sessions` | mint a connect ticket |
| `GET` | `/api/sessions/live` | who is connected now |
| `POST` | `/api/sessions/:id/kill` | admin |
| `GET` | `/api/sessions/history` | |
| `GET` | `/api/sessions/audit` | admin |
| `POST` | `/api/public/enroll` | no auth, enrolment token |
| `POST` | `/api/public/session/:token` | no auth, support link |
| WS | `/ws/vnc?ticket=` | browser session |
| WS | `/ws/agent?clientId=&key=` | agent control channel |
| WS | `/ws/tunnel?clientId=&key=&tunnelId=` | agent data tunnel |
+358
View File
@@ -0,0 +1,358 @@
#!/usr/bin/env node
'use strict';
// Remote-control support agent.
//
// Runs on the machine being supported. Dials *out* to the hub and keeps a
// control WebSocket open, so the machine never needs an inbound port or a
// public address. When the hub asks for a session, the agent opens a second
// WebSocket and pipes it to the VNC server listening on localhost.
//
// Deliberately dependency-free: Node 22 ships a global WebSocket, so this file
// can be copied onto a machine and run with nothing but `node`.
const fs = require('fs');
const net = require('net');
const os = require('os');
const path = require('path');
const { execFile } = require('child_process');
const VERSION = '0.1.0';
const DEFAULT_CONFIG = process.env.RCS_AGENT_CONFIG
|| path.join(os.homedir(), '.rcs-agent.json');
const RECONNECT_MIN_MS = 2_000;
const RECONNECT_MAX_MS = 60_000;
/* ----------------------------------------------------------------- utils */
function log(...args) {
console.log(new Date().toISOString(), '[agent]', ...args);
}
function readConfig(file = DEFAULT_CONFIG) {
if (!fs.existsSync(file)) return null;
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
function writeConfig(cfg, file = DEFAULT_CONFIG) {
fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
}
/** Accepts either a full enrolment URL or a bare token. */
function parseInvite(input) {
const raw = String(input || '').trim();
if (!raw) throw new Error('an enrolment link or token is required');
if (/^https?:\/\//i.test(raw)) {
const url = new URL(raw);
const token = url.pathname.split('/').filter(Boolean).pop();
if (!token) throw new Error(`cannot find a token in ${raw}`);
return { hub: `${url.protocol}//${url.host}`, token };
}
return { hub: null, token: raw };
}
function wsBase(hub) {
return hub.replace(/^http/i, 'ws').replace(/\/$/, '');
}
function parseArgs(argv) {
const out = { _: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) {
const key = a.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith('--')) out[key] = true;
else {
out[key] = next;
i++;
}
} else {
out._.push(a);
}
}
return out;
}
/* --------------------------------------------------------------- consent */
/**
* Ask the person sitting at this machine whether to allow the session.
* Falls back to denying if no dialog tool is available — failing closed is the
* right default when the whole point of the flag is that a human must agree.
*/
function askConsent({ operator, role, timeoutMs = 40_000 }) {
const message = `${operator} wants to ${role === 'viewer' ? 'view' : 'control'} this computer.\n\nAllow the connection?`;
const attempt = (cmd, args) => new Promise((resolve) => {
const child = execFile(cmd, args, { timeout: timeoutMs }, (err) => resolve(!err));
child.on('error', () => resolve(null)); // tool missing — try the next one
});
if (process.platform === 'darwin') {
return attempt('osascript', [
'-e',
`display dialog ${JSON.stringify(message)} with title "Remote Support" buttons {"Deny","Allow"} default button "Allow" giving up after ${Math.floor(timeoutMs / 1000)}`,
'-e',
'if button returned of result is not "Allow" then error number 1',
]);
}
if (process.platform === 'win32') {
const ps = `Add-Type -AssemblyName PresentationFramework;`
+ `$r=[System.Windows.MessageBox]::Show(${JSON.stringify(message)},'Remote Support','YesNo','Question');`
+ `if($r -ne 'Yes'){exit 1}`;
return attempt('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps]);
}
// Linux: try the common dialog helpers in turn.
return (async () => {
for (const [cmd, args] of [
['zenity', ['--question', '--title=Remote Support', `--text=${message}`, `--timeout=${Math.floor(timeoutMs / 1000)}`]],
['kdialog', ['--title', 'Remote Support', '--yesno', message]],
]) {
const result = await attempt(cmd, args);
if (result !== null) return result;
}
log('consent required but no dialog tool (zenity/kdialog) is installed — denying');
return false;
})();
}
/* --------------------------------------------------------------- enroll */
async function cmdEnroll(args) {
const { hub: linkHub, token } = parseInvite(args._[0]);
const hub = (args.hub || linkHub || process.env.RCS_HUB || '').replace(/\/$/, '');
if (!hub) throw new Error('cannot tell which hub to enrol with — pass a full link or --hub <url>');
const vncPort = Number(args['vnc-port'] || process.env.RCS_VNC_PORT || 5900);
const vncHost = String(args['vnc-host'] || process.env.RCS_VNC_HOST || '127.0.0.1');
const res = await fetch(`${hub}/api/public/enroll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token,
hostname: os.hostname(),
os: `${os.type()} ${os.release()} (${os.arch()})`,
agentVersion: VERSION,
vncPort,
name: args.name || undefined,
}),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || `enrolment failed (HTTP ${res.status})`);
const cfg = {
hub,
clientId: body.clientId,
agentKey: body.agentKey,
name: body.name,
vncHost,
vncPort,
autoAccept: !body.requireConsent,
};
const file = args.config || DEFAULT_CONFIG;
writeConfig(cfg, file);
log(`enrolled as "${body.name}"`);
log(`config written to ${file}`);
log(`consent prompt: ${body.requireConsent ? 'on' : 'off'}`);
log('start the agent with: node agent.js run');
}
/* ------------------------------------------------------------------ run */
class Agent {
constructor(cfg) {
this.cfg = cfg;
this.backoff = RECONNECT_MIN_MS;
this.ws = null;
this.stopping = false;
}
start() {
this.connect();
process.on('SIGINT', () => this.stop());
process.on('SIGTERM', () => this.stop());
}
stop() {
this.stopping = true;
if (this.ws) try { this.ws.close(); } catch { /* already gone */ }
process.exit(0);
}
connect() {
const { hub, clientId, agentKey } = this.cfg;
const url = `${wsBase(hub)}/ws/agent?clientId=${encodeURIComponent(clientId)}&key=${encodeURIComponent(agentKey)}`;
log(`connecting to ${hub}`);
const ws = new WebSocket(url);
this.ws = ws;
ws.addEventListener('open', () => {
this.backoff = RECONNECT_MIN_MS;
log('connected');
ws.send(JSON.stringify({
type: 'hello',
version: VERSION,
os: `${os.type()} ${os.release()} (${os.arch()})`,
hostname: os.hostname(),
vncPort: this.cfg.vncPort,
}));
});
ws.addEventListener('message', (event) => {
let msg;
try {
msg = JSON.parse(typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString());
} catch {
return;
}
this.onMessage(msg).catch((err) => log('message handler failed:', err.message));
});
ws.addEventListener('close', (event) => {
this.ws = null;
if (this.stopping) return;
// 4001/4003 mean the hub deliberately dropped us; still retry, more slowly.
log(`disconnected (${event.code}${event.reason ? `: ${event.reason}` : ''}), retrying in ${Math.round(this.backoff / 1000)}s`);
setTimeout(() => this.connect(), this.backoff);
this.backoff = Math.min(this.backoff * 2, RECONNECT_MAX_MS);
});
ws.addEventListener('error', () => { /* close always follows */ });
}
async onMessage(msg) {
switch (msg.type) {
case 'welcome':
log(`registered as "${msg.name}"${msg.requireConsent ? ' (consent required)' : ''}`);
this.requireConsent = !!msg.requireConsent;
break;
case 'open': {
const needsConsent = msg.requireConsent && !this.cfg.autoAccept;
if (needsConsent) {
log(`${msg.operator} is requesting ${msg.role} access — prompting`);
const allowed = await askConsent({ operator: msg.operator, role: msg.role });
if (!allowed) {
log('connection denied at the client');
this.send({ type: 'denied', tunnelId: msg.tunnelId, reason: 'the person at that machine declined' });
return;
}
}
this.openTunnel(msg);
break;
}
case 'session-ended':
break;
default:
break;
}
}
send(obj) {
if (this.ws && this.ws.readyState === 1) this.ws.send(JSON.stringify(obj));
}
openTunnel(msg) {
const { hub, clientId, agentKey, vncHost, vncPort } = this.cfg;
const url = `${wsBase(hub)}/ws/tunnel?clientId=${encodeURIComponent(clientId)}`
+ `&key=${encodeURIComponent(agentKey)}&tunnelId=${encodeURIComponent(msg.tunnelId)}`;
const socket = net.connect({ host: vncHost || '127.0.0.1', port: vncPort || 5900 });
socket.setNoDelay(true);
const tunnel = new WebSocket(url);
tunnel.binaryType = 'arraybuffer';
let closed = false;
const shutdown = (why) => {
if (closed) return;
closed = true;
log(`session ended (${why})`);
try { socket.destroy(); } catch { /* already gone */ }
try { tunnel.close(); } catch { /* already gone */ }
};
socket.on('error', (err) => {
log(`cannot reach the local VNC server at ${vncHost}:${vncPort}${err.code || err.message}`);
this.send({ type: 'error', tunnelId: msg.tunnelId, message: `no VNC server on ${vncHost}:${vncPort}` });
shutdown('local VNC error');
});
socket.on('close', () => shutdown('VNC server closed'));
tunnel.addEventListener('open', () => {
log(`session started for ${msg.operator} (${msg.role})`);
socket.on('data', (chunk) => {
if (tunnel.readyState === 1) tunnel.send(chunk);
});
});
tunnel.addEventListener('message', (event) => {
const data = typeof event.data === 'string' ? Buffer.from(event.data) : Buffer.from(event.data);
socket.write(data);
});
tunnel.addEventListener('close', () => shutdown('hub closed the tunnel'));
tunnel.addEventListener('error', () => shutdown('tunnel error'));
}
}
function cmdRun(args) {
const file = args.config || DEFAULT_CONFIG;
const cfg = readConfig(file);
if (!cfg) throw new Error(`no agent config at ${file} — run "node agent.js enroll <link>" first`);
if (args['vnc-port']) cfg.vncPort = Number(args['vnc-port']);
if (args['vnc-host']) cfg.vncHost = String(args['vnc-host']);
log(`agent ${VERSION}, hub ${cfg.hub}, VNC ${cfg.vncHost || '127.0.0.1'}:${cfg.vncPort || 5900}`);
new Agent(cfg).start();
}
function cmdStatus(args) {
const file = args.config || DEFAULT_CONFIG;
const cfg = readConfig(file);
if (!cfg) {
console.log(`not enrolled (no config at ${file})`);
process.exitCode = 1;
return;
}
console.log(JSON.stringify({ ...cfg, agentKey: '***' }, null, 2));
}
const USAGE = `remote-control-support agent ${VERSION}
node agent.js enroll <link|token> [--hub URL] [--vnc-host H] [--vnc-port N] [--name NAME]
node agent.js run [--vnc-host H] [--vnc-port N]
node agent.js status
--config PATH agent config file (default ${DEFAULT_CONFIG})
`;
async function main() {
const args = parseArgs(process.argv.slice(2));
const cmd = args._.shift();
try {
if (cmd === 'enroll') await cmdEnroll(args);
else if (cmd === 'run') cmdRun(args);
else if (cmd === 'status') cmdStatus(args);
else {
console.log(USAGE);
process.exitCode = cmd ? 1 : 0;
}
} catch (err) {
console.error(`error: ${err.message}`);
process.exitCode = 1;
}
}
main();
+39
View File
@@ -0,0 +1,39 @@
# Deployed to Portainer (Swarm endpoint) by .gitea/workflows/deploy.yml.
# ${IMAGE} and the secrets below are injected as env_data at deploy time.
#
# Two constraints this stack cannot break:
# 1. exactly one replica — agent control sockets and live sessions are held in
# process memory, so a second replica would not see the first one's agents;
# 2. pinned placement — the SQLite volume is node-local, so the task has to
# come back to the same node or it wakes up with an empty database.
services:
remote-control-support:
image: ${IMAGE:-git.mancinas.io/rmancinas/remote-control-support-webapp:latest}
ports:
- "8091:8080"
environment:
DB_PATH: /data/rcs.db
TRUST_PROXY: "true"
AUTH_URL: ${AUTH_URL:-http://192.168.4.208:8000}
ADMIN_USERS: ${ADMIN_USERS:-}
ADMIN_LEVEL: ${ADMIN_LEVEL:-}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
PUBLIC_URL: ${PUBLIC_URL:-}
ALLOW_SESSION_INVITES: ${ALLOW_SESSION_INVITES:-true}
volumes:
- rcs_data:/data
deploy:
replicas: 1
placement:
constraints:
- node.role == manager
update_config:
# start-first would briefly run two containers against one SQLite file.
order: stop-first
failure_action: rollback
restart_policy:
condition: any
delay: 10s
volumes:
rcs_data:
+21
View File
@@ -0,0 +1,21 @@
{
"name": "remote-control-support-webapp",
"version": "0.1.0",
"description": "Self-hosted remote support over VNC — browser viewer, invite links, access control",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
"dev": "node --watch server/index.js",
"agent": "node agent/agent.js",
"test": "node test/e2e.js"
},
"license": "MIT",
"engines": {
"node": ">=22.5"
},
"dependencies": {
"@novnc/novnc": "^1.6.0",
"express": "^4.19.2",
"ws": "^8.18.0"
}
}
+605
View File
@@ -0,0 +1,605 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@novnc/novnc':
specifier: ^1.6.0
version: 1.7.0
express:
specifier: ^4.19.2
version: 4.22.2
ws:
specifier: ^8.18.0
version: 8.21.3
packages:
'@novnc/novnc@1.7.0':
resolution: {integrity: sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==}
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
body-parser@1.20.6:
resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
call-bound@1.0.4:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
content-disposition@0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
content-type@1.0.5:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
cookie-signature@1.0.7:
resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
destroy@1.2.0:
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
encodeurl@2.0.0:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-object-atoms@1.1.2:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
express@4.22.2:
resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==}
engines: {node: '>= 0.10.0'}
finalhandler@1.3.2:
resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
engines: {node: '>= 0.8'}
forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
merge-descriptors@1.0.3:
resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
methods@1.1.2:
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
engines: {node: '>= 0.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mime@1.6.0:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'}
hasBin: true
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
negotiator@0.6.3:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
path-to-regexp@0.1.13:
resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==}
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
qs@6.15.3:
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
engines: {node: '>=0.6'}
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
raw-body@2.5.3:
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
engines: {node: '>= 0.8'}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
send@0.19.2:
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
engines: {node: '>= 0.8.0'}
serve-static@1.16.3:
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
engines: {node: '>= 0.8.0'}
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
side-channel-list@1.0.1:
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
side-channel-map@1.0.1:
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
engines: {node: '>= 0.4'}
side-channel-weakmap@1.0.2:
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
engines: {node: '>= 0.4'}
side-channel@1.1.1:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
type-is@1.6.18:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
utils-merge@1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
ws@8.21.3:
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
snapshots:
'@novnc/novnc@1.7.0': {}
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
negotiator: 0.6.3
array-flatten@1.1.1: {}
body-parser@1.20.6:
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 2.6.9
depd: 2.0.0
destroy: 1.2.0
http-errors: 2.0.1
iconv-lite: 0.4.24
on-finished: 2.4.1
qs: 6.15.3
raw-body: 2.5.3
type-is: 1.6.18
unpipe: 1.0.0
transitivePeerDependencies:
- supports-color
bytes@3.1.2: {}
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
call-bound@1.0.4:
dependencies:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
content-disposition@0.5.4:
dependencies:
safe-buffer: 5.2.1
content-type@1.0.5: {}
cookie-signature@1.0.7: {}
cookie@0.7.2: {}
debug@2.6.9:
dependencies:
ms: 2.0.0
depd@2.0.0: {}
destroy@1.2.0: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
ee-first@1.1.1: {}
encodeurl@2.0.0: {}
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-object-atoms@1.1.2:
dependencies:
es-errors: 1.3.0
escape-html@1.0.3: {}
etag@1.8.1: {}
express@4.22.2:
dependencies:
accepts: 1.3.8
array-flatten: 1.1.1
body-parser: 1.20.6
content-disposition: 0.5.4
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.0.7
debug: 2.6.9
depd: 2.0.0
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
finalhandler: 1.3.2
fresh: 0.5.2
http-errors: 2.0.1
merge-descriptors: 1.0.3
methods: 1.1.2
on-finished: 2.4.1
parseurl: 1.3.3
path-to-regexp: 0.1.13
proxy-addr: 2.0.7
qs: 6.15.3
range-parser: 1.2.1
safe-buffer: 5.2.1
send: 0.19.2
serve-static: 1.16.3
setprototypeof: 1.2.0
statuses: 2.0.2
type-is: 1.6.18
utils-merge: 1.0.1
vary: 1.1.2
transitivePeerDependencies:
- supports-color
finalhandler@1.3.2:
dependencies:
debug: 2.6.9
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
parseurl: 1.3.3
statuses: 2.0.2
unpipe: 1.0.0
transitivePeerDependencies:
- supports-color
forwarded@0.2.0: {}
fresh@0.5.2: {}
function-bind@1.1.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.2
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.4
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.2
gopd@1.2.0: {}
has-symbols@1.1.0: {}
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
http-errors@2.0.1:
dependencies:
depd: 2.0.0
inherits: 2.0.4
setprototypeof: 1.2.0
statuses: 2.0.2
toidentifier: 1.0.1
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
inherits@2.0.4: {}
ipaddr.js@1.9.1: {}
math-intrinsics@1.1.0: {}
media-typer@0.3.0: {}
merge-descriptors@1.0.3: {}
methods@1.1.2: {}
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mime@1.6.0: {}
ms@2.0.0: {}
ms@2.1.3: {}
negotiator@0.6.3: {}
object-inspect@1.13.4: {}
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
parseurl@1.3.3: {}
path-to-regexp@0.1.13: {}
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
ipaddr.js: 1.9.1
qs@6.15.3:
dependencies:
es-define-property: 1.0.1
side-channel: 1.1.1
range-parser@1.2.1: {}
raw-body@2.5.3:
dependencies:
bytes: 3.1.2
http-errors: 2.0.1
iconv-lite: 0.4.24
unpipe: 1.0.0
safe-buffer@5.2.1: {}
safer-buffer@2.1.2: {}
send@0.19.2:
dependencies:
debug: 2.6.9
depd: 2.0.0
destroy: 1.2.0
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
fresh: 0.5.2
http-errors: 2.0.1
mime: 1.6.0
ms: 2.1.3
on-finished: 2.4.1
range-parser: 1.2.1
statuses: 2.0.2
transitivePeerDependencies:
- supports-color
serve-static@1.16.3:
dependencies:
encodeurl: 2.0.0
escape-html: 1.0.3
parseurl: 1.3.3
send: 0.19.2
transitivePeerDependencies:
- supports-color
setprototypeof@1.2.0: {}
side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-map@1.0.1:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-weakmap@1.0.2:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-map: 1.0.1
side-channel@1.1.1:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-list: 1.0.1
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
statuses@2.0.2: {}
toidentifier@1.0.1: {}
type-is@1.6.18:
dependencies:
media-typer: 0.3.0
mime-types: 2.1.35
unpipe@1.0.0: {}
utils-merge@1.0.1: {}
vary@1.1.2: {}
ws@8.21.3: {}
+718
View File
@@ -0,0 +1,718 @@
'use strict';
/* Remote Support console. No build step: plain DOM, plain fetch. */
const TOKEN_KEY = 'rcs.token';
const state = {
token: localStorage.getItem(TOKEN_KEY) || null,
username: null,
isAdmin: false,
clients: [],
tab: 'machines',
};
/* ------------------------------------------------------------- helpers */
function h(tag, props = {}, ...children) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props || {})) {
if (v === null || v === undefined || v === false) continue;
if (k === 'class') el.className = v;
else if (k === 'text') el.textContent = v;
else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
else el.setAttribute(k, v === true ? '' : v);
}
for (const child of children.flat()) {
if (child === null || child === undefined || child === false) continue;
el.append(child.nodeType ? child : document.createTextNode(String(child)));
}
return el;
}
function $(sel) { return document.querySelector(sel); }
function toast(message, ms = 2600) {
document.getElementById('toast')?.remove();
const el = h('div', { id: 'toast', text: message });
document.body.append(el);
setTimeout(() => el.remove(), ms);
}
function ago(ts) {
if (!ts) return 'never';
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 45) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
function until(ts) {
if (!ts) return 'never';
const s = Math.floor((ts - Date.now()) / 1000);
if (s <= 0) return 'expired';
if (s < 3600) return `${Math.floor(s / 60)}m`;
if (s < 86400) return `${Math.floor(s / 3600)}h`;
return `${Math.floor(s / 86400)}d`;
}
function bytes(n) {
if (!n) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1);
return `${(n / 1024 ** i).toFixed(i ? 1 : 0)} ${units[i]}`;
}
function duration(from, to) {
const s = Math.floor(((to || Date.now()) - from) / 1000);
const m = Math.floor(s / 60);
return m ? `${m}m ${s % 60}s` : `${s}s`;
}
/* ----------------------------------------------------------------- api */
async function api(path, { method = 'GET', body } = {}) {
const res = await fetch(`/api${path}`, {
method,
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...(state.token ? { Authorization: `Bearer ${state.token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 401) {
// The garagedoor token is only good for an hour; expiry means log in again.
signOut();
throw new Error('your session expired — sign in again');
}
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `request failed (${res.status})`);
return data;
}
/* --------------------------------------------------------------- modal */
function modal({ title, body, actions }) {
const root = $('#modal-root');
const close = () => { root.innerHTML = ''; document.removeEventListener('keydown', onKey); };
const onKey = (e) => { if (e.key === 'Escape') close(); };
document.addEventListener('keydown', onKey);
const box = h('div', { class: 'modal' },
h('div', { class: 'modal-head' },
h('h2', { text: title }),
h('div', { class: 'spacer' }),
h('button', { class: 'ghost small', text: '✕', onclick: close })),
body,
actions ? h('div', { class: 'modal-actions' }, ...actions(close)) : null);
const backdrop = h('div', {
class: 'modal-backdrop',
onclick: (e) => { if (e.target === backdrop) close(); },
}, box);
root.append(backdrop);
box.querySelector('input, select, textarea')?.focus();
return close;
}
function showLink(title, url, note) {
const input = h('input', { value: url, readonly: true });
modal({
title,
body: h('div', {},
note ? h('p', { class: 'faint', text: note }) : null,
h('div', { class: 'copybox' },
input,
h('button', {
class: 'primary',
text: 'Copy',
onclick: async () => {
try {
await navigator.clipboard.writeText(url);
toast('Link copied');
} catch {
input.select();
toast('Press ⌘C / Ctrl-C to copy');
}
},
})),
h('p', { class: 'faint', style: 'margin-bottom:0', text: 'This link is shown once. Copy it now.' })),
actions: (close) => [h('button', { text: 'Done', onclick: close })],
});
input.select();
}
/* ----------------------------------------------------------------- auth */
async function signIn(event) {
event.preventDefault();
const button = $('#login-button');
const err = $('#login-error');
err.classList.add('hidden');
button.disabled = true;
button.textContent = 'Signing in…';
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: $('#username').value, password: $('#password').value }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'sign in failed');
state.token = data.token;
state.username = data.username;
state.isAdmin = data.isAdmin;
localStorage.setItem(TOKEN_KEY, data.token);
$('#password').value = '';
showApp();
} catch (e) {
err.textContent = e.message;
err.classList.remove('hidden');
} finally {
button.disabled = false;
button.textContent = 'Sign in';
}
}
function signOut() {
state.token = null;
localStorage.removeItem(TOKEN_KEY);
$('#app').classList.add('hidden');
$('#login').classList.remove('hidden');
}
/* ------------------------------------------------------------- machines */
function connect(client, viewOnly) {
const url = new URL('/viewer', location.origin);
url.searchParams.set('client', client.id);
if (viewOnly) url.searchParams.set('viewOnly', '1');
window.open(url.toString(), '_blank', 'noopener');
}
function clientCard(client) {
const isAgent = client.mode === 'agent';
const online = isAgent ? client.online : null;
const status = isAgent
? h('span', { class: `dot ${online ? 'online' : 'offline'}`, title: online ? 'online' : 'offline' })
: h('span', { class: 'dot direct', title: 'direct connection' });
const meta = [];
if (isAgent) meta.push(online ? 'Online' : `Last seen ${ago(client.lastSeenAt)}`);
else meta.push(`${client.host}:${client.port}`);
if (client.os) meta.push(client.os);
const canConnect = !isAgent || online;
const actions = [
h('button', {
class: 'primary small',
text: 'Connect',
disabled: !canConnect,
onclick: () => connect(client, false),
}),
h('button', {
class: 'small',
text: 'View only',
disabled: !canConnect,
onclick: () => connect(client, true),
}),
];
if (state.isAdmin) {
actions.push(h('button', { class: 'ghost small', text: 'Share', onclick: () => newShareLink(client) }));
actions.push(h('button', { class: 'ghost small', text: '⋯', onclick: () => clientMenu(client) }));
}
return h('div', { class: 'card' },
h('div', { class: 'card-title' },
status,
h('h3', { text: client.name }),
h('div', { class: 'spacer' }),
client.requireConsent ? h('span', { class: 'tag warn', text: 'asks first' }) : null,
client.grantedRole ? h('span', { class: 'tag role', text: client.grantedRole }) : null),
client.description ? h('div', { class: 'faint', text: client.description }) : null,
h('div', { class: 'faint', text: meta.join(' · ') }),
client.tags.length ? h('div', { class: 'row wrap' }, client.tags.map((t) => h('span', { class: 'tag', text: t }))) : null,
h('div', { class: 'card-actions' }, ...actions));
}
function clientMenu(client) {
modal({
title: client.name,
body: h('div', { class: 'row wrap' },
h('button', { class: 'small', text: 'Edit', onclick: () => { $('#modal-root').innerHTML = ''; clientForm(client); } }),
h('button', { class: 'small', text: 'Who has access', onclick: () => { $('#modal-root').innerHTML = ''; grantsDialog(client); } }),
h('button', { class: 'small', text: 'Session history', onclick: () => { $('#modal-root').innerHTML = ''; historyDialog(client); } }),
h('button', {
class: 'small',
text: client.enrolled ? 'Re-issue agent key' : 'Issue agent key',
onclick: async () => {
if (client.enrolled && !confirm('The current agent will be disconnected and must be reconfigured. Continue?')) return;
const { agentKey } = await api(`/clients/${client.id}/agent-key`, { method: 'POST' });
$('#modal-root').innerHTML = '';
showLink('Agent key', agentKey, 'Put this in the agent config on that machine.');
refresh();
},
}),
h('button', {
class: 'small danger',
text: 'Delete',
onclick: async () => {
if (!confirm(`Delete "${client.name}"? Its grants and live sessions go with it.`)) return;
await api(`/clients/${client.id}`, { method: 'DELETE' });
$('#modal-root').innerHTML = '';
toast('Machine deleted');
refresh();
},
})),
});
}
function clientForm(client) {
const editing = !!client;
const c = client || { mode: 'direct', port: 5900, tags: [] };
const name = h('input', { value: c.name || '', required: true, placeholder: 'Reception PC' });
const description = h('input', { value: c.description || '', placeholder: 'Optional note' });
const host = h('input', { value: c.host || '', placeholder: '192.168.4.50' });
const port = h('input', { type: 'number', value: c.port || 5900, min: '1', max: '65535' });
const password = h('input', {
type: 'password',
placeholder: editing && c.hasPassword ? '•••••••• (unchanged)' : 'VNC password, if the server needs one',
});
const tags = h('input', { value: (c.tags || []).join(', '), placeholder: 'office, windows' });
const consent = h('input', { type: 'checkbox', ...(c.requireConsent ? { checked: true } : {}) });
const mode = h('select', {},
h('option', { value: 'direct', ...(c.mode === 'direct' ? { selected: true } : {}) }, 'Direct — the hub can reach its VNC port'),
h('option', { value: 'agent', ...(c.mode === 'agent' ? { selected: true } : {}) }, 'Agent — the machine dials out to the hub'));
const directFields = h('div', {},
h('div', { class: 'field-row' },
h('div', { class: 'field' }, h('label', { text: 'Host' }), host),
h('div', { class: 'field' }, h('label', { text: 'Port' }), port)));
const syncMode = () => { directFields.classList.toggle('hidden', mode.value !== 'direct'); };
mode.addEventListener('change', syncMode);
const error = h('div', { class: 'notice error hidden' });
const body = h('div', {},
error,
h('div', { class: 'field' }, h('label', { text: 'Name' }), name),
h('div', { class: 'field' }, h('label', { text: 'Description' }), description),
h('div', { class: 'field' }, h('label', { text: 'Connection' }), mode),
directFields,
h('div', { class: 'field' }, h('label', { text: 'VNC password' }), password),
h('div', { class: 'field' }, h('label', { text: 'Tags' }), tags),
h('div', { class: 'field' }, h('label', { class: 'check' }, consent, 'Ask the person at that machine before connecting')));
syncMode();
modal({
title: editing ? 'Edit machine' : 'Add machine',
body,
actions: (close) => [
h('button', { text: 'Cancel', onclick: close }),
h('button', {
class: 'primary',
text: editing ? 'Save' : 'Add',
onclick: async (e) => {
const button = e.currentTarget;
button.disabled = true;
error.classList.add('hidden');
const payload = {
name: name.value,
description: description.value,
mode: mode.value,
host: host.value,
port: Number(port.value) || 5900,
tags: tags.value,
requireConsent: consent.checked,
};
// Leaving the password blank on an edit keeps whatever is stored.
if (password.value || !editing) payload.vncPassword = password.value;
try {
if (editing) await api(`/clients/${client.id}`, { method: 'PATCH', body: payload });
else await api('/clients', { method: 'POST', body: payload });
close();
toast(editing ? 'Saved' : 'Machine added');
refresh();
} catch (err) {
error.textContent = err.message;
error.classList.remove('hidden');
button.disabled = false;
}
},
}),
],
});
}
async function grantsDialog(client) {
const { grants } = await api(`/clients/${client.id}/grants`);
const list = h('div', { class: 'table-wrap', style: 'margin-bottom:16px' });
const draw = (rows) => {
list.innerHTML = '';
if (!rows.length) {
list.append(h('div', { class: 'empty', text: 'Only admins can reach this machine.' }));
return;
}
list.append(h('table', {},
h('thead', {}, h('tr', {}, h('th', { text: 'User' }), h('th', { text: 'Role' }), h('th', { text: 'Expires' }), h('th', {}))),
h('tbody', {}, rows.map((g) => h('tr', {},
h('td', { text: g.username }),
h('td', { text: g.role }),
h('td', { class: 'faint', text: g.expires_at ? until(g.expires_at) : '—' }),
h('td', {}, h('button', {
class: 'ghost small danger',
text: 'Remove',
onclick: async () => {
await api(`/clients/${client.id}/grants/${encodeURIComponent(g.username)}`, { method: 'DELETE' });
draw(rows.filter((r) => r.username !== g.username));
},
})))))));
};
draw(grants);
const username = h('input', { placeholder: 'username' });
const role = h('select', {}, h('option', { value: 'viewer' }, 'View only'), h('option', { value: 'operator' }, 'Full control'));
modal({
title: `Access to ${client.name}`,
body: h('div', {},
list,
h('div', { class: 'field-row' },
h('div', { class: 'field' }, h('label', { text: 'Add user' }), username),
h('div', { class: 'field' }, h('label', { text: 'Role' }), role))),
actions: (close) => [
h('button', { text: 'Close', onclick: close }),
h('button', {
class: 'primary',
text: 'Grant access',
onclick: async () => {
if (!username.value.trim()) return;
await api(`/clients/${client.id}/grants`, {
method: 'POST',
body: { username: username.value.trim(), role: role.value },
});
const fresh = await api(`/clients/${client.id}/grants`);
username.value = '';
draw(fresh.grants);
toast('Access granted');
},
}),
],
});
}
async function historyDialog(client) {
const { sessions } = await api(`/sessions/history?clientId=${encodeURIComponent(client.id)}&limit=50`);
modal({
title: `${client.name} — sessions`,
body: sessions.length
? h('div', { class: 'table-wrap' }, sessionTable(sessions))
: h('div', { class: 'empty', text: 'No sessions recorded yet.' }),
});
}
/* -------------------------------------------------------------- invites */
const TTL_OPTIONS = [
['1 hour', 3600e3],
['8 hours', 8 * 3600e3],
['1 day', 24 * 3600e3],
['7 days', 7 * 24 * 3600e3],
['30 days', 30 * 24 * 3600e3],
];
function newEnrollLink() {
const name = h('input', { placeholder: 'leave blank to use the machine name' });
const tags = h('input', { placeholder: 'office, windows' });
const consent = h('input', { type: 'checkbox' });
const ttl = h('select', {}, TTL_OPTIONS.map(([label, ms], i) =>
h('option', { value: ms, ...(i === 2 ? { selected: true } : {}) }, label)));
modal({
title: 'Invite a machine',
body: h('div', {},
h('p', { class: 'faint', text: 'Send this to whoever is at the machine. Running the agent with it registers the machine here.' }),
h('div', { class: 'field' }, h('label', { text: 'Name it' }), name),
h('div', { class: 'field' }, h('label', { text: 'Tags' }), tags),
h('div', { class: 'field' }, h('label', { text: 'Link valid for' }), ttl),
h('div', { class: 'field' }, h('label', { class: 'check' }, consent, 'Ask the person there before each connection'))),
actions: (close) => [
h('button', { text: 'Cancel', onclick: close }),
h('button', {
class: 'primary',
text: 'Create link',
onclick: async () => {
const res = await api('/invites', {
method: 'POST',
body: {
kind: 'enroll',
name: name.value || undefined,
tags: tags.value,
requireConsent: consent.checked,
ttlMs: Number(ttl.value),
},
});
close();
showLink('Enrollment link', res.url, 'Open this on the machine you want to support.');
refresh();
},
}),
],
});
}
function newShareLink(preselect) {
const target = h('select', {}, state.clients.map((c) =>
h('option', { value: c.id, ...(preselect && c.id === preselect.id ? { selected: true } : {}) }, c.name)));
const role = h('select', {}, h('option', { value: 'viewer' }, 'View only'), h('option', { value: 'operator' }, 'Full control'));
const ttl = h('select', {}, TTL_OPTIONS.map(([label, ms], i) =>
h('option', { value: ms, ...(i === 0 ? { selected: true } : {}) }, label)));
const label = h('input', { placeholder: 'who is this for?' });
modal({
title: 'Support link',
body: h('div', {},
h('p', { class: 'faint', text: 'Anyone with this link can connect to that machine until it expires. No sign in needed.' }),
h('div', { class: 'field' }, h('label', { text: 'Machine' }), target),
h('div', { class: 'field' }, h('label', { text: 'They can' }), role),
h('div', { class: 'field' }, h('label', { text: 'Link valid for' }), ttl),
h('div', { class: 'field' }, h('label', { text: 'Label' }), label)),
actions: (close) => [
h('button', { text: 'Cancel', onclick: close }),
h('button', {
class: 'primary',
text: 'Create link',
onclick: async () => {
const res = await api('/invites', {
method: 'POST',
body: { kind: 'session', clientId: target.value, role: role.value, ttlMs: Number(ttl.value), label: label.value },
});
close();
showLink('Support link', res.url, 'Send this to the person who needs access.');
if (state.tab === 'invites') loadInvites();
},
}),
],
});
}
async function loadInvites() {
const container = $('#invites');
const { invites } = await api('/invites');
container.innerHTML = '';
if (!invites.length) {
container.append(h('div', { class: 'empty', text: 'No invite links yet.' }));
return;
}
container.append(h('div', { class: 'table-wrap' }, h('table', {},
h('thead', {}, h('tr', {},
h('th', { text: 'Kind' }), h('th', { text: 'Target' }), h('th', { text: 'Label' }),
h('th', { text: 'Role' }), h('th', { text: 'Uses' }), h('th', { text: 'Expires' }),
h('th', { text: 'Status' }), h('th', {}))),
h('tbody', {}, invites.map((i) => h('tr', {},
h('td', { text: i.kind === 'enroll' ? 'Machine' : 'Support' }),
h('td', { text: i.clientName || '—' }),
h('td', { class: 'faint', text: i.label || '—' }),
h('td', { text: i.role || '—' }),
h('td', { text: i.maxUses ? `${i.uses}/${i.maxUses}` : String(i.uses) }),
h('td', { class: 'faint', text: until(i.expiresAt) }),
h('td', {}, h('span', { class: `tag ${i.status === 'active' ? 'role' : ''}`, text: i.status })),
h('td', {}, i.status === 'active'
? h('button', {
class: 'ghost small danger',
text: 'Revoke',
onclick: async () => {
await api(`/invites/${i.id}/revoke`, { method: 'POST' });
toast('Link revoked');
loadInvites();
},
})
: h('button', {
class: 'ghost small',
text: 'Delete',
onclick: async () => {
await api(`/invites/${i.id}`, { method: 'DELETE' });
loadInvites();
},
}))))))));
}
/* ------------------------------------------------------------- sessions */
function sessionTable(rows) {
return h('table', {},
h('thead', {}, h('tr', {},
h('th', { text: 'Machine' }), h('th', { text: 'Who' }), h('th', { text: 'Role' }),
h('th', { text: 'Started' }), h('th', { text: 'Length' }), h('th', { text: 'Traffic' }), h('th', { text: 'Ended' }))),
h('tbody', {}, rows.map((s) => h('tr', {},
h('td', { text: s.client_name || s.clientName || '—' }),
h('td', { text: s.username }),
h('td', {}, h('span', { class: 'tag', text: s.role })),
h('td', { class: 'faint', text: ago(s.started_at || s.startedAt) }),
h('td', { class: 'faint', text: duration(s.started_at || s.startedAt, s.ended_at) }),
h('td', { class: 'faint', text: bytes((s.bytes_in || 0) + (s.bytes_out || 0)) }),
h('td', { class: 'faint', text: s.end_reason || '—' })))));
}
async function loadSessions() {
const liveBox = $('#live-sessions');
const histBox = $('#session-history');
const { sessions: liveRows } = await api('/sessions/live');
liveBox.innerHTML = '';
if (!liveRows.length) {
liveBox.append(h('div', { class: 'empty', text: 'Nobody is connected right now.' }));
} else {
liveBox.append(h('div', { class: 'table-wrap' }, h('table', {},
h('thead', {}, h('tr', {},
h('th', { text: 'Machine' }), h('th', { text: 'Who' }), h('th', { text: 'Role' }),
h('th', { text: 'Source' }), h('th', { text: 'For' }), h('th', { text: 'Traffic' }), h('th', {}))),
h('tbody', {}, liveRows.map((s) => h('tr', {},
h('td', {}, h('span', { class: 'row' }, h('span', { class: 'dot online' }), s.clientName)),
h('td', { text: s.username }),
h('td', {}, h('span', { class: 'tag role', text: s.role })),
h('td', { class: 'faint', text: s.source }),
h('td', { class: 'faint', text: duration(s.startedAt) }),
h('td', { class: 'faint', text: bytes(s.bytesIn + s.bytesOut) }),
h('td', {}, state.isAdmin
? h('button', {
class: 'ghost small danger',
text: 'Disconnect',
onclick: async () => {
await api(`/sessions/${s.id}/kill`, { method: 'POST' });
toast('Session ended');
loadSessions();
},
})
: null)))))));
}
if (!state.isAdmin) {
histBox.innerHTML = '';
histBox.append(h('div', { class: 'empty', text: 'History is admin-only.' }));
return;
}
const { sessions: history } = await api('/sessions/history?limit=100');
histBox.innerHTML = '';
histBox.append(history.length
? h('div', { class: 'table-wrap' }, sessionTable(history))
: h('div', { class: 'empty', text: 'No sessions recorded yet.' }));
}
async function loadAudit() {
const box = $('#audit');
const { audit } = await api('/sessions/audit?limit=200');
box.innerHTML = '';
box.append(audit.length
? h('div', { class: 'table-wrap' }, h('table', {},
h('thead', {}, h('tr', {}, h('th', { text: 'When' }), h('th', { text: 'Who' }), h('th', { text: 'Action' }), h('th', { text: 'Target' }), h('th', { text: 'Detail' }))),
h('tbody', {}, audit.map((a) => h('tr', {},
h('td', { class: 'faint', text: ago(a.ts) }),
h('td', { text: a.username || '—' }),
h('td', {}, h('span', { class: 'tag', text: a.action })),
h('td', { class: 'mono dim', text: (a.target || '—').slice(0, 8) }),
h('td', { class: 'faint', text: a.detail || '' }))))))
: h('div', { class: 'empty', text: 'Nothing logged yet.' }));
}
/* ------------------------------------------------------------- shell */
async function refresh() {
try {
const { clients, isAdmin } = await api('/clients');
state.clients = clients;
state.isAdmin = isAdmin;
const grid = $('#machines');
grid.innerHTML = '';
$('#machines-count').textContent = clients.length ? `${clients.length} total` : '';
if (!clients.length) {
grid.append(h('div', { class: 'empty', style: 'grid-column:1/-1' },
state.isAdmin
? 'No machines yet. Add one directly, or send an enrollment link.'
: 'Nobody has given you access to a machine yet.'));
} else {
for (const c of clients) grid.append(clientCard(c));
}
for (const el of document.querySelectorAll('[data-admin-only]')) el.classList.toggle('hidden', !state.isAdmin);
} catch (err) {
toast(err.message);
}
}
function selectTab(tab) {
state.tab = tab;
for (const b of document.querySelectorAll('#tabs button')) b.classList.toggle('active', b.dataset.tab === tab);
for (const name of ['machines', 'invites', 'sessions', 'audit']) {
$(`#tab-${name}`).classList.toggle('hidden', name !== tab);
}
if (tab === 'machines') refresh();
if (tab === 'invites') loadInvites().catch((e) => toast(e.message));
if (tab === 'sessions') loadSessions().catch((e) => toast(e.message));
if (tab === 'audit') loadAudit().catch((e) => toast(e.message));
}
function showApp() {
$('#login').classList.add('hidden');
$('#app').classList.remove('hidden');
$('#whoami').textContent = state.isAdmin ? `${state.username} · admin` : state.username;
for (const el of [$('#btn-add-client'), $('#btn-enroll-invite'), $('#btn-new-enroll'), $('#btn-new-share')]) {
el.classList.toggle('hidden', !state.isAdmin);
}
$('#tabs').querySelector('[data-tab="audit"]').classList.toggle('hidden', !state.isAdmin);
selectTab('machines');
}
async function boot() {
$('#login-form').addEventListener('submit', signIn);
$('#logout').addEventListener('click', signOut);
$('#tabs').addEventListener('click', (e) => {
const tab = e.target.closest('button')?.dataset.tab;
if (tab) selectTab(tab);
});
$('#btn-add-client').addEventListener('click', () => clientForm(null));
$('#btn-enroll-invite').addEventListener('click', newEnrollLink);
$('#btn-new-enroll').addEventListener('click', newEnrollLink);
$('#btn-new-share').addEventListener('click', () => newShareLink(null));
$('#btn-refresh-sessions').addEventListener('click', () => loadSessions().catch((e) => toast(e.message)));
if (!state.token) return $('#login').classList.remove('hidden');
try {
const me = await api('/me');
state.username = me.username;
state.isAdmin = me.isAdmin;
showApp();
} catch {
$('#login').classList.remove('hidden');
}
// Keep the machine list and any open session view roughly current.
setInterval(() => {
if (document.hidden || $('#app').classList.contains('hidden')) return;
if (state.tab === 'machines') refresh();
if (state.tab === 'sessions') loadSessions().catch(() => {});
}, 15_000);
}
boot();
+115
View File
@@ -0,0 +1,115 @@
<!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>Set up remote support</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="bg-field" id="bg-field" aria-hidden="true"></div>
<div class="center-shell">
<div class="center-card">
<div class="card-header">
<div class="brand-mark">RC</div>
<div>
<strong style="font-size:14px">Remote Support</strong>
<span class="brand-sub">Machine enrollment</span>
</div>
</div>
<div class="card-body">
<span class="card-kicker">One-time setup</span>
<h1>Set up remote support</h1>
<div id="state-loading" class="faint">Checking this link…</div>
<div id="state-invalid" class="notice error hidden"></div>
<div id="state-ok" class="hidden">
<p class="faint">
This registers this computer so it can be supported remotely. It stays connected
until you stop the agent.
</p>
<ol class="steps">
<li>
<strong>Make sure a VNC server is running</strong> on this machine, listening on
<span class="mono">127.0.0.1:5900</span>.
<div class="faint" style="margin-top:6px">
macOS: System Settings → General → Sharing → Screen Sharing.<br>
Windows: install TightVNC or UltraVNC.<br>
Linux: <span class="mono">x11vnc -localhost -rfbport 5900</span>.
</div>
</li>
<li>
<strong>Install <a href="https://nodejs.org" target="_blank" rel="noopener">Node.js 22+</a></strong> if it is not already there.
</li>
<li>
<strong>Run this</strong> in a terminal:
<pre class="code" id="oneliner"></pre>
<div class="row wrap" style="margin-top:10px">
<button class="small" id="copy-unix">Copy for macOS / Linux</button>
<button class="small" id="copy-win">Copy for Windows</button>
</div>
</li>
</ol>
<p class="login-foot">
Link expires <span id="expiry"></span> · once enrolled, this machine appears in the operator console
</p>
</div>
</div>
</div>
</div>
<script>
const token = location.pathname.split('/').filter(Boolean).pop();
const origin = location.origin;
const link = `${origin}/enroll/${token}`;
const unix = `curl -fsSL ${origin}/download/agent.js -o rcs-agent.js \\\n && node rcs-agent.js enroll ${link} \\\n && node rcs-agent.js run`;
const win = `iwr ${origin}/download/agent.js -OutFile rcs-agent.js; `
+ `node rcs-agent.js enroll ${link}; node rcs-agent.js run`;
function copy(text, button) {
navigator.clipboard.writeText(text).then(() => {
const old = button.textContent;
button.textContent = 'Copied';
setTimeout(() => { button.textContent = old; }, 1500);
});
}
document.getElementById('oneliner').textContent = unix;
document.getElementById('copy-unix').onclick = (e) => copy(unix, e.currentTarget);
document.getElementById('copy-win').onclick = (e) => copy(win, e.currentTarget);
fetch(`/api/public/invite/${encodeURIComponent(token)}`)
.then((r) => r.json().then((body) => ({ ok: r.ok, body })))
.then(({ ok, body }) => {
document.getElementById('state-loading').classList.add('hidden');
if (!ok || !body.usable || body.kind !== 'enroll') {
const box = document.getElementById('state-invalid');
box.textContent = body.error || `This enrollment link is ${body.reason || 'not valid'}.`;
box.classList.remove('hidden');
return;
}
const expiry = document.getElementById('expiry');
expiry.textContent = body.expiresAt ? new Date(body.expiresAt).toLocaleString() : 'never';
document.getElementById('state-ok').classList.remove('hidden');
})
.catch(() => {
document.getElementById('state-loading').textContent = 'Could not reach the server.';
});
</script>
<script type="module">
import { mountParticles } from '/particles.js';
mountParticles(document.getElementById('bg-field'));
</script>
</body>
</html>
+165
View File
@@ -0,0 +1,165 @@
<!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>
+273
View File
@@ -0,0 +1,273 @@
/* ============================================================================
particles.js — ambient "network of machines" field.
Self-contained, no dependencies. Mounts a <canvas> into a host element and
draws slow-drifting nodes with proximity links.
import { mountParticles } from '/particles.js';
const field = mountParticles(document.getElementById('bg-field'));
field.setMode('static'); // freeze: draws one frame, then zero cost
field.destroy();
Budget rules baked in, because this runs on whatever desktop is on the desk:
- node count is derived from viewport area and hard-capped
- device pixel ratio is capped at 2
- the loop is throttled to ~30fps and uses no shadows or gradients
- rAF is cancelled outright when the tab is hidden or the mode is static
- prefers-reduced-motion renders a single static frame and never loops
========================================================================= */
'use strict';
const DEFAULTS = {
/* one node per this many CSS pixels of area */
areaPerNode: 26000,
minNodes: 14,
maxNodes: 78,
/* proximity links */
linkDistance: 138,
linkAlpha: 0.20,
/* nodes */
nodeAlpha: 0.62,
nodeSize: 2,
hubEvery: 7, /* every Nth node is drawn as a larger "hub" */
/* drift, CSS px per second */
speed: 7,
fps: 30,
colors: ['#3ddc97', '#3ddc97', '#3ddc97', '#9aa2ff', '#e9a05c'],
linkColor: '61, 220, 151',
mode: 'animate',
};
const NOOP_HANDLE = {
setMode() {},
destroy() {},
canvas: null,
};
export function mountParticles(host, options = {}) {
if (!host || typeof document === 'undefined') return NOOP_HANDLE;
const cfg = { ...DEFAULTS, ...options };
const canvas = document.createElement('canvas');
canvas.setAttribute('aria-hidden', 'true');
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
if (!ctx) return NOOP_HANDLE;
host.append(canvas);
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let nodes = [];
let width = 0;
let height = 0;
let raf = 0;
let lastFrame = 0;
let mode = cfg.mode;
let destroyed = false;
/* ------------------------------------------------------------ geometry */
function targetCount() {
const raw = Math.round((width * height) / cfg.areaPerNode);
return Math.max(cfg.minNodes, Math.min(cfg.maxNodes, raw));
}
function makeNode(index) {
const angle = Math.random() * Math.PI * 2;
return {
x: Math.random() * width,
y: Math.random() * height,
vx: Math.cos(angle) * cfg.speed * (0.35 + Math.random() * 0.85),
vy: Math.sin(angle) * cfg.speed * (0.35 + Math.random() * 0.85),
color: cfg.colors[index % cfg.colors.length],
hub: index % cfg.hubEvery === 0,
/* per-node brightness keeps the field from looking like a lattice */
alpha: cfg.nodeAlpha * (0.45 + Math.random() * 0.55),
};
}
function reconcileNodes() {
const want = targetCount();
while (nodes.length > want) nodes.pop();
while (nodes.length < want) nodes.push(makeNode(nodes.length));
for (const n of nodes) {
if (n.x > width) n.x = Math.random() * width;
if (n.y > height) n.y = Math.random() * height;
}
}
function resize() {
if (destroyed) return;
const w = host.clientWidth || window.innerWidth;
const h = host.clientHeight || window.innerHeight;
if (!w || !h) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
width = w;
height = h;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
reconcileNodes();
if (!running()) draw();
}
/* --------------------------------------------------------------- paint */
function draw() {
ctx.clearRect(0, 0, width, height);
/* links first, so nodes sit on top of them */
const max = cfg.linkDistance;
const maxSq = max * max;
ctx.lineWidth = 1;
for (let i = 0; i < nodes.length; i++) {
const a = nodes[i];
for (let j = i + 1; j < nodes.length; j++) {
const b = nodes[j];
const dx = a.x - b.x;
const dy = a.y - b.y;
const distSq = dx * dx + dy * dy;
if (distSq > maxSq) continue;
const strength = 1 - Math.sqrt(distSq) / max;
ctx.strokeStyle = `rgba(${cfg.linkColor}, ${(strength * strength * cfg.linkAlpha).toFixed(3)})`;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
/* nodes: small squares read as devices, not as bokeh */
ctx.globalAlpha = 1;
for (const n of nodes) {
const s = n.hub ? cfg.nodeSize + 1.5 : cfg.nodeSize;
ctx.fillStyle = withAlpha(n.color, n.alpha);
ctx.fillRect(n.x - s / 2, n.y - s / 2, s, s);
if (n.hub) {
ctx.strokeStyle = withAlpha(n.color, n.alpha * 0.30);
ctx.strokeRect(n.x - s * 1.6, n.y - s * 1.6, s * 3.2, s * 3.2);
}
}
}
function step(now) {
raf = window.requestAnimationFrame(step);
const interval = 1000 / cfg.fps;
const elapsed = now - lastFrame;
if (elapsed < interval) return;
/* keep the phase, but never integrate a huge dt after a stall */
lastFrame = now - (elapsed % interval);
const dt = Math.min(elapsed, 100) / 1000;
for (const n of nodes) {
n.x += n.vx * dt;
n.y += n.vy * dt;
if (n.x < 0) { n.x = 0; n.vx = -n.vx; }
else if (n.x > width) { n.x = width; n.vx = -n.vx; }
if (n.y < 0) { n.y = 0; n.vy = -n.vy; }
else if (n.y > height) { n.y = height; n.vy = -n.vy; }
}
draw();
}
/* ------------------------------------------------------------ lifecycle */
function running() { return raf !== 0; }
function shouldAnimate() {
return !destroyed
&& mode === 'animate'
&& !document.hidden
&& !motionQuery.matches;
}
function stop() {
if (raf) window.cancelAnimationFrame(raf);
raf = 0;
}
function sync() {
if (shouldAnimate()) {
if (!running()) {
lastFrame = performance.now();
raf = window.requestAnimationFrame(step);
}
return;
}
stop();
if (!destroyed && mode !== 'off') draw();
if (mode === 'off') ctx.clearRect(0, 0, width, height);
}
/* ------------------------------------------------------------- plumbing */
let resizeTimer = 0;
const onResize = () => {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(resize, 140);
};
const onVisibility = () => sync();
window.addEventListener('resize', onResize, { passive: true });
document.addEventListener('visibilitychange', onVisibility);
addMediaListener(motionQuery, sync);
let observer = null;
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(onResize);
observer.observe(host);
}
resize();
sync();
return {
canvas,
/** 'animate' | 'static' (one frozen frame) | 'off' (blank) */
setMode(next) {
if (next === mode) return;
mode = next;
sync();
},
destroy() {
destroyed = true;
stop();
window.clearTimeout(resizeTimer);
window.removeEventListener('resize', onResize);
document.removeEventListener('visibilitychange', onVisibility);
removeMediaListener(motionQuery, sync);
observer?.disconnect();
canvas.remove();
},
};
}
/* ------------------------------------------------------------------ utils */
function withAlpha(hex, alpha) {
const h = hex.replace('#', '');
const n = parseInt(h.length === 3 ? h.replace(/./g, (c) => c + c) : h, 16);
const r = (n >> 16) & 255;
const g = (n >> 8) & 255;
const b = n & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha.toFixed(3)})`;
}
/* Safari < 14 only has the deprecated listener API. */
function addMediaListener(query, fn) {
if (query.addEventListener) query.addEventListener('change', fn);
else if (query.addListener) query.addListener(fn);
}
function removeMediaListener(query, fn) {
if (query.removeEventListener) query.removeEventListener('change', fn);
else if (query.removeListener) query.removeListener(fn);
}
export default mountParticles;
+106
View File
@@ -0,0 +1,106 @@
<!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>Join remote session</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="bg-field" id="bg-field" aria-hidden="true"></div>
<div class="center-shell">
<div class="center-card" style="max-width:460px">
<div class="card-header">
<div class="brand-mark">RC</div>
<div>
<strong style="font-size:14px">Remote Support</strong>
<span class="brand-sub">Guest access</span>
</div>
</div>
<div class="card-body">
<span class="card-kicker">Support link</span>
<h1>Join a remote session</h1>
<div id="loading" class="faint">Checking this link…</div>
<div id="invalid" class="notice error hidden"></div>
<form id="join" class="hidden">
<p class="faint">
You have been given <strong id="role-text"></strong> access to
<strong id="client-name"></strong>.
</p>
<div class="field">
<label for="name">Your name</label>
<input id="name" placeholder="so the session log knows who connected" autofocus>
</div>
<button class="primary" type="submit" style="width:100%;padding:11px 14px;font-size:14px" id="join-button">Connect</button>
<p class="login-foot">Access expires <span id="expiry"></span></p>
</form>
</div>
</div>
</div>
<script>
const token = location.pathname.split('/').filter(Boolean).pop();
const el = (id) => document.getElementById(id);
fetch(`/api/public/invite/${encodeURIComponent(token)}`)
.then((r) => r.json().then((body) => ({ ok: r.ok, body })))
.then(({ ok, body }) => {
el('loading').classList.add('hidden');
if (!ok || !body.usable || body.kind !== 'session') {
el('invalid').textContent = body.error || `This link is ${body.reason || 'not valid'}.`;
el('invalid').classList.remove('hidden');
return;
}
el('role-text').textContent = body.role === 'operator' ? 'full control' : 'view only';
el('client-name').textContent = body.clientName || 'a machine';
el('expiry').textContent = body.expiresAt ? new Date(body.expiresAt).toLocaleString() : 'never';
el('join').classList.remove('hidden');
})
.catch(() => { el('loading').textContent = 'Could not reach the server.'; });
el('join').addEventListener('submit', async (event) => {
event.preventDefault();
const button = el('join-button');
button.disabled = true;
button.textContent = 'Connecting…';
try {
const res = await fetch(`/api/public/session/${encodeURIComponent(token)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: el('name').value }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'could not start the session');
// Tickets are single-use and expire in seconds, so handing one to the
// viewer in the URL is fine — the viewer strips it immediately.
const url = new URL('/viewer', location.origin);
url.searchParams.set('ticket', data.ticket);
url.searchParams.set('name', data.clientName);
url.searchParams.set('role', data.role);
location.href = url.toString();
} catch (err) {
el('invalid').textContent = err.message;
el('invalid').classList.remove('hidden');
button.disabled = false;
button.textContent = 'Connect';
}
});
</script>
<script type="module">
import { mountParticles } from '/particles.js';
mountParticles(document.getElementById('bg-field'));
</script>
</body>
</html>
+1021
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
<!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 session</title>
<link rel="stylesheet" href="/styles.css">
</head>
<!-- No particle field here: every frame belongs to the remote desktop. -->
<body class="viewer-body">
<div class="viewer-bar">
<span class="dot" id="conn-dot"></span>
<strong id="client-name">Connecting…</strong>
<span class="tag" id="role-tag" hidden></span>
<span class="viewer-sep"></span>
<span class="faint" id="conn-status"></span>
<div class="spacer"></div>
<button class="small" id="btn-cad" disabled title="Send Ctrl-Alt-Delete">Ctrl-Alt-Del</button>
<button class="small" id="btn-clipboard" disabled title="Send clipboard text to the remote machine">Paste</button>
<button class="small" id="btn-fit" disabled title="Toggle between fit-to-window and actual size">Fit</button>
<button class="small" id="btn-fullscreen" disabled>Fullscreen</button>
<span class="viewer-sep"></span>
<button class="small danger" id="btn-disconnect" disabled>Disconnect</button>
</div>
<div id="screen" style="position:relative">
<div class="viewer-status" id="status-overlay">
<div class="box">
<h2 id="status-title">Connecting…</h2>
<p class="faint" id="status-text" style="margin-bottom:0">Setting up the session.</p>
<div id="status-actions" class="row" style="justify-content:center;margin-top:18px"></div>
</div>
</div>
</div>
<script type="module" src="/viewer.js"></script>
</body>
</html>
+159
View File
@@ -0,0 +1,159 @@
import RFB from '/novnc/core/rfb.js';
// The viewer takes either a client id (mint a ticket via the API) or a ticket
// that has already been minted for it — the support-link page does the latter,
// since those visitors have no login at all.
const params = new URLSearchParams(location.search);
const token = localStorage.getItem('rcs.token');
const el = (id) => document.getElementById(id);
const overlay = el('status-overlay');
let rfb = null;
let scaled = true;
function status(title, text, actions = []) {
el('status-title').textContent = title;
el('status-text').textContent = text || '';
const box = el('status-actions');
box.innerHTML = '';
for (const a of actions) {
const b = document.createElement('button');
b.textContent = a.label;
b.className = a.primary ? 'primary' : '';
b.addEventListener('click', a.onClick);
box.append(b);
}
overlay.classList.remove('hidden');
}
function hideStatus() {
overlay.classList.add('hidden');
}
function setControls(enabled, { control = true } = {}) {
for (const id of ['btn-fit', 'btn-fullscreen', 'btn-disconnect']) el(id).disabled = !enabled;
for (const id of ['btn-cad', 'btn-clipboard']) el(id).disabled = !enabled || !control;
}
async function mintTicket() {
const clientId = params.get('client');
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ clientId, viewOnly: params.get('viewOnly') === '1' }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `could not start a session (${res.status})`);
return data;
}
function connect(session) {
el('client-name').textContent = session.clientName || 'Remote machine';
document.title = `${session.clientName || 'Remote'} — session`;
const viewOnly = session.role === 'viewer';
const roleTag = el('role-tag');
roleTag.textContent = viewOnly ? 'view only' : 'full control';
roleTag.className = viewOnly ? 'tag' : 'tag role';
roleTag.hidden = false;
if (session.requireConsent) {
status('Waiting for permission', 'Someone at that machine has to allow the connection.');
} else {
status('Connecting…', 'Negotiating with the remote desktop.');
}
const url = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/vnc?ticket=${encodeURIComponent(session.ticket)}`;
rfb = new RFB(el('screen'), url);
rfb.viewOnly = viewOnly; // the hub enforces this too; this just avoids noise
rfb.scaleViewport = true;
rfb.resizeSession = false;
rfb.background = '#05070a';
rfb.clipViewport = false;
rfb.focusOnClick = true;
rfb.addEventListener('connect', () => {
hideStatus();
el('conn-dot').className = 'dot online';
el('conn-status').textContent = 'connected';
setControls(true, { control: !viewOnly });
});
rfb.addEventListener('disconnect', (e) => {
el('conn-dot').className = 'dot offline';
el('conn-status').textContent = 'disconnected';
setControls(false);
const reason = e.detail?.reason || (e.detail?.clean ? 'The session ended.' : 'The connection dropped.');
status(e.detail?.clean ? 'Session ended' : 'Disconnected', reason, [
{ label: 'Reconnect', primary: true, onClick: () => location.reload() },
{ label: 'Close', onClick: () => window.close() },
]);
});
rfb.addEventListener('credentialsrequired', () => {
// The hub authenticates upstream, so the browser should never be asked.
status('Unexpected password prompt', 'The hub could not complete VNC authentication for this machine.');
rfb.disconnect();
});
rfb.addEventListener('securityfailure', (e) => {
status('Rejected', e.detail?.reason || 'The remote machine refused the connection.');
});
}
/* ------------------------------------------------------------- toolbar */
el('btn-cad').addEventListener('click', () => rfb?.sendCtrlAltDel());
el('btn-clipboard').addEventListener('click', async () => {
try {
const text = await navigator.clipboard.readText();
if (text) rfb?.clipboardPasteFrom(text);
} catch {
const text = prompt('Text to send to the remote machine:');
if (text) rfb?.clipboardPasteFrom(text);
}
});
el('btn-fit').addEventListener('click', (e) => {
scaled = !scaled;
if (rfb) rfb.scaleViewport = scaled;
e.currentTarget.textContent = scaled ? 'Fit' : '1:1';
});
el('btn-fullscreen').addEventListener('click', () => {
if (document.fullscreenElement) document.exitFullscreen();
else document.documentElement.requestFullscreen();
});
el('btn-disconnect').addEventListener('click', () => rfb?.disconnect());
/* ---------------------------------------------------------------- boot */
(async function start() {
try {
if (params.get('ticket')) {
connect({
ticket: params.get('ticket'),
clientName: params.get('name') || 'Remote machine',
role: params.get('role') || 'viewer',
requireConsent: params.get('consent') === '1',
});
// The ticket is single-use; drop it from the address bar and history.
history.replaceState(null, '', location.pathname);
return;
}
if (!params.get('client')) throw new Error('nothing to connect to');
if (!token) throw new Error('you are not signed in');
connect(await mintTicket());
} catch (err) {
status('Cannot start', err.message, [
{ label: 'Back to machines', primary: true, onClick: () => { location.href = '/'; } },
]);
}
})();
+138
View File
@@ -0,0 +1,138 @@
'use strict';
// Auth proxy against the garagedoor-node-ws service.
// We never hold the JWT secret here — login and validation are delegated to the
// auth service, with a short-lived validation cache to avoid hammering it.
//
// garagedoor quirk: HTTP status is 200 even on bad credentials and invalid
// tokens. Always branch on `body.result`, never on `res.ok`.
const config = require('./config');
const { grants, clients } = require('./db');
const VALIDATE_CACHE_TTL_MS = 60 * 1000;
// token -> { username, level, expiresAt }
const validateCache = new Map();
async function login(username, password) {
let res;
try {
res = await fetch(`${config.authUrl}/authenticate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
} catch {
throw Object.assign(new Error('auth service unreachable'), { status: 502 });
}
if (!res.ok) throw Object.assign(new Error('auth service error'), { status: 502 });
const body = await res.json();
if (body.result !== 'success' || !body.token) {
throw Object.assign(new Error(body.message || 'Authentication failed'), { status: 401 });
}
// Cache the level from login so isAdmin() has it without an extra round-trip.
validateCache.set(body.token, {
username: body.username,
level: body.level,
expiresAt: Date.now() + VALIDATE_CACHE_TTL_MS,
});
return { token: body.token, username: body.username, level: body.level };
}
async function validateToken(token) {
if (!token) return null;
const cached = validateCache.get(token);
if (cached && cached.expiresAt > Date.now()) return { username: cached.username, level: cached.level };
let res;
try {
res = await fetch(`${config.authUrl}/validate`, { headers: { Authorization: `Bearer ${token}` } });
} catch {
throw Object.assign(new Error('auth service unreachable'), { status: 502 });
}
if (!res.ok) return null;
const body = await res.json();
if (body.result !== 'success') return null;
// /validate does not return level; carry over whatever login cached, if anything.
const level = cached ? cached.level : undefined;
validateCache.set(token, { username: body.username, level, expiresAt: Date.now() + VALIDATE_CACHE_TTL_MS });
if (validateCache.size > 500) {
const t = Date.now();
for (const [k, v] of validateCache) if (v.expiresAt <= t) validateCache.delete(k);
}
return { username: body.username, level };
}
function isAdmin(user) {
if (!user) return false;
if (config.adminUsers.length && config.adminUsers.includes(String(user.username).toLowerCase())) return true;
if (config.adminLevel !== null && user.level !== undefined && Number(user.level) >= config.adminLevel) return true;
// No admin policy configured at all: any authenticated user is an admin. This
// keeps a fresh single-operator install usable; set ADMIN_USERS to lock down.
return !config.adminUsers.length && config.adminLevel === null;
}
function extractToken(req) {
const header = req.headers['authorization'];
if (header && header.startsWith('Bearer ')) return header.slice(7);
if (req.query && req.query.token) return String(req.query.token);
return null;
}
async function requireAuth(req, res, next) {
const token = extractToken(req);
if (!token) return res.status(401).json({ error: 'missing token' });
try {
const user = await validateToken(token);
if (!user) return res.status(401).json({ error: 'invalid or expired session' });
req.user = user;
req.username = user.username;
req.isAdmin = isAdmin(user);
next();
} catch (e) {
res.status(e.status === 502 ? 502 : 500).json({ error: 'auth service unreachable' });
}
}
function requireAdmin(req, res, next) {
if (!req.isAdmin) return res.status(403).json({ error: 'admin only' });
next();
}
/**
* Effective role for a user on a client: 'admin' | 'operator' | 'viewer' | null.
* Admins get full control on everything; everyone else needs an unexpired grant.
*/
function roleForClient(user, clientId, admin) {
if (admin ?? isAdmin(user)) return 'admin';
const g = grants.find(clientId, user.username);
if (!g) return null;
if (g.expires_at && g.expires_at < Date.now()) return null;
return g.role === 'operator' ? 'operator' : 'viewer';
}
// Roles that may send keyboard/mouse input. Everything else is filtered to view-only.
function canControl(role) {
return role === 'admin' || role === 'operator';
}
function visibleClients(user, admin) {
return (admin ?? isAdmin(user)) ? clients.list() : clients.listForUser(user.username);
}
module.exports = {
login,
validateToken,
requireAuth,
requireAdmin,
isAdmin,
extractToken,
roleForClient,
canControl,
visibleClients,
};
+51
View File
@@ -0,0 +1,51 @@
'use strict';
const path = require('path');
function bool(v, dflt) {
if (v === undefined || v === '') return dflt;
return /^(1|true|yes|on)$/i.test(String(v));
}
function list(v) {
return String(v || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
}
const config = {
port: Number(process.env.PORT || 8080),
host: process.env.HOST || '0.0.0.0',
// garagedoor-node-ws central auth
authUrl: (process.env.AUTH_URL || 'http://192.168.4.208:8000').replace(/\/$/, ''),
// Admins: username allowlist, or garagedoor `level` at/above this threshold.
adminUsers: list(process.env.ADMIN_USERS),
adminLevel: process.env.ADMIN_LEVEL === '' || process.env.ADMIN_LEVEL === undefined
? null
: Number(process.env.ADMIN_LEVEL),
dbPath: process.env.DB_PATH || path.join(__dirname, '..', 'data', 'rcs.db'),
// Key material for AES-256-GCM at-rest encryption of VNC passwords / agent keys.
encryptionKey: process.env.ENCRYPTION_KEY || '',
// Public base URL, used when rendering invite links. Falls back to the request host.
publicUrl: (process.env.PUBLIC_URL || '').replace(/\/$/, ''),
ticketTtlMs: Number(process.env.TICKET_TTL_MS || 30_000),
inviteDefaultTtlMs: Number(process.env.INVITE_TTL_MS || 24 * 60 * 60 * 1000),
agentOfflineAfterMs: Number(process.env.AGENT_OFFLINE_AFTER_MS || 90_000),
consentTimeoutMs: Number(process.env.CONSENT_TIMEOUT_MS || 45_000),
// Session invites let unauthenticated people connect. Off by default is safer,
// but the whole point of this app is handing a link to someone, so: on.
allowSessionInvites: bool(process.env.ALLOW_SESSION_INVITES, true),
trustProxy: bool(process.env.TRUST_PROXY, true),
logLevel: process.env.LOG_LEVEL || 'info',
};
module.exports = config;
+81
View File
@@ -0,0 +1,81 @@
'use strict';
// Secrets at rest: VNC passwords and agent keys are AES-256-GCM encrypted.
// Tokens that we only ever need to *compare* (invite tokens, agent keys as
// presented by a client) are stored as SHA-256 and checked in constant time.
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const config = require('./config');
const KEY_FILE = path.join(path.dirname(config.dbPath), 'encryption.key');
let keyCache = null;
// A stable 32-byte key. Prefer ENCRYPTION_KEY from the environment; otherwise
// generate one next to the database so a bare `npm start` still works and stays
// decryptable across restarts.
function key() {
if (keyCache) return keyCache;
let material = config.encryptionKey;
if (!material) {
fs.mkdirSync(path.dirname(KEY_FILE), { recursive: true });
if (fs.existsSync(KEY_FILE)) {
material = fs.readFileSync(KEY_FILE, 'utf8').trim();
} else {
material = crypto.randomBytes(32).toString('hex');
fs.writeFileSync(KEY_FILE, material, { mode: 0o600 });
console.warn(`[crypto] ENCRYPTION_KEY not set — generated one at ${KEY_FILE}. Back it up.`);
}
}
keyCache = crypto.createHash('sha256').update(material, 'utf8').digest();
return keyCache;
}
function encrypt(plaintext) {
if (plaintext === null || plaintext === undefined || plaintext === '') return null;
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `v1.${iv.toString('base64')}.${tag.toString('base64')}.${ct.toString('base64')}`;
}
function decrypt(blob) {
if (!blob) return null;
const parts = String(blob).split('.');
if (parts.length !== 4 || parts[0] !== 'v1') return null;
try {
const decipher = crypto.createDecipheriv('aes-256-gcm', key(), Buffer.from(parts[1], 'base64'));
decipher.setAuthTag(Buffer.from(parts[2], 'base64'));
return Buffer.concat([decipher.update(Buffer.from(parts[3], 'base64')), decipher.final()]).toString('utf8');
} catch {
// Wrong key or tampered ciphertext — treat as absent rather than crashing a session.
return null;
}
}
// URL-safe random token, 32 bytes of entropy.
function randomToken(bytes = 32) {
return crypto.randomBytes(bytes).toString('base64url');
}
function sha256(value) {
return crypto.createHash('sha256').update(String(value), 'utf8').digest('hex');
}
function timingSafeEqualHex(a, b) {
const ba = Buffer.from(String(a || ''), 'hex');
const bb = Buffer.from(String(b || ''), 'hex');
if (ba.length !== bb.length || ba.length === 0) return false;
return crypto.timingSafeEqual(ba, bb);
}
function uuid() {
return crypto.randomUUID();
}
module.exports = { encrypt, decrypt, randomToken, sha256, timingSafeEqualHex, uuid };
+309
View File
@@ -0,0 +1,309 @@
'use strict';
// Persistence. Uses Node's built-in SQLite so the app has zero native build
// dependencies — important because this ships as a container to unraid.
// Positional (?) parameters only: named-parameter binding differs between
// node:sqlite releases.
const fs = require('fs');
const path = require('path');
const { DatabaseSync } = require('node:sqlite');
const config = require('./config');
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
const db = new DatabaseSync(config.dbPath);
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS clients (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
mode TEXT NOT NULL DEFAULT 'direct', -- 'direct' | 'agent'
host TEXT,
port INTEGER NOT NULL DEFAULT 5900,
vnc_password_enc TEXT,
agent_key_hash TEXT,
require_consent INTEGER NOT NULL DEFAULT 0,
tags TEXT NOT NULL DEFAULT '',
os TEXT,
hostname TEXT,
agent_version TEXT,
last_seen_at INTEGER,
last_ip TEXT,
enrolled_at INTEGER,
created_by TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_clients_name ON clients(name);
CREATE TABLE IF NOT EXISTS grants (
id TEXT PRIMARY KEY,
client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
username TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer', -- 'viewer' | 'operator'
expires_at INTEGER,
created_by TEXT,
created_at INTEGER NOT NULL,
UNIQUE (client_id, username)
);
CREATE INDEX IF NOT EXISTS idx_grants_username ON grants(username);
CREATE TABLE IF NOT EXISTS invites (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL, -- 'enroll' | 'session'
client_id TEXT REFERENCES clients(id) ON DELETE CASCADE,
label TEXT,
role TEXT, -- session invites
prefill TEXT, -- enroll invites, JSON
max_uses INTEGER NOT NULL DEFAULT 1,
uses INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER,
revoked_at INTEGER,
last_used_at INTEGER,
created_by TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_invites_client ON invites(client_id);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
client_name TEXT,
username TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'web', -- 'web' | 'invite'
invite_id TEXT,
role TEXT NOT NULL,
remote_ip TEXT,
started_at INTEGER NOT NULL,
ended_at INTEGER,
bytes_in INTEGER NOT NULL DEFAULT 0,
bytes_out INTEGER NOT NULL DEFAULT 0,
end_reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_client ON sessions(client_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
username TEXT,
action TEXT NOT NULL,
target TEXT,
detail TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(ts DESC);
`);
const now = () => Date.now();
/* ---------------------------------------------------------------- clients */
const clients = {
list() {
return db.prepare('SELECT * FROM clients ORDER BY name COLLATE NOCASE').all();
},
listForUser(username) {
return db
.prepare(
`SELECT c.*, g.role AS granted_role, g.expires_at AS grant_expires_at
FROM clients c
JOIN grants g ON g.client_id = c.id
WHERE g.username = ?
AND (g.expires_at IS NULL OR g.expires_at > ?)
ORDER BY c.name COLLATE NOCASE`
)
.all(username, now());
},
get(id) {
return db.prepare('SELECT * FROM clients WHERE id = ?').get(id);
},
getByName(name) {
return db.prepare('SELECT * FROM clients WHERE name = ? COLLATE NOCASE').get(name);
},
create(c) {
const ts = now();
db.prepare(
`INSERT INTO clients
(id, name, description, mode, host, port, vnc_password_enc, agent_key_hash,
require_consent, tags, os, hostname, agent_version, enrolled_at,
created_by, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
).run(
c.id, c.name, c.description ?? null, c.mode, c.host ?? null, c.port ?? 5900,
c.vnc_password_enc ?? null, c.agent_key_hash ?? null,
c.require_consent ? 1 : 0, c.tags ?? '', c.os ?? null, c.hostname ?? null,
c.agent_version ?? null, c.enrolled_at ?? null, c.created_by ?? null, ts, ts
);
return clients.get(c.id);
},
update(id, fields) {
const allowed = [
'name', 'description', 'mode', 'host', 'port', 'vnc_password_enc', 'agent_key_hash',
'require_consent', 'tags', 'os', 'hostname', 'agent_version', 'last_seen_at',
'last_ip', 'enrolled_at',
];
const keys = Object.keys(fields).filter((k) => allowed.includes(k));
if (!keys.length) return clients.get(id);
const sql = `UPDATE clients SET ${keys.map((k) => `${k} = ?`).join(', ')}, updated_at = ? WHERE id = ?`;
db.prepare(sql).run(...keys.map((k) => fields[k]), now(), id);
return clients.get(id);
},
touch(id, ip) {
db.prepare('UPDATE clients SET last_seen_at = ?, last_ip = ? WHERE id = ?').run(now(), ip ?? null, id);
},
remove(id) {
db.prepare('DELETE FROM clients WHERE id = ?').run(id);
},
};
/* ----------------------------------------------------------------- grants */
const grants = {
listForClient(clientId) {
return db.prepare('SELECT * FROM grants WHERE client_id = ? ORDER BY username').all(clientId);
},
listForUser(username) {
return db.prepare('SELECT * FROM grants WHERE username = ?').all(username);
},
find(clientId, username) {
return db
.prepare('SELECT * FROM grants WHERE client_id = ? AND username = ? COLLATE NOCASE')
.get(clientId, username);
},
upsert(g) {
db.prepare(
`INSERT INTO grants (id, client_id, username, role, expires_at, created_by, created_at)
VALUES (?,?,?,?,?,?,?)
ON CONFLICT(client_id, username)
DO UPDATE SET role = excluded.role, expires_at = excluded.expires_at`
).run(g.id, g.client_id, g.username.toLowerCase(), g.role, g.expires_at ?? null, g.created_by ?? null, now());
return grants.find(g.client_id, g.username);
},
remove(clientId, username) {
db.prepare('DELETE FROM grants WHERE client_id = ? AND username = ? COLLATE NOCASE')
.run(clientId, username);
},
};
/* ---------------------------------------------------------------- invites */
const invites = {
list() {
return db
.prepare(
`SELECT i.*, c.name AS client_name
FROM invites i LEFT JOIN clients c ON c.id = i.client_id
ORDER BY i.created_at DESC`
)
.all();
},
get(id) {
return db.prepare('SELECT * FROM invites WHERE id = ?').get(id);
},
findByHash(hash) {
return db.prepare('SELECT * FROM invites WHERE token_hash = ?').get(hash);
},
create(i) {
db.prepare(
`INSERT INTO invites
(id, token_hash, kind, client_id, label, role, prefill, max_uses, uses, expires_at, created_by, created_at)
VALUES (?,?,?,?,?,?,?,?,0,?,?,?)`
).run(
i.id, i.token_hash, i.kind, i.client_id ?? null, i.label ?? null, i.role ?? null,
i.prefill ?? null, i.max_uses ?? 1, i.expires_at ?? null, i.created_by ?? null, now()
);
return invites.get(i.id);
},
consume(id) {
db.prepare('UPDATE invites SET uses = uses + 1, last_used_at = ? WHERE id = ?').run(now(), id);
},
revoke(id) {
db.prepare('UPDATE invites SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL').run(now(), id);
},
remove(id) {
db.prepare('DELETE FROM invites WHERE id = ?').run(id);
},
};
// An invite is usable when it is not revoked, not expired, and has uses left.
function inviteUsable(inv) {
if (!inv) return { ok: false, reason: 'not found' };
if (inv.revoked_at) return { ok: false, reason: 'revoked' };
if (inv.expires_at && inv.expires_at < now()) return { ok: false, reason: 'expired' };
if (inv.max_uses > 0 && inv.uses >= inv.max_uses) return { ok: false, reason: 'already used' };
return { ok: true };
}
/* --------------------------------------------------------------- sessions */
const sessions = {
start(s) {
db.prepare(
`INSERT INTO sessions
(id, client_id, client_name, username, source, invite_id, role, remote_ip, started_at)
VALUES (?,?,?,?,?,?,?,?,?)`
).run(
s.id, s.client_id, s.client_name ?? null, s.username, s.source ?? 'web',
s.invite_id ?? null, s.role, s.remote_ip ?? null, now()
);
},
end(id, { bytesIn = 0, bytesOut = 0, reason = 'closed' } = {}) {
db.prepare(
`UPDATE sessions SET ended_at = ?, bytes_in = ?, bytes_out = ?, end_reason = ?
WHERE id = ? AND ended_at IS NULL`
).run(now(), bytesIn, bytesOut, reason, id);
},
// Anything still marked open at boot was killed by a restart, not by a user.
closeOrphans() {
const r = db
.prepare(`UPDATE sessions SET ended_at = ?, end_reason = 'server restart' WHERE ended_at IS NULL`)
.run(now());
return r.changes;
},
recent(limit = 100, clientId = null) {
return clientId
? db.prepare('SELECT * FROM sessions WHERE client_id = ? ORDER BY started_at DESC LIMIT ?').all(clientId, limit)
: db.prepare('SELECT * FROM sessions ORDER BY started_at DESC LIMIT ?').all(limit);
},
};
/* ------------------------------------------------------------------ audit */
function audit(username, action, target, detail) {
db.prepare('INSERT INTO audit (ts, username, action, target, detail) VALUES (?,?,?,?,?)').run(
now(), username ?? null, action, target ?? null,
detail === undefined || detail === null ? null : typeof detail === 'string' ? detail : JSON.stringify(detail)
);
}
audit.recent = (limit = 200) =>
db.prepare('SELECT * FROM audit ORDER BY ts DESC LIMIT ?').all(limit);
module.exports = { db, clients, grants, invites, inviteUsable, sessions, audit };
+195
View File
@@ -0,0 +1,195 @@
'use strict';
const http = require('http');
const path = require('path');
const express = require('express');
const { WebSocketServer } = require('ws');
const config = require('./config');
const { clients, sessions, audit } = require('./db');
const { sha256, timingSafeEqualHex } = require('./crypto');
const auth = require('./auth');
const tickets = require('./tickets');
const hub = require('./vnc/hub');
const bridge = require('./vnc/bridge');
const clientsRoutes = require('./routes/clients');
const invitesRoutes = require('./routes/invites');
const sessionsRoutes = require('./routes/sessions');
const publicRoutes = require('./routes/public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const NOVNC_DIR = path.join(__dirname, '..', 'node_modules', '@novnc', 'novnc');
const app = express();
if (config.trustProxy) app.set('trust proxy', true);
app.use(express.json({ limit: '256kb' }));
/* ------------------------------------------------------------------ auth */
app.post('/api/login', async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) return res.status(400).json({ error: 'username and password are required' });
try {
const result = await auth.login(String(username), String(password));
audit(result.username, 'login', null, { ip: req.ip });
res.json({
token: result.token,
username: result.username,
isAdmin: auth.isAdmin(result),
});
} catch (err) {
res.status(err.status || 500).json({ error: err.message });
}
});
app.get('/api/me', auth.requireAuth, (req, res) => {
res.json({ username: req.username, isAdmin: req.isAdmin });
});
app.get('/api/health', (_req, res) => {
res.json({ ok: true, agentsOnline: hub.onlineIds().length, liveSessions: bridge.listLive().length });
});
/* ---------------------------------------------------------------- routes */
app.use('/api/clients', clientsRoutes.router);
app.use('/api/invites', invitesRoutes.router);
app.use('/api/sessions', sessionsRoutes.router);
app.use('/api/public', publicRoutes.router);
/* ----------------------------------------------------------------- pages */
// Served unauthenticated on purpose: the enrolment page tells a machine to curl
// this, and the agent is useless without a valid enrolment token anyway.
app.get('/download/agent.js', (_req, res) => {
res.type('application/javascript');
res.sendFile(path.join(__dirname, '..', 'agent', 'agent.js'));
});
app.use('/novnc', express.static(NOVNC_DIR, { maxAge: '7d', immutable: true }));
app.use(express.static(PUBLIC_DIR));
app.get('/viewer', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'viewer.html')));
app.get('/enroll/:token', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'enroll.html')));
app.get('/s/:token', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'share.html')));
app.use((req, res) => {
if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'not found' });
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
});
// eslint-disable-next-line no-unused-vars -- Express identifies error handlers by arity
app.use((err, req, res, _next) => {
console.error('[http]', err);
res.status(500).json({ error: 'internal error' });
});
/* ------------------------------------------------------------ websockets */
const server = http.createServer(app);
const vncWss = new WebSocketServer({ noServer: true });
const agentWss = new WebSocketServer({ noServer: true });
const tunnelWss = new WebSocketServer({ noServer: true });
function clientIp(req) {
if (config.trustProxy) {
const fwd = req.headers['x-forwarded-for'];
if (fwd) return String(fwd).split(',')[0].trim();
}
return req.socket.remoteAddress;
}
/** Agent sockets authenticate with the key issued at enrolment, compared by hash. */
function authenticateAgent(params) {
const clientId = params.get('clientId');
const key = params.get('key');
if (!clientId || !key) return null;
const client = clients.get(clientId);
if (!client || !client.agent_key_hash) return null;
if (!timingSafeEqualHex(sha256(key), client.agent_key_hash)) return null;
return client;
}
function reject(socket, code, message) {
socket.write(`HTTP/1.1 ${code} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
socket.destroy();
}
server.on('upgrade', (req, socket, head) => {
let url;
try {
url = new URL(req.url, 'http://localhost');
} catch {
return reject(socket, 400, 'Bad Request');
}
const params = url.searchParams;
const ip = clientIp(req);
if (url.pathname === '/ws/vnc') {
const payload = tickets.redeem(params.get('ticket'));
if (!payload) return reject(socket, 401, 'Unauthorized');
const client = clients.get(payload.clientId);
if (!client) return reject(socket, 404, 'Not Found');
return vncWss.handleUpgrade(req, socket, head, (ws) => {
ws.binaryType = 'nodebuffer';
bridge.startSession(ws, {
sessionId: payload.sessionId,
client,
username: payload.username,
role: payload.role,
source: payload.source,
inviteId: payload.inviteId,
remoteIp: ip,
}).catch((err) => {
console.error('[vnc] session failed', err);
try { ws.close(4500, String(err.message).slice(0, 120)); } catch { /* gone */ }
});
});
}
if (url.pathname === '/ws/agent') {
const client = authenticateAgent(params);
if (!client) return reject(socket, 401, 'Unauthorized');
return agentWss.handleUpgrade(req, socket, head, (ws) => {
hub.handleAgentSocket(ws, client, ip);
});
}
if (url.pathname === '/ws/tunnel') {
const client = authenticateAgent(params);
const tunnelId = params.get('tunnelId');
if (!client || !tunnelId) return reject(socket, 401, 'Unauthorized');
return tunnelWss.handleUpgrade(req, socket, head, (ws) => {
ws.binaryType = 'nodebuffer';
hub.handleTunnelSocket(ws, client.id, tunnelId);
});
}
reject(socket, 404, 'Not Found');
});
/* ------------------------------------------------------------------ boot */
const orphans = sessions.closeOrphans();
if (orphans) console.log(`[boot] closed ${orphans} session(s) left open by a previous run`);
server.listen(config.port, config.host, () => {
console.log(`[boot] remote-control-support listening on http://${config.host}:${config.port}`);
console.log(`[boot] auth service: ${config.authUrl}`);
if (!config.adminUsers.length && config.adminLevel === null) {
console.warn('[boot] no ADMIN_USERS or ADMIN_LEVEL set — every authenticated user is an admin');
}
});
function shutdown(signal) {
console.log(`[boot] ${signal} received, shutting down`);
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 5000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
module.exports = { app, server };
+197
View File
@@ -0,0 +1,197 @@
'use strict';
const express = require('express');
const { clients, grants, audit } = require('../db');
const { encrypt, uuid, sha256, randomToken } = require('../crypto');
const { requireAuth, requireAdmin, roleForClient, visibleClients } = require('../auth');
const hub = require('../vnc/hub');
const bridge = require('../vnc/bridge');
const router = express.Router();
/** Shape a client row for the API. The stored VNC password never leaves the hub. */
function publicClient(c, extra = {}) {
return {
id: c.id,
name: c.name,
description: c.description,
mode: c.mode,
host: c.host,
port: c.port,
hasPassword: !!c.vnc_password_enc,
enrolled: !!c.agent_key_hash,
requireConsent: !!c.require_consent,
tags: c.tags ? c.tags.split(',').filter(Boolean) : [],
os: c.os,
hostname: c.hostname,
agentVersion: c.agent_version,
lastSeenAt: c.last_seen_at,
lastIp: c.last_ip,
createdBy: c.created_by,
createdAt: c.created_at,
online: c.mode === 'agent' ? hub.isOnline(c.id) : null,
grantedRole: c.granted_role,
...extra,
};
}
function normalizeTags(tags) {
if (Array.isArray(tags)) return tags.map((t) => String(t).trim()).filter(Boolean).join(',');
if (typeof tags === 'string') return tags.split(',').map((t) => t.trim()).filter(Boolean).join(',');
return '';
}
function validate(body, { partial = false } = {}) {
const errors = [];
const out = {};
if (body.name !== undefined) {
const name = String(body.name).trim();
if (!name) errors.push('name is required');
else if (name.length > 100) errors.push('name is too long');
else out.name = name;
} else if (!partial) {
errors.push('name is required');
}
if (body.mode !== undefined) {
if (!['direct', 'agent'].includes(body.mode)) errors.push('mode must be "direct" or "agent"');
else out.mode = body.mode;
} else if (!partial) {
out.mode = 'direct';
}
if (body.host !== undefined) out.host = body.host ? String(body.host).trim() : null;
if (body.port !== undefined && body.port !== null && body.port !== '') {
const port = Number(body.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) errors.push('port must be between 1 and 65535');
else out.port = port;
}
if (body.description !== undefined) out.description = body.description ? String(body.description) : null;
if (body.tags !== undefined) out.tags = normalizeTags(body.tags);
if (body.requireConsent !== undefined) out.require_consent = body.requireConsent ? 1 : 0;
// "" clears the stored password; undefined leaves it alone.
if (body.vncPassword !== undefined) {
out.vnc_password_enc = body.vncPassword ? encrypt(String(body.vncPassword)) : null;
}
const mode = out.mode || (partial ? undefined : 'direct');
if (mode === 'direct' && !partial && !out.host) errors.push('direct clients need a host');
return { value: out, errors };
}
router.use(requireAuth);
router.get('/', (req, res) => {
const rows = visibleClients(req.user, req.isAdmin);
res.json({ clients: rows.map((c) => publicClient(c)), isAdmin: req.isAdmin });
});
router.get('/:id', (req, res) => {
const client = clients.get(req.params.id);
if (!client) return res.status(404).json({ error: 'no such client' });
const role = roleForClient(req.user, client.id, req.isAdmin);
if (!role) return res.status(403).json({ error: 'you do not have access to this client' });
res.json({
client: publicClient(client, { yourRole: role, agent: hub.agentInfo(client.id) }),
grants: req.isAdmin ? grants.listForClient(client.id) : undefined,
});
});
router.post('/', requireAdmin, (req, res) => {
const { value, errors } = validate(req.body || {});
if (errors.length) return res.status(400).json({ error: errors.join('; ') });
if (clients.getByName(value.name)) return res.status(409).json({ error: 'a client with that name already exists' });
const id = uuid();
const created = clients.create({ ...value, id, created_by: req.username });
audit(req.username, 'client.create', id, { name: created.name, mode: created.mode });
res.status(201).json({ client: publicClient(created) });
});
router.patch('/:id', requireAdmin, (req, res) => {
const client = clients.get(req.params.id);
if (!client) return res.status(404).json({ error: 'no such client' });
const { value, errors } = validate(req.body || {}, { partial: true });
if (errors.length) return res.status(400).json({ error: errors.join('; ') });
if (value.name && value.name !== client.name) {
const clash = clients.getByName(value.name);
if (clash && clash.id !== client.id) return res.status(409).json({ error: 'a client with that name already exists' });
}
const updated = clients.update(client.id, value);
audit(req.username, 'client.update', client.id, Object.keys(value));
res.json({ client: publicClient(updated) });
});
router.delete('/:id', requireAdmin, (req, res) => {
const client = clients.get(req.params.id);
if (!client) return res.status(404).json({ error: 'no such client' });
bridge.killSessionsForClient(client.id, 'client removed');
hub.disconnectAgent(client.id, 'client removed');
clients.remove(client.id);
audit(req.username, 'client.delete', client.id, { name: client.name });
res.json({ ok: true });
});
/**
* Issue a fresh agent key. Returned exactly once — only its hash is stored — so
* the UI has to show it to the operator there and then.
*/
router.post('/:id/agent-key', requireAdmin, (req, res) => {
const client = clients.get(req.params.id);
if (!client) return res.status(404).json({ error: 'no such client' });
const agentKey = randomToken(32);
clients.update(client.id, { agent_key_hash: sha256(agentKey), mode: 'agent' });
hub.disconnectAgent(client.id, 'agent key rotated');
audit(req.username, 'client.agent-key', client.id, null);
res.json({ agentKey, clientId: client.id });
});
/* ---------------------------------------------------------------- grants */
router.get('/:id/grants', requireAdmin, (req, res) => {
if (!clients.get(req.params.id)) return res.status(404).json({ error: 'no such client' });
res.json({ grants: grants.listForClient(req.params.id) });
});
router.post('/:id/grants', requireAdmin, (req, res) => {
const client = clients.get(req.params.id);
if (!client) return res.status(404).json({ error: 'no such client' });
const username = String((req.body || {}).username || '').trim();
const role = (req.body || {}).role === 'operator' ? 'operator' : 'viewer';
const expiresAt = (req.body || {}).expiresAt ? Number((req.body || {}).expiresAt) : null;
if (!username) return res.status(400).json({ error: 'username is required' });
const grant = grants.upsert({
id: uuid(),
client_id: client.id,
username,
role,
expires_at: expiresAt,
created_by: req.username,
});
audit(req.username, 'grant.set', client.id, { username, role, expiresAt });
res.status(201).json({ grant });
});
router.delete('/:id/grants/:username', requireAdmin, (req, res) => {
if (!clients.get(req.params.id)) return res.status(404).json({ error: 'no such client' });
grants.remove(req.params.id, req.params.username);
audit(req.username, 'grant.remove', req.params.id, { username: req.params.username });
res.json({ ok: true });
});
module.exports = { router, publicClient };
+136
View File
@@ -0,0 +1,136 @@
'use strict';
const express = require('express');
const config = require('../config');
const { invites, clients, audit } = require('../db');
const { uuid, randomToken, sha256 } = require('../crypto');
const { requireAuth, requireAdmin } = require('../auth');
const router = express.Router();
function baseUrl(req) {
if (config.publicUrl) return config.publicUrl;
const proto = req.headers['x-forwarded-proto'] || req.protocol;
return `${proto}://${req.get('host')}`;
}
function inviteLink(req, kind, token) {
return `${baseUrl(req)}/${kind === 'enroll' ? 'enroll' : 's'}/${token}`;
}
function publicInvite(i) {
return {
id: i.id,
kind: i.kind,
clientId: i.client_id,
clientName: i.client_name,
label: i.label,
role: i.role,
maxUses: i.max_uses,
uses: i.uses,
expiresAt: i.expires_at,
revokedAt: i.revoked_at,
lastUsedAt: i.last_used_at,
createdBy: i.created_by,
createdAt: i.created_at,
status: i.revoked_at
? 'revoked'
: i.expires_at && i.expires_at < Date.now()
? 'expired'
: i.max_uses > 0 && i.uses >= i.max_uses
? 'used up'
: 'active',
};
}
router.use(requireAuth, requireAdmin);
router.get('/', (req, res) => {
res.json({ invites: invites.list().map(publicInvite) });
});
/**
* Two kinds of invite:
* - enroll: hand to a *machine*. Redeeming it registers a new client.
* - session: hand to a *person*. Redeeming it grants time-boxed access to one client.
*/
router.post('/', (req, res) => {
const body = req.body || {};
const kind = body.kind === 'enroll' ? 'enroll' : 'session';
let clientId = null;
let role = null;
let prefill = null;
if (kind === 'session') {
if (!config.allowSessionInvites) {
return res.status(403).json({ error: 'session invites are disabled on this server' });
}
const client = clients.get(String(body.clientId || ''));
if (!client) return res.status(400).json({ error: 'a valid clientId is required for a session invite' });
clientId = client.id;
role = body.role === 'operator' ? 'operator' : 'viewer';
} else {
// Defaults applied to whatever machine redeems this enrollment link.
prefill = JSON.stringify({
name: body.name ? String(body.name).trim() : null,
tags: body.tags ? String(body.tags) : '',
requireConsent: body.requireConsent ? 1 : 0,
});
if (body.clientId) {
// Re-enrolling an existing client (replace a machine, rotate its key).
const client = clients.get(String(body.clientId));
if (!client) return res.status(400).json({ error: 'no such client' });
clientId = client.id;
}
}
const ttlMs = Number(body.ttlMs) > 0 ? Number(body.ttlMs) : config.inviteDefaultTtlMs;
// An enrollment link is meant for exactly one machine. A support link should
// survive a dropped connection, so it defaults to unlimited uses until it expires.
const defaultMaxUses = kind === 'enroll' ? 1 : 0;
const maxUses = body.maxUses === undefined || body.maxUses === null || body.maxUses === ''
? defaultMaxUses
: Math.max(0, Number.parseInt(body.maxUses, 10) || 0);
const token = randomToken(32);
const invite = invites.create({
id: uuid(),
token_hash: sha256(token),
kind,
client_id: clientId,
label: body.label ? String(body.label).slice(0, 200) : null,
role,
prefill,
max_uses: maxUses,
expires_at: Date.now() + ttlMs,
created_by: req.username,
});
audit(req.username, 'invite.create', invite.id, { kind, clientId, role, maxUses, ttlMs });
// The token itself is shown exactly once; only its hash is persisted.
res.status(201).json({
invite: publicInvite({ ...invite, client_name: clientId ? clients.get(clientId)?.name : null }),
url: inviteLink(req, kind, token),
token,
});
});
router.post('/:id/revoke', (req, res) => {
const invite = invites.get(req.params.id);
if (!invite) return res.status(404).json({ error: 'no such invite' });
invites.revoke(invite.id);
audit(req.username, 'invite.revoke', invite.id, null);
res.json({ ok: true });
});
router.delete('/:id', (req, res) => {
const invite = invites.get(req.params.id);
if (!invite) return res.status(404).json({ error: 'no such invite' });
invites.remove(invite.id);
audit(req.username, 'invite.delete', invite.id, null);
res.json({ ok: true });
});
module.exports = { router, baseUrl, publicInvite };
+172
View File
@@ -0,0 +1,172 @@
'use strict';
// Unauthenticated endpoints backing the two invite link types. Everything here
// is reachable without a login, so each handler is rate limited and every token
// is looked up by hash.
const express = require('express');
const config = require('../config');
const { invites, inviteUsable, clients, audit } = require('../db');
const { uuid, randomToken, sha256 } = require('../crypto');
const tickets = require('../tickets');
const router = express.Router();
/* Crude per-IP limiter: enough to make token guessing pointless without pulling
in a dependency. Buckets refill continuously and are swept on a timer. */
const buckets = new Map();
const LIMIT = 30;
const WINDOW_MS = 60_000;
function rateLimit(req, res, next) {
const ip = req.ip || 'unknown';
const now = Date.now();
const b = buckets.get(ip) || { count: 0, resetAt: now + WINDOW_MS };
if (b.resetAt < now) {
b.count = 0;
b.resetAt = now + WINDOW_MS;
}
b.count++;
buckets.set(ip, b);
if (b.count > LIMIT) {
return res.status(429).json({ error: 'too many attempts, wait a minute' });
}
next();
}
setInterval(() => {
const now = Date.now();
for (const [ip, b] of buckets) if (b.resetAt < now) buckets.delete(ip);
}, WINDOW_MS).unref?.();
router.use(rateLimit);
function lookup(token) {
const invite = invites.findByHash(sha256(String(token || '')));
const usable = inviteUsable(invite);
return { invite, usable };
}
/** What a landing page needs to render, without consuming a use. */
router.get('/invite/:token', (req, res) => {
const { invite, usable } = lookup(req.params.token);
if (!invite) return res.status(404).json({ error: 'this link is not valid' });
const client = invite.client_id ? clients.get(invite.client_id) : null;
res.json({
kind: invite.kind,
label: invite.label,
role: invite.role,
clientName: client ? client.name : null,
expiresAt: invite.expires_at,
usable: usable.ok,
reason: usable.ok ? null : usable.reason,
});
});
/**
* A machine redeems an enrollment link. Returns an agent key, shown once and
* stored only as a hash — the agent keeps it and reconnects with it forever.
*/
router.post('/enroll', (req, res) => {
const body = req.body || {};
const { invite, usable } = lookup(body.token);
if (!invite || invite.kind !== 'enroll') return res.status(404).json({ error: 'this enrollment link is not valid' });
if (!usable.ok) return res.status(410).json({ error: `this enrollment link is ${usable.reason}` });
const prefill = invite.prefill ? JSON.parse(invite.prefill) : {};
const hostname = String(body.hostname || '').trim().slice(0, 100) || 'unnamed machine';
const agentKey = randomToken(32);
let client;
if (invite.client_id) {
// Re-enrolment of an existing entry: keep its grants and history, new key.
client = clients.get(invite.client_id);
if (!client) return res.status(410).json({ error: 'the client this link pointed at has been deleted' });
clients.update(client.id, {
mode: 'agent',
agent_key_hash: sha256(agentKey),
hostname,
os: body.os ? String(body.os).slice(0, 60) : null,
agent_version: body.agentVersion ? String(body.agentVersion).slice(0, 30) : null,
port: Number(body.vncPort) || client.port || 5900,
enrolled_at: Date.now(),
last_ip: req.ip,
});
} else {
let name = (prefill.name || body.name || hostname).trim().slice(0, 100);
// Names are how operators pick a machine, so keep them unique.
if (clients.getByName(name)) {
let n = 2;
while (clients.getByName(`${name} (${n})`)) n++;
name = `${name} (${n})`;
}
client = clients.create({
id: uuid(),
name,
mode: 'agent',
host: null,
port: Number(body.vncPort) || 5900,
agent_key_hash: sha256(agentKey),
require_consent: prefill.requireConsent ? 1 : 0,
tags: prefill.tags || '',
os: body.os ? String(body.os).slice(0, 60) : null,
hostname,
agent_version: body.agentVersion ? String(body.agentVersion).slice(0, 30) : null,
enrolled_at: Date.now(),
created_by: invite.created_by,
});
}
invites.consume(invite.id);
audit(invite.created_by, 'client.enroll', client.id, { name: client.name, hostname, ip: req.ip });
res.status(201).json({
clientId: client.id,
name: client.name,
agentKey,
requireConsent: !!client.require_consent,
});
});
/**
* A person redeems a support link. No login: the invite itself is the
* authorisation, and it fixes both the target machine and the role.
*/
router.post('/session/:token', (req, res) => {
if (!config.allowSessionInvites) return res.status(403).json({ error: 'session invites are disabled' });
const { invite, usable } = lookup(req.params.token);
if (!invite || invite.kind !== 'session') return res.status(404).json({ error: 'this link is not valid' });
if (!usable.ok) return res.status(410).json({ error: `this link is ${usable.reason}` });
const client = clients.get(invite.client_id);
if (!client) return res.status(410).json({ error: 'the machine this link pointed at has been removed' });
const displayName = String((req.body || {}).name || '').trim().slice(0, 60);
const username = displayName ? `invite:${displayName}` : `invite:${invite.label || invite.id.slice(0, 8)}`;
const role = invite.role === 'operator' ? 'operator' : 'viewer';
const sessionId = uuid();
const ticket = tickets.issue({
sessionId,
clientId: client.id,
username,
role,
source: 'invite',
inviteId: invite.id,
});
invites.consume(invite.id);
audit(username, 'invite.redeem', invite.id, { clientId: client.id, role, ip: req.ip });
res.json({
ticket: ticket.token,
expiresIn: ticket.expiresIn,
clientName: client.name,
role,
sessionId,
});
});
module.exports = { router };
+78
View File
@@ -0,0 +1,78 @@
'use strict';
const express = require('express');
const { clients, sessions, audit } = require('../db');
const { uuid } = require('../crypto');
const { requireAuth, requireAdmin, roleForClient } = require('../auth');
const tickets = require('../tickets');
const bridge = require('../vnc/bridge');
const hub = require('../vnc/hub');
const router = express.Router();
router.use(requireAuth);
/** Mint a one-time ticket for the VNC WebSocket. This is the connect handshake. */
router.post('/', (req, res) => {
const client = clients.get(String((req.body || {}).clientId || ''));
if (!client) return res.status(404).json({ error: 'no such client' });
const role = roleForClient(req.user, client.id, req.isAdmin);
if (!role) return res.status(403).json({ error: 'you do not have access to this client' });
// A viewer can deliberately drop to view-only, but never upgrade past its grant.
const requested = (req.body || {}).viewOnly ? 'viewer' : role;
if (client.mode === 'agent' && !hub.isOnline(client.id)) {
return res.status(409).json({ error: 'that machine is offline' });
}
const sessionId = uuid();
const ticket = tickets.issue({
sessionId,
clientId: client.id,
username: req.username,
role: requested,
source: 'web',
});
res.json({
ticket: ticket.token,
expiresIn: ticket.expiresIn,
sessionId,
role: requested,
clientName: client.name,
requireConsent: !!client.require_consent,
});
});
router.get('/live', (req, res) => {
const all = bridge.listLive();
res.json({ sessions: req.isAdmin ? all : all.filter((s) => s.username === req.username) });
});
router.get('/history', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 100, 500);
const clientId = req.query.clientId ? String(req.query.clientId) : null;
if (!req.isAdmin) {
if (!clientId) return res.status(403).json({ error: 'admin only' });
if (!roleForClient(req.user, clientId, false)) {
return res.status(403).json({ error: 'you do not have access to this client' });
}
}
res.json({ sessions: sessions.recent(limit, clientId) });
});
router.post('/:id/kill', requireAdmin, (req, res) => {
const ok = bridge.killSession(req.params.id, `disconnected by ${req.username}`);
if (!ok) return res.status(404).json({ error: 'no such live session' });
audit(req.username, 'session.kill', req.params.id, null);
res.json({ ok: true });
});
router.get('/audit', requireAdmin, (req, res) => {
res.json({ audit: audit.recent(Math.min(Number(req.query.limit) || 200, 1000)) });
});
module.exports = { router };
+36
View File
@@ -0,0 +1,36 @@
'use strict';
// One-time, short-lived tickets for WebSocket connects.
//
// A browser cannot set an Authorization header on a WebSocket, and putting a
// 1-hour session JWT in a query string leaks it into proxy and access logs. So
// the REST layer mints a ticket that is single-use and expires in seconds, and
// the WebSocket carries only that.
const { randomToken } = require('./crypto');
const config = require('./config');
const tickets = new Map(); // token -> { payload, expiresAt }
function issue(payload, ttlMs = config.ticketTtlMs) {
const token = randomToken(24);
tickets.set(token, { payload, expiresAt: Date.now() + ttlMs });
return { token, expiresIn: Math.floor(ttlMs / 1000) };
}
function redeem(token) {
if (!token) return null;
const entry = tickets.get(token);
if (!entry) return null;
tickets.delete(token); // single use, redeemed or not
if (entry.expiresAt < Date.now()) return null;
return entry.payload;
}
const sweep = setInterval(() => {
const now = Date.now();
for (const [token, entry] of tickets) if (entry.expiresAt < now) tickets.delete(token);
}, 60_000);
sweep.unref?.();
module.exports = { issue, redeem };
+213
View File
@@ -0,0 +1,213 @@
'use strict';
// Splices a browser WebSocket to a client machine's VNC server, keeping a
// registry of what is live so operators can see and kill active sessions.
const net = require('net');
const { Transform, pipeline } = require('stream');
const { createWebSocketStream } = require('ws');
const { decrypt } = require('../crypto');
const { sessions, clients, audit } = require('../db');
const { handshakeWithServer, handshakeWithBrowser, ViewOnlyFilter, ByteReader } = require('./rfb');
const { canControl } = require('../auth');
const hub = require('./hub');
/** sessionId -> live session handle */
const live = new Map();
class Counter extends Transform {
constructor() {
super();
this.bytes = 0;
}
_transform(chunk, _enc, cb) {
this.bytes += chunk.length;
cb(null, chunk);
}
}
/** Drops input-bearing RFB messages so a viewer physically cannot control. */
class ViewOnlyTransform extends Transform {
constructor() {
super();
this.filter = new ViewOnlyFilter();
}
_transform(chunk, _enc, cb) {
let out;
try {
out = this.filter.push(chunk);
} catch (err) {
return cb(err);
}
cb(null, out || undefined);
}
get blocked() {
return this.filter.blocked;
}
}
function connectDirect(client, timeoutMs = 10_000) {
return new Promise((resolve, reject) => {
if (!client.host) return reject(new Error('this client has no host configured'));
const socket = net.connect({ host: client.host, port: client.port || 5900 });
socket.setNoDelay(true);
const timer = setTimeout(() => {
socket.destroy();
reject(new Error(`timed out connecting to ${client.host}:${client.port || 5900}`));
}, timeoutMs);
socket.once('connect', () => {
clearTimeout(timer);
socket.removeListener('error', onError);
resolve(socket);
});
const onError = (err) => {
clearTimeout(timer);
reject(new Error(`cannot reach ${client.host}:${client.port || 5900} (${err.code || err.message})`));
};
socket.once('error', onError);
});
}
async function connectUpstream(client, meta) {
if (client.mode === 'agent') {
return hub.openTunnel(client.id, {
requireConsent: !!client.require_consent,
operator: meta.username,
role: meta.role,
sessionId: meta.sessionId,
});
}
return connectDirect(client);
}
/**
* Take over a browser WebSocket and run a VNC session on it.
* `ctx` = { sessionId, client, username, role, source, inviteId, remoteIp }
*/
async function startSession(browserWs, ctx) {
const { sessionId, client, username, role } = ctx;
const control = canControl(role);
let upstream;
try {
upstream = await connectUpstream(client, { username, role, sessionId });
await handshakeWithServer(upstream, decrypt(client.vnc_password_enc));
} catch (err) {
if (upstream) upstream.destroy();
audit(username, 'session.failed', client.id, { error: err.message });
// The browser has not started RFB yet, so a close reason is still readable
// by the viewer page. WebSocket close reasons are capped at 123 bytes.
try { browserWs.close(4500, String(err.message).slice(0, 120)); } catch { /* gone */ }
return;
}
sessions.start({
id: sessionId,
client_id: client.id,
client_name: client.name,
username,
source: ctx.source || 'web',
invite_id: ctx.inviteId || null,
role,
remote_ip: ctx.remoteIp || null,
});
const browserStream = createWebSocketStream(browserWs, { allowHalfOpen: false });
try {
await handshakeWithBrowser(browserStream);
// ClientInit is a single shared-desktop flag and belongs to the handshake,
// not to the message stream. Relay it by hand: the view-only filter would
// otherwise try to read it as a message type and lose the framing.
const clientInit = await new ByteReader(browserStream, 20_000).read(1);
upstream.write(clientInit);
} catch (err) {
upstream.destroy();
browserStream.destroy();
sessions.end(sessionId, { reason: `handshake: ${err.message}` });
return;
}
const toClient = new Counter(); // browser -> VNC server
const toBrowser = new Counter(); // VNC server -> browser
const guard = control ? null : new ViewOnlyTransform();
let ended = false;
const finish = (reason) => {
if (ended) return;
ended = true;
live.delete(sessionId);
sessions.end(sessionId, {
bytesIn: toClient.bytes,
bytesOut: toBrowser.bytes,
reason,
});
hub.notifySessionEnded(client.id, sessionId);
upstream.destroy();
browserStream.destroy();
};
const outbound = guard ? [browserStream, guard, toClient, upstream] : [browserStream, toClient, upstream];
pipeline(...outbound, (err) => finish(err ? `client stream: ${err.message}` : 'closed by viewer'));
pipeline(upstream, toBrowser, browserStream, (err) => finish(err ? `server stream: ${err.message}` : 'closed by host'));
live.set(sessionId, {
id: sessionId,
clientId: client.id,
clientName: client.name,
username,
role,
source: ctx.source || 'web',
remoteIp: ctx.remoteIp || null,
startedAt: Date.now(),
get bytesIn() { return toClient.bytes; },
get bytesOut() { return toBrowser.bytes; },
get blockedInputs() { return guard ? guard.blocked : 0; },
kill(reason) {
try { browserWs.close(4008, String(reason).slice(0, 120)); } catch { /* gone */ }
finish(reason);
},
});
clients.touch(client.id, ctx.remoteIp);
audit(username, 'session.start', client.id, { sessionId, role, source: ctx.source || 'web' });
}
function listLive() {
return Array.from(live.values()).map((s) => ({
id: s.id,
clientId: s.clientId,
clientName: s.clientName,
username: s.username,
role: s.role,
source: s.source,
remoteIp: s.remoteIp,
startedAt: s.startedAt,
bytesIn: s.bytesIn,
bytesOut: s.bytesOut,
blockedInputs: s.blockedInputs,
}));
}
function killSession(sessionId, reason = 'disconnected by an administrator') {
const s = live.get(sessionId);
if (!s) return false;
s.kill(reason);
return true;
}
function killSessionsForClient(clientId, reason) {
let n = 0;
for (const s of Array.from(live.values())) {
if (s.clientId === clientId) {
s.kill(reason);
n++;
}
}
return n;
}
module.exports = { startSession, listLive, killSession, killSessionsForClient };
+199
View File
@@ -0,0 +1,199 @@
'use strict';
// Minimal single-block DES-ECB encryption.
//
// Why this exists: RFB "VNC Authentication" (security type 2) is DES-based, and
// Node's OpenSSL 3 build no longer exposes des-ecb outside the legacy provider
// (`createCipheriv('des-ecb', ...)` throws "digital envelope routines::unsupported").
// So the hub carries its own DES purely to answer the auth challenge. It is not
// used for anything that needs to be secure — VNC auth is weak by design; the
// transport is protected by TLS in front of the hub instead.
const IP = [
58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4,
62, 54, 46, 38, 30, 22, 14, 6, 64, 56, 48, 40, 32, 24, 16, 8,
57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3,
61, 53, 45, 37, 29, 21, 13, 5, 63, 55, 47, 39, 31, 23, 15, 7,
];
const FP = [
40, 8, 48, 16, 56, 24, 64, 32, 39, 7, 47, 15, 55, 23, 63, 31,
38, 6, 46, 14, 54, 22, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29,
36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27,
34, 2, 42, 10, 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25,
];
const E = [
32, 1, 2, 3, 4, 5, 4, 5, 6, 7, 8, 9, 8, 9, 10, 11, 12, 13,
12, 13, 14, 15, 16, 17, 16, 17, 18, 19, 20, 21, 20, 21, 22, 23, 24, 25,
24, 25, 26, 27, 28, 29, 28, 29, 30, 31, 32, 1,
];
const P = [
16, 7, 20, 21, 29, 12, 28, 17, 1, 15, 23, 26, 5, 18, 31, 10,
2, 8, 24, 14, 32, 27, 3, 9, 19, 13, 30, 6, 22, 11, 4, 25,
];
const PC1 = [
57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18,
10, 2, 59, 51, 43, 35, 27, 19, 11, 3, 60, 52, 44, 36,
63, 55, 47, 39, 31, 23, 15, 7, 62, 54, 46, 38, 30, 22,
14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 28, 20, 12, 4,
];
const PC2 = [
14, 17, 11, 24, 1, 5, 3, 28, 15, 6, 21, 10,
23, 19, 12, 4, 26, 8, 16, 7, 27, 20, 13, 2,
41, 52, 31, 37, 47, 55, 30, 40, 51, 45, 33, 48,
44, 49, 39, 56, 34, 53, 46, 42, 50, 36, 29, 32,
];
const SHIFTS = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1];
const S = [
[14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7,
0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8,
4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0,
15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13],
[15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10,
3, 13, 4, 7, 15, 2, 8, 14, 12, 0, 1, 10, 6, 9, 11, 5,
0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15,
13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9],
[10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8,
13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1,
13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7,
1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12],
[7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15,
13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9,
10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4,
3, 15, 0, 6, 10, 1, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14],
[2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9,
14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6,
4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14,
11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3],
[12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11,
10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8,
9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6,
4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13],
[4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1,
13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6,
1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2,
6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12],
[13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7,
1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2,
7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8,
2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11],
];
function bytesToBits(buf) {
const bits = new Uint8Array(buf.length * 8);
for (let i = 0; i < buf.length; i++) {
for (let b = 0; b < 8; b++) bits[i * 8 + b] = (buf[i] >> (7 - b)) & 1;
}
return bits;
}
function bitsToBytes(bits) {
const out = Buffer.alloc(bits.length / 8);
for (let i = 0; i < out.length; i++) {
let v = 0;
for (let b = 0; b < 8; b++) v = (v << 1) | bits[i * 8 + b];
out[i] = v;
}
return out;
}
function permute(bits, table) {
const out = new Uint8Array(table.length);
for (let i = 0; i < table.length; i++) out[i] = bits[table[i] - 1];
return out;
}
function rotateLeft(bits, n) {
const out = new Uint8Array(bits.length);
for (let i = 0; i < bits.length; i++) out[i] = bits[(i + n) % bits.length];
return out;
}
function subkeys(keyBits) {
const pc1 = permute(keyBits, PC1);
let c = pc1.slice(0, 28);
let d = pc1.slice(28, 56);
const keys = [];
for (let round = 0; round < 16; round++) {
c = rotateLeft(c, SHIFTS[round]);
d = rotateLeft(d, SHIFTS[round]);
const cd = new Uint8Array(56);
cd.set(c, 0);
cd.set(d, 28);
keys.push(permute(cd, PC2));
}
return keys;
}
function feistel(rBits, subkey) {
const expanded = permute(rBits, E);
const x = new Uint8Array(48);
for (let i = 0; i < 48; i++) x[i] = expanded[i] ^ subkey[i];
const sOut = new Uint8Array(32);
for (let box = 0; box < 8; box++) {
const o = box * 6;
const row = (x[o] << 1) | x[o + 5];
const col = (x[o + 1] << 3) | (x[o + 2] << 2) | (x[o + 3] << 1) | x[o + 4];
const val = S[box][row * 16 + col];
for (let b = 0; b < 4; b++) sOut[box * 4 + b] = (val >> (3 - b)) & 1;
}
return permute(sOut, P);
}
/** Encrypt one 8-byte block with an 8-byte key. */
function encryptBlock(block, key) {
const keys = subkeys(bytesToBits(key));
const ip = permute(bytesToBits(block), IP);
let l = ip.slice(0, 32);
let r = ip.slice(32, 64);
for (let round = 0; round < 16; round++) {
const f = feistel(r, keys[round]);
const next = new Uint8Array(32);
for (let i = 0; i < 32; i++) next[i] = l[i] ^ f[i];
l = r;
r = next;
}
const preOutput = new Uint8Array(64);
preOutput.set(r, 0);
preOutput.set(l, 32);
return bitsToBytes(permute(preOutput, FP));
}
/** ECB over a buffer whose length is a multiple of 8. No padding. */
function encryptEcb(data, key) {
if (data.length % 8 !== 0) throw new Error('DES-ECB input must be a multiple of 8 bytes');
const out = Buffer.alloc(data.length);
for (let off = 0; off < data.length; off += 8) {
encryptBlock(data.subarray(off, off + 8), key).copy(out, off);
}
return out;
}
function reverseBits(byte) {
let r = 0;
for (let i = 0; i < 8; i++) r |= ((byte >> i) & 1) << (7 - i);
return r;
}
/**
* Answer an RFB VNC Authentication challenge.
* The DES key is the password truncated/zero-padded to 8 bytes, with the bits of
* each byte reversed — a quirk of the original AT&T implementation.
*/
function vncAuthResponse(challenge, password) {
const key = Buffer.alloc(8, 0);
const pw = Buffer.from(String(password || ''), 'latin1');
for (let i = 0; i < 8 && i < pw.length; i++) key[i] = reverseBits(pw[i]);
return encryptEcb(challenge, key);
}
module.exports = { encryptBlock, encryptEcb, vncAuthResponse };
+215
View File
@@ -0,0 +1,215 @@
'use strict';
// Agent registry and tunnel broker.
//
// Agent-mode clients sit behind NAT, so they dial *out* to the hub and hold a
// control WebSocket open. When someone wants to view that machine, the hub asks
// the agent over that control channel to open a second, data-only WebSocket; the
// agent pipes it to the local VNC server. The hub pairs that data socket with
// the waiting browser session.
const { createWebSocketStream } = require('ws');
const config = require('../config');
const { clients, audit } = require('../db');
const { randomToken } = require('../crypto');
const HEARTBEAT_MS = 30_000;
class Hub {
constructor() {
/** clientId -> { ws, info, connectedAt, lastSeen, alive } */
this.agents = new Map();
/** tunnelId -> { clientId, resolve, reject, timer } */
this.pending = new Map();
this.heartbeat = setInterval(() => this._sweep(), HEARTBEAT_MS);
this.heartbeat.unref?.();
}
/* ------------------------------------------------------------- control */
handleAgentSocket(ws, client, remoteIp) {
// A machine may only have one live control channel; a reconnect wins.
const existing = this.agents.get(client.id);
if (existing && existing.ws !== ws) {
try { existing.ws.close(4001, 'replaced by a newer connection'); } catch { /* already gone */ }
}
const entry = { ws, info: {}, connectedAt: Date.now(), lastSeen: Date.now(), alive: true, lastIp: remoteIp };
this.agents.set(client.id, entry);
clients.touch(client.id, remoteIp);
ws.on('pong', () => {
entry.alive = true;
entry.lastSeen = Date.now();
clients.touch(client.id, remoteIp);
});
ws.on('message', (raw) => {
let msg;
try {
msg = JSON.parse(raw.toString());
} catch {
return;
}
entry.lastSeen = Date.now();
this._onAgentMessage(client, entry, msg, remoteIp);
});
ws.on('close', () => {
if (this.agents.get(client.id) === entry) this.agents.delete(client.id);
// Fail anything that was waiting on this agent.
for (const [tunnelId, p] of this.pending) {
if (p.clientId === client.id) this._rejectPending(tunnelId, new Error('agent disconnected'));
}
});
ws.on('error', () => { /* close handler does the cleanup */ });
this._send(ws, {
type: 'welcome',
clientId: client.id,
name: client.name,
requireConsent: !!client.require_consent,
heartbeatMs: HEARTBEAT_MS,
});
}
_onAgentMessage(client, entry, msg, remoteIp) {
switch (msg.type) {
case 'hello': {
entry.info = {
version: msg.version,
os: msg.os,
hostname: msg.hostname,
vncPort: msg.vncPort,
};
clients.update(client.id, {
os: msg.os ?? null,
hostname: msg.hostname ?? null,
agent_version: msg.version ?? null,
last_seen_at: Date.now(),
last_ip: remoteIp ?? null,
});
break;
}
case 'denied': {
this._rejectPending(msg.tunnelId, new Error(msg.reason || 'the person at that machine declined'));
break;
}
case 'error': {
this._rejectPending(msg.tunnelId, new Error(msg.message || 'agent reported an error'));
break;
}
default:
break;
}
}
_send(ws, obj) {
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
}
_sweep() {
const now = Date.now();
for (const [clientId, entry] of this.agents) {
if (!entry.alive) {
try { entry.ws.terminate(); } catch { /* already gone */ }
this.agents.delete(clientId);
continue;
}
entry.alive = false;
try { entry.ws.ping(); } catch { /* handled on next sweep */ }
if (now - entry.lastSeen < config.agentOfflineAfterMs) clients.touch(clientId, entry.lastIp);
}
}
/* -------------------------------------------------------------- status */
isOnline(clientId) {
return this.agents.has(clientId);
}
onlineIds() {
return Array.from(this.agents.keys());
}
agentInfo(clientId) {
const e = this.agents.get(clientId);
return e ? { ...e.info, connectedAt: e.connectedAt, lastSeen: e.lastSeen } : null;
}
/* ------------------------------------------------------------- tunnels */
/**
* Ask an agent to open a data tunnel. Resolves with a Duplex carrying the raw
* RFB byte stream from the client machine's local VNC server.
*/
openTunnel(clientId, meta = {}) {
const entry = this.agents.get(clientId);
if (!entry) return Promise.reject(new Error('that machine is offline'));
const tunnelId = randomToken(16);
const requireConsent = !!meta.requireConsent;
const timeoutMs = requireConsent ? config.consentTimeoutMs : 15_000;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(tunnelId);
reject(new Error(requireConsent
? 'no response to the connection request on that machine'
: 'the agent did not open a tunnel in time'));
}, timeoutMs);
timer.unref?.();
this.pending.set(tunnelId, { clientId, resolve, reject, timer });
this._send(entry.ws, {
type: 'open',
tunnelId,
requireConsent,
operator: meta.operator || 'someone',
role: meta.role || 'viewer',
sessionId: meta.sessionId,
});
});
}
/** Called when the agent's data socket arrives and claims a pending tunnel. */
handleTunnelSocket(ws, clientId, tunnelId) {
const pending = this.pending.get(tunnelId);
if (!pending || pending.clientId !== clientId) {
try { ws.close(4004, 'unknown tunnel'); } catch { /* nothing to do */ }
return false;
}
clearTimeout(pending.timer);
this.pending.delete(tunnelId);
pending.resolve(createWebSocketStream(ws, { allowHalfOpen: false }));
return true;
}
_rejectPending(tunnelId, err) {
const pending = this.pending.get(tunnelId);
if (!pending) return;
clearTimeout(pending.timer);
this.pending.delete(tunnelId);
pending.reject(err);
}
/** Tell an agent to drop whatever it is doing (used by force-disconnect). */
notifySessionEnded(clientId, sessionId) {
const entry = this.agents.get(clientId);
if (entry) this._send(entry.ws, { type: 'session-ended', sessionId });
}
disconnectAgent(clientId, reason = 'removed') {
const entry = this.agents.get(clientId);
if (!entry) return false;
try { entry.ws.close(4003, reason); } catch { /* already gone */ }
this.agents.delete(clientId);
audit(null, 'agent.disconnect', clientId, { reason });
return true;
}
}
module.exports = new Hub();
+274
View File
@@ -0,0 +1,274 @@
'use strict';
// RFB protocol handling for the hub.
//
// The hub is a man-in-the-middle by design: it completes the RFB handshake with
// the real VNC server itself (including VNC Authentication using a password the
// browser never receives), and separately presents a "no authentication needed"
// handshake to the browser. Once both sides are past ServerInit the two streams
// are spliced together.
//
// That MITM position is also what makes view-only enforceable: the client->server
// direction is parsed and input-bearing messages are dropped, so a viewer cannot
// send keystrokes no matter what its browser does.
const { vncAuthResponse } = require('./des');
const SEC_NONE = 1;
const SEC_VNC_AUTH = 2;
/**
* Reads exact byte counts off a Readable without putting it into flowing mode,
* so anything we do not consume stays in the stream's internal buffer and is
* picked up by the later pipe().
*/
class ByteReader {
constructor(stream, timeoutMs = 20_000) {
this.stream = stream;
this.timeoutMs = timeoutMs;
}
read(n) {
return new Promise((resolve, reject) => {
const attempt = () => {
const buf = this.stream.read(n);
if (buf) {
cleanup();
resolve(buf);
}
};
const onEnd = () => {
cleanup();
reject(new Error('connection closed during RFB handshake'));
};
const onError = (err) => {
cleanup();
reject(err);
};
const timer = setTimeout(() => {
cleanup();
reject(new Error('timed out during RFB handshake'));
}, this.timeoutMs);
const cleanup = () => {
clearTimeout(timer);
this.stream.off('readable', attempt);
this.stream.off('end', onEnd);
this.stream.off('close', onEnd);
this.stream.off('error', onError);
};
this.stream.on('readable', attempt);
this.stream.on('end', onEnd);
this.stream.on('close', onEnd);
this.stream.on('error', onError);
attempt();
});
}
async readU8() {
return (await this.read(1))[0];
}
async readU32() {
return (await this.read(4)).readUInt32BE(0);
}
// RFB failure reasons are a u32 length followed by that many bytes of text.
async readReason() {
const len = await this.readU32();
if (!len) return '';
return (await this.read(Math.min(len, 4096))).toString('utf8');
}
}
function parseVersion(buf) {
const text = buf.toString('ascii');
const m = /^RFB (\d{3})\.(\d{3})\n$/.exec(text);
if (!m) throw new Error(`not a VNC server (got ${JSON.stringify(text)})`);
return { major: Number(m[1]), minor: Number(m[2]) };
}
/**
* Act as a VNC *client* toward the real server: version negotiation, security
* negotiation, VNC Authentication if required. Returns once the server is ready
* for ClientInit, which the browser will supply.
*/
async function handshakeWithServer(stream, password, timeoutMs = 20_000) {
const r = new ByteReader(stream, timeoutMs);
const { major, minor: rawMinor } = parseVersion(await r.read(12));
// Apple advertises 003.889; anything above 3.8 is negotiated down to 3.8.
const minor = rawMinor > 8 ? 8 : rawMinor;
const negotiated = minor >= 8 ? 8 : minor >= 7 ? 7 : 3;
stream.write(Buffer.from(`RFB 003.00${negotiated}\n`, 'ascii'));
let secType;
if (negotiated >= 7) {
const count = await r.readU8();
if (count === 0) throw new Error(`server refused connection: ${await r.readReason()}`);
const types = Array.from(await r.read(count));
if (password && types.includes(SEC_VNC_AUTH)) secType = SEC_VNC_AUTH;
else if (types.includes(SEC_NONE)) secType = SEC_NONE;
else if (types.includes(SEC_VNC_AUTH)) {
throw new Error('VNC server requires a password but none is stored for this client');
} else {
throw new Error(`no supported VNC security type (server offered ${types.join(', ')})`);
}
stream.write(Buffer.from([secType]));
} else {
secType = await r.readU32();
if (secType === 0) throw new Error(`server refused connection: ${await r.readReason()}`);
if (secType === SEC_VNC_AUTH && !password) {
throw new Error('VNC server requires a password but none is stored for this client');
}
}
if (secType === SEC_VNC_AUTH) {
const challenge = await r.read(16);
stream.write(vncAuthResponse(challenge, password));
} else if (secType !== SEC_NONE) {
throw new Error(`unsupported VNC security type ${secType}`);
}
// 3.8 always sends SecurityResult; earlier versions only send it for real auth.
if (negotiated >= 8 || secType !== SEC_NONE) {
const result = await r.readU32();
if (result !== 0) {
const reason = negotiated >= 8 ? await r.readReason().catch(() => '') : '';
throw new Error(reason || 'VNC authentication failed (wrong password?)');
}
}
return { version: `${major}.${rawMinor}`, securityType: secType };
}
/**
* Act as a VNC *server* toward the browser, offering "None" security. By the
* time this runs the hub has already authenticated upstream, so the browser is
* handed an already-authorised stream and never sees the real password.
*/
async function handshakeWithBrowser(stream, timeoutMs = 20_000) {
const r = new ByteReader(stream, timeoutMs);
stream.write(Buffer.from('RFB 003.008\n', 'ascii'));
const { minor } = parseVersion(await r.read(12));
if (minor >= 7) {
stream.write(Buffer.from([1, SEC_NONE]));
const chosen = await r.readU8();
if (chosen !== SEC_NONE) {
const reason = Buffer.from('unsupported security type', 'utf8');
const buf = Buffer.alloc(8 + reason.length);
buf.writeUInt32BE(1, 0);
buf.writeUInt32BE(reason.length, 4);
reason.copy(buf, 8);
stream.write(buf);
throw new Error('browser chose an unsupported security type');
}
// SecurityResult: OK
const ok = Buffer.alloc(4);
ok.writeUInt32BE(0, 0);
stream.write(ok);
} else {
// RFB 3.3: the server dictates the security type and sends no SecurityResult.
const buf = Buffer.alloc(4);
buf.writeUInt32BE(SEC_NONE, 0);
stream.write(buf);
}
}
/* ------------------------------------------------------------------------ */
/* View-only enforcement */
/* ------------------------------------------------------------------------ */
// Client-to-server messages that cannot change anything on the remote machine.
// Everything else is dropped for viewers — notably KeyEvent, PointerEvent,
// ClientCutText (paste), SetDesktopSize and xvp (which can power off a host).
const PASSIVE_MESSAGES = new Set([
0, // SetPixelFormat
2, // SetEncodings
3, // FramebufferUpdateRequest
150, // EnableContinuousUpdates
248, // ClientFence
]);
/**
* Length of the client->server message starting at offset 0 of `buf`.
* Returns 0 when more bytes are needed, -1 when the type is unknown (which means
* we can no longer track message boundaries and must drop the connection).
*/
function clientMessageLength(buf) {
const type = buf[0];
switch (type) {
case 0: return 20; // SetPixelFormat
case 2: // SetEncodings
if (buf.length < 4) return 0;
return 4 + 4 * buf.readUInt16BE(2);
case 3: return 10; // FramebufferUpdateRequest
case 4: return 8; // KeyEvent
case 5: return 6; // PointerEvent
case 6: // ClientCutText
if (buf.length < 8) return 0;
// A negative length marks the extended clipboard extension.
return 8 + Math.abs(buf.readInt32BE(4));
case 150: return 10; // EnableContinuousUpdates
case 248: // ClientFence
if (buf.length < 9) return 0;
return 9 + buf[8];
case 250: return 4; // xvp (shutdown/reboot/reset)
case 251: // SetDesktopSize
if (buf.length < 8) return 0;
return 8 + 16 * buf[6];
case 255: // QEMU client message
if (buf.length < 2) return 0;
if (buf[1] === 0) return 12; // QEMU Extended Key Event
return -1;
default:
return -1;
}
}
/**
* Incremental filter for the browser->server direction of a view-only session.
* Feed it chunks; it returns only the bytes that are safe to forward.
*/
class ViewOnlyFilter {
constructor() {
this.pending = Buffer.alloc(0);
this.blocked = 0;
}
push(chunk) {
this.pending = this.pending.length ? Buffer.concat([this.pending, chunk]) : chunk;
const keep = [];
while (this.pending.length > 0) {
const len = clientMessageLength(this.pending);
if (len === -1) {
throw new Error(`unparseable client message type ${this.pending[0]} in view-only session`);
}
if (len === 0 || this.pending.length < len) break;
const msg = this.pending.subarray(0, len);
if (PASSIVE_MESSAGES.has(msg[0])) keep.push(Buffer.from(msg));
else this.blocked++;
this.pending = this.pending.subarray(len);
}
if (!keep.length) return null;
return keep.length === 1 ? keep[0] : Buffer.concat(keep);
}
}
module.exports = {
ByteReader,
handshakeWithServer,
handshakeWithBrowser,
ViewOnlyFilter,
clientMessageLength,
SEC_NONE,
SEC_VNC_AUTH,
};
+601
View File
@@ -0,0 +1,601 @@
'use strict';
// End-to-end test: boots the real hub against a fake garagedoor auth service and
// a fake VNC server, then drives it as a browser would.
//
// Covers the things that are easy to get subtly wrong: server-side VNC
// authentication (the browser must never be asked for a password), view-only
// enforcement at the proxy, and the agent tunnel path.
//
// node test/e2e.js
const http = require('http');
const net = require('net');
const os = require('os');
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const { WebSocket, createWebSocketStream } = require('ws');
const { vncAuthResponse } = require('../server/vnc/des');
const { ByteReader } = require('../server/vnc/rfb');
const VNC_PASSWORD = 'hunter2';
let failures = 0;
let passes = 0;
function check(ok, label, detail) {
if (ok) {
passes++;
console.log(` ok ${label}`);
} else {
failures++;
console.log(` FAIL ${label}${detail ? `${detail}` : ''}`);
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function listen(server, port = 0) {
return new Promise((resolve) => server.listen(port, '127.0.0.1', () => resolve(server.address().port)));
}
/* -------------------------------------------------------- fake services */
function startFakeAuth() {
const server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
if (req.url === '/authenticate' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
const { username, password } = JSON.parse(body || '{}');
// garagedoor answers 200 even on failure; the app must read body.result.
if (password === 'correct-horse') {
res.end(JSON.stringify({ statusCode: 200, result: 'success', username, level: 10, token: `tok-${username}` }));
} else {
res.end(JSON.stringify({ statusCode: 401, result: 'failed', message: 'Authentication failed' }));
}
});
return;
}
if (req.url === '/validate') {
const auth = req.headers.authorization || '';
const token = auth.replace('Bearer ', '');
if (token.startsWith('tok-')) {
res.end(JSON.stringify({ statusCode: 200, result: 'success', username: token.slice(4) }));
} else {
res.end(JSON.stringify({ statusCode: 200, result: 'failed' }));
}
return;
}
res.statusCode = 404;
res.end('{}');
});
return server;
}
/**
* A VNC server that demands VNC Authentication, then records every
* client-to-server message it receives after ServerInit.
*/
function startFakeVnc(state) {
const server = net.createServer(async (socket) => {
const r = new ByteReader(socket, 5000);
try {
socket.write(Buffer.from('RFB 003.008\n', 'ascii'));
await r.read(12);
socket.write(Buffer.from([1, 2])); // offer only VNC Authentication
const chosen = await r.readU8();
state.chosenSecurity = chosen;
const challenge = Buffer.alloc(16, 0x5a);
socket.write(challenge);
const response = await r.read(16);
const expected = vncAuthResponse(challenge, VNC_PASSWORD);
state.authOk = response.equals(expected);
const ok = Buffer.alloc(4);
ok.writeUInt32BE(state.authOk ? 0 : 1, 0);
socket.write(ok);
if (!state.authOk) return socket.end();
const shared = await r.read(1);
state.sharedFlag = shared[0];
const name = Buffer.from('fake screen', 'utf8');
const init = Buffer.alloc(24 + name.length);
init.writeUInt16BE(1024, 0);
init.writeUInt16BE(768, 2);
init[4] = 32; init[5] = 24; init[6] = 0; init[7] = 1; // bpp, depth, big-endian, true-colour
init.writeUInt16BE(255, 8); init.writeUInt16BE(255, 10); init.writeUInt16BE(255, 12);
init[14] = 16; init[15] = 8; init[16] = 0; // shifts
init.writeUInt32BE(name.length, 20);
name.copy(init, 24);
socket.write(init);
state.connected = true;
socket.on('data', (chunk) => {
for (const byte of chunk) state.received.push(byte);
state.messageTypes.push(chunk[0]);
});
} catch (err) {
state.error = err.message;
}
});
return server;
}
/* ------------------------------------------------------------ hub client */
class Hub {
constructor(base) {
this.base = base;
this.token = null;
}
async request(method, path, body, { auth = true } = {}) {
const res = await fetch(`${this.base}${path}`, {
method,
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...(auth && this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
return { status: res.status, data };
}
}
/** Speak the browser half of RFB over the hub's WebSocket. */
async function browserSession(base, ticket) {
const ws = new WebSocket(`${base.replace('http', 'ws')}/ws/vnc?ticket=${encodeURIComponent(ticket)}`);
ws.binaryType = 'nodebuffer';
const closed = new Promise((resolve) => {
ws.on('close', (code, reason) => resolve({ code, reason: reason.toString() }));
});
const opened = await new Promise((resolve) => {
ws.on('open', () => resolve(true));
ws.on('close', () => resolve(false));
ws.on('error', () => resolve(false));
});
if (!opened) return { ok: false, closed };
const stream = createWebSocketStream(ws, { allowHalfOpen: false });
stream.on('error', () => { /* the close reason is the useful signal */ });
const r = new ByteReader(stream, 5000);
try {
return await handshakeAsBrowser(r, stream, ws, closed);
} catch (err) {
// A hub-side failure arrives as a close code + reason; surface it.
const info = await Promise.race([closed, sleep(300).then(() => null)]);
return { ok: false, closed, error: info ? `${info.code}: ${info.reason}` : err.message };
}
}
async function handshakeAsBrowser(r, stream, ws, closed) {
const version = await r.read(12);
stream.write(Buffer.from('RFB 003.008\n', 'ascii'));
const count = await r.readU8();
const types = Array.from(await r.read(count));
stream.write(Buffer.from([1])); // None
const securityResult = await r.readU32();
stream.write(Buffer.from([1])); // ClientInit, shared
const head = await r.read(24);
const nameLen = head.readUInt32BE(20);
const name = nameLen ? (await r.read(nameLen)).toString() : '';
return {
ok: true,
ws,
stream,
closed,
version: version.toString().trim(),
securityTypes: types,
securityResult,
width: head.readUInt16BE(0),
height: head.readUInt16BE(2),
name,
};
}
function keyEvent(keysym, down = true) {
const b = Buffer.alloc(8);
b[0] = 4;
b[1] = down ? 1 : 0;
b.writeUInt32BE(keysym, 4);
return b;
}
function framebufferUpdateRequest() {
const b = Buffer.alloc(10);
b[0] = 3;
b[1] = 1;
b.writeUInt16BE(0, 2);
b.writeUInt16BE(0, 4);
b.writeUInt16BE(1024, 6);
b.writeUInt16BE(768, 8);
return b;
}
/* ------------------------------------------------------------------ main */
async function main() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rcs-e2e-'));
const authServer = startFakeAuth();
const authPort = await listen(authServer);
const vncState = { received: [], messageTypes: [] };
const vncServer = startFakeVnc(vncState);
const vncPort = await listen(vncServer);
const hubPort = 18099;
const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {
env: {
...process.env,
PORT: String(hubPort),
HOST: '127.0.0.1',
AUTH_URL: `http://127.0.0.1:${authPort}`,
DB_PATH: path.join(tmp, 'test.db'),
ENCRYPTION_KEY: 'test-key-not-a-secret',
ADMIN_USERS: 'alice',
NODE_NO_WARNINGS: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout.on('data', (d) => process.env.VERBOSE && process.stdout.write(` [hub] ${d}`));
child.stderr.on('data', (d) => process.stderr.write(` [hub!] ${d}`));
const base = `http://127.0.0.1:${hubPort}`;
for (let i = 0; i < 60; i++) {
try {
const res = await fetch(`${base}/api/health`);
if (res.ok) break;
} catch { /* not up yet */ }
await sleep(100);
}
const hub = new Hub(base);
let clientId;
try {
console.log('\nauth');
{
const bad = await hub.request('POST', '/api/login', { username: 'alice', password: 'wrong' }, { auth: false });
check(bad.status === 401, 'bad password is a real 401', `got ${bad.status}`);
const good = await hub.request('POST', '/api/login', { username: 'alice', password: 'correct-horse' }, { auth: false });
check(good.status === 200 && !!good.data.token, 'login returns a token');
check(good.data.isAdmin === true, 'ADMIN_USERS makes alice an admin');
hub.token = good.data.token;
const anon = await hub.request('GET', '/api/clients', null, { auth: false });
check(anon.status === 401, 'unauthenticated API access is refused');
}
console.log('\nclients');
{
const created = await hub.request('POST', '/api/clients', {
name: 'test-desk',
mode: 'direct',
host: '127.0.0.1',
port: vncPort,
vncPassword: VNC_PASSWORD,
tags: 'lab, test',
});
check(created.status === 201, 'client created', JSON.stringify(created.data));
clientId = created.data.client?.id;
check(created.data.client?.hasPassword === true, 'client reports a stored password');
check(!('vncPassword' in (created.data.client || {})) && !('vnc_password_enc' in (created.data.client || {})),
'the API never echoes the VNC password back');
const dupe = await hub.request('POST', '/api/clients', { name: 'test-desk', mode: 'direct', host: '127.0.0.1' });
check(dupe.status === 409, 'duplicate names are rejected');
const list = await hub.request('GET', '/api/clients');
check(list.data.clients.length === 1, 'client shows in the list');
}
console.log('\nfull-control session');
{
const ticket = await hub.request('POST', '/api/sessions', { clientId });
check(ticket.status === 200 && !!ticket.data.ticket, 'ticket minted');
check(ticket.data.role === 'admin', 'admin gets the admin role');
const session = await browserSession(base, ticket.data.ticket);
check(session.ok, 'websocket accepted the ticket');
check(session.version === 'RFB 003.008', 'hub speaks RFB 3.8 to the browser');
check(session.securityTypes.length === 1 && session.securityTypes[0] === 1,
'browser is offered "None" security only', JSON.stringify(session.securityTypes));
check(session.securityResult === 0, 'browser gets SecurityResult OK without a password');
check(vncState.chosenSecurity === 2, 'hub chose VNC Authentication upstream');
check(vncState.authOk === true, 'hub answered the DES challenge correctly');
check(session.width === 1024 && session.name === 'fake screen', 'ServerInit passed through');
vncState.messageTypes.length = 0;
session.stream.write(keyEvent(0x41));
session.stream.write(framebufferUpdateRequest());
await sleep(200);
check(vncState.messageTypes.includes(4), 'operator key events reach the VNC server');
const replayed = await hub.request('POST', '/api/sessions', { clientId });
const reused = await browserSession(base, ticket.data.ticket);
check(!reused.ok, 'a ticket cannot be redeemed twice');
check(replayed.status === 200, 'a fresh ticket can still be minted');
session.ws.close();
await sleep(150);
}
console.log('\nview-only enforcement');
{
const ticket = await hub.request('POST', '/api/sessions', { clientId, viewOnly: true });
check(ticket.data.role === 'viewer', 'viewOnly downgrades the session role');
const session = await browserSession(base, ticket.data.ticket);
check(session.ok, 'view-only session connected', session.error);
if (!session.ok) throw new Error(`view-only session failed: ${session.error}`);
vncState.messageTypes.length = 0;
session.stream.write(keyEvent(0x41)); // must be dropped
session.stream.write(Buffer.from([5, 1, 0, 10, 0, 10])); // PointerEvent, must be dropped
session.stream.write(framebufferUpdateRequest()); // must pass
await sleep(250);
check(!vncState.messageTypes.includes(4), 'KeyEvent is blocked by the proxy');
check(!vncState.messageTypes.includes(5), 'PointerEvent is blocked by the proxy');
check(vncState.messageTypes.includes(3), 'FramebufferUpdateRequest still passes');
const live = await hub.request('GET', '/api/sessions/live');
const mine = live.data.sessions.find((s) => s.role === 'viewer');
check(!!mine, 'the live session is listed');
check(mine && mine.blockedInputs >= 2, 'blocked input count is reported', JSON.stringify(mine));
const killed = await hub.request('POST', `/api/sessions/${mine.id}/kill`);
check(killed.status === 200, 'admin can force-disconnect');
const after = await session.closed;
check(after.code === 4008, 'the viewer socket was closed by the hub', `code ${after.code}`);
}
console.log('\naccess control');
{
const bobLogin = await hub.request('POST', '/api/login', { username: 'bob', password: 'correct-horse' }, { auth: false });
const bob = new Hub(base);
bob.token = bobLogin.data.token;
check(bobLogin.data.isAdmin === false, 'bob is not an admin');
const bobList = await bob.request('GET', '/api/clients');
check(bobList.data.clients.length === 0, 'bob sees no machines without a grant');
const denied = await bob.request('POST', '/api/sessions', { clientId });
check(denied.status === 403, 'bob cannot start a session without a grant');
const create = await bob.request('POST', '/api/clients', { name: 'bobs-pc', mode: 'direct', host: '127.0.0.1' });
check(create.status === 403, 'bob cannot create machines');
await hub.request('POST', `/api/clients/${clientId}/grants`, { username: 'bob', role: 'viewer' });
const granted = await bob.request('GET', '/api/clients');
check(granted.data.clients.length === 1, 'the grant makes the machine visible to bob');
const bobTicket = await bob.request('POST', '/api/sessions', { clientId });
check(bobTicket.data.role === 'viewer', 'bob is held to viewer even asking for control');
await hub.request('DELETE', `/api/clients/${clientId}/grants/bob`);
const revoked = await bob.request('GET', '/api/clients');
check(revoked.data.clients.length === 0, 'removing the grant hides it again');
}
console.log('\ninvites and enrolment');
{
const enroll = await hub.request('POST', '/api/invites', { kind: 'enroll', name: 'kiosk', ttlMs: 60_000 });
check(enroll.status === 201 && !!enroll.data.token, 'enrolment invite created');
check(enroll.data.url.includes('/enroll/'), 'enrolment link points at the enrol page');
const info = await fetch(`${base}/api/public/invite/${enroll.data.token}`).then((r) => r.json());
check(info.usable === true && info.kind === 'enroll', 'invite info is readable without a login');
const enrolled = await hub.request('POST', '/api/public/enroll', {
token: enroll.data.token,
hostname: 'kiosk-01',
os: 'Linux 6.1',
agentVersion: '0.1.0',
vncPort: vncPort,
}, { auth: false });
check(enrolled.status === 201 && !!enrolled.data.agentKey, 'machine enrolled and got an agent key');
check(enrolled.data.name === 'kiosk', 'the invite name was applied');
const again = await hub.request('POST', '/api/public/enroll', { token: enroll.data.token, hostname: 'x' }, { auth: false });
check(again.status === 410, 'a single-use enrolment link cannot be reused');
global.agentClientId = enrolled.data.clientId;
global.agentKey = enrolled.data.agentKey;
}
console.log('\nagent tunnel');
{
const clientId = global.agentClientId;
const key = global.agentKey;
const badAgent = new WebSocket(`${base.replace('http', 'ws')}/ws/agent?clientId=${clientId}&key=wrong`);
const badResult = await new Promise((resolve) => {
badAgent.on('open', () => resolve('open'));
badAgent.on('error', () => resolve('rejected'));
});
check(badResult === 'rejected', 'a wrong agent key is refused at upgrade');
// Minimal stand-in for agent/agent.js: hold a control socket, open tunnels on demand.
const control = new WebSocket(`${base.replace('http', 'ws')}/ws/agent?clientId=${clientId}&key=${key}`);
await new Promise((resolve, reject) => {
control.on('open', resolve);
control.on('error', reject);
});
control.send(JSON.stringify({ type: 'hello', version: '0.1.0', os: 'Linux', hostname: 'kiosk-01', vncPort }));
control.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type !== 'open') return;
const tunnel = new WebSocket(
`${base.replace('http', 'ws')}/ws/tunnel?clientId=${clientId}&key=${key}&tunnelId=${msg.tunnelId}`
);
tunnel.binaryType = 'nodebuffer';
tunnel.on('open', () => {
const socket = net.connect({ host: '127.0.0.1', port: vncPort });
socket.on('data', (c) => tunnel.readyState === 1 && tunnel.send(c));
tunnel.on('message', (c) => socket.write(c));
tunnel.on('close', () => socket.destroy());
socket.on('close', () => tunnel.close());
});
});
await sleep(300);
const listed = await hub.request('GET', '/api/clients');
const agentClient = listed.data.clients.find((c) => c.id === clientId);
check(agentClient?.online === true, 'the agent shows as online');
check(agentClient?.hostname === 'kiosk-01', 'the agent reported its hostname');
// The enrolled client has no stored VNC password, so point it at a server
// that does not demand one for this leg of the test.
await hub.request('PATCH', `/api/clients/${clientId}`, { vncPassword: VNC_PASSWORD });
const ticket = await hub.request('POST', '/api/sessions', { clientId });
check(ticket.status === 200, 'ticket minted for the agent client', JSON.stringify(ticket.data));
const session = await browserSession(base, ticket.data.ticket);
check(session.ok, 'session established through the agent tunnel');
check(session.name === 'fake screen', 'framebuffer details came back through the tunnel');
session.ws?.close();
control.close();
await sleep(200);
const offline = await hub.request('GET', '/api/clients');
check(offline.data.clients.find((c) => c.id === clientId)?.online === false,
'the client goes offline when the agent disconnects');
}
console.log('\nthe real agent binary');
{
const invite = await hub.request('POST', '/api/invites', { kind: 'enroll', name: 'agent-test', ttlMs: 60_000 });
const agentConfig = path.join(tmp, 'agent.json');
const agentPath = path.join(__dirname, '..', 'agent', 'agent.js');
const enrolled = await new Promise((resolve) => {
const p = spawn(process.execPath, [
agentPath, 'enroll', invite.data.url,
'--config', agentConfig, '--vnc-port', String(vncPort),
], { env: { ...process.env, NODE_NO_WARNINGS: '1' }, stdio: ['ignore', 'pipe', 'pipe'] });
let out = '';
p.stdout.on('data', (d) => { out += d; });
p.stderr.on('data', (d) => { out += d; });
p.on('exit', (code) => resolve({ code, out }));
});
check(enrolled.code === 0, 'agent enroll succeeded', enrolled.out.trim());
check(fs.existsSync(agentConfig), 'agent wrote its config');
const saved = JSON.parse(fs.readFileSync(agentConfig, 'utf8'));
check(!!saved.agentKey && !!saved.clientId, 'config holds the client id and key');
check((fs.statSync(agentConfig).mode & 0o777) === 0o600, 'config file is owner-only');
const agentProc = spawn(process.execPath, [agentPath, 'run', '--config', agentConfig], {
env: { ...process.env, NODE_NO_WARNINGS: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let agentOut = '';
agentProc.stdout.on('data', (d) => { agentOut += d; });
agentProc.stderr.on('data', (d) => { agentOut += d; });
for (let i = 0; i < 40 && !agentOut.includes('registered as'); i++) await sleep(100);
check(agentOut.includes('registered as'), 'agent connected and registered', agentOut.trim());
await hub.request('PATCH', `/api/clients/${saved.clientId}`, { vncPassword: VNC_PASSWORD });
const ticket = await hub.request('POST', '/api/sessions', { clientId: saved.clientId });
check(ticket.status === 200, 'ticket minted for the real agent', JSON.stringify(ticket.data));
const session = await browserSession(base, ticket.data.ticket);
check(session.ok, 'session ran through the real agent', session.error);
if (session.ok) {
vncState.messageTypes.length = 0;
session.stream.write(framebufferUpdateRequest());
await sleep(250);
check(vncState.messageTypes.includes(3), 'input reached the VNC server through the agent');
session.ws.close();
}
await sleep(200);
agentProc.kill('SIGTERM');
await sleep(200);
}
console.log('\nsupport links');
{
const share = await hub.request('POST', '/api/invites', {
kind: 'session', clientId, role: 'viewer', ttlMs: 60_000, label: 'customer',
});
check(share.status === 201, 'support link created');
check(share.data.invite.maxUses === 0, 'support links default to unlimited uses until expiry');
const joined = await fetch(`${base}/api/public/session/${share.data.token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Dana' }),
}).then((r) => r.json());
check(!!joined.ticket, 'a guest with the link gets a ticket without logging in');
check(joined.role === 'viewer', 'the link fixes the role');
const guest = await browserSession(base, joined.ticket);
check(guest.ok, 'the guest connected');
vncState.messageTypes.length = 0;
guest.stream.write(keyEvent(0x41));
await sleep(200);
check(!vncState.messageTypes.includes(4), 'a guest viewer still cannot send input');
guest.ws?.close();
await hub.request('POST', `/api/invites/${share.data.invite.id}/revoke`);
const afterRevoke = await fetch(`${base}/api/public/session/${share.data.token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
check(afterRevoke.status === 410, 'a revoked link stops working');
const bogus = await fetch(`${base}/api/public/invite/definitely-not-a-real-token`);
check(bogus.status === 404, 'an unknown token is a 404');
}
console.log('\naudit');
{
const { data } = await hub.request('GET', '/api/sessions/audit');
const actions = data.audit.map((a) => a.action);
check(actions.includes('client.create'), 'client creation is audited');
check(actions.includes('session.start'), 'sessions are audited');
check(actions.includes('invite.create'), 'invites are audited');
check(actions.includes('session.kill'), 'force-disconnects are audited');
const history = await hub.request('GET', '/api/sessions/history');
check(history.data.sessions.length >= 4, 'session history persisted', `${history.data.sessions.length} rows`);
check(history.data.sessions.every((s) => s.ended_at), 'closed sessions have an end time');
}
} catch (err) {
failures++;
console.log(`\n FAIL unexpected error: ${err.stack}`);
} finally {
child.kill('SIGTERM');
authServer.close();
vncServer.close();
await sleep(200);
fs.rmSync(tmp, { recursive: true, force: true });
}
console.log(`\n${passes} passed, ${failures} failed\n`);
process.exit(failures ? 1 : 0);
}
main();