From 999717f77b99701f247df984ef57ee80a8ff7a68 Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Tue, 11 Aug 2026 23:37:35 -0700 Subject: [PATCH] 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 --- .dockerignore | 7 + .env.example | 32 ++ .gitea/workflows/deploy.yml | 94 ++++ .gitignore | 5 + Dockerfile | 25 + PLAN.md | 170 ++++++ README.md | 209 +++++++ agent/agent.js | 358 ++++++++++++ docker-compose.yml | 39 ++ package.json | 21 + pnpm-lock.yaml | 605 +++++++++++++++++++++ public/app.js | 718 ++++++++++++++++++++++++ public/enroll.html | 115 ++++ public/index.html | 165 ++++++ public/particles.js | 273 ++++++++++ public/share.html | 106 ++++ public/styles.css | 1021 +++++++++++++++++++++++++++++++++++ public/viewer.html | 40 ++ public/viewer.js | 159 ++++++ server/auth.js | 138 +++++ server/config.js | 51 ++ server/crypto.js | 81 +++ server/db.js | 309 +++++++++++ server/index.js | 195 +++++++ server/routes/clients.js | 197 +++++++ server/routes/invites.js | 136 +++++ server/routes/public.js | 172 ++++++ server/routes/sessions.js | 78 +++ server/tickets.js | 36 ++ server/vnc/bridge.js | 213 ++++++++ server/vnc/des.js | 199 +++++++ server/vnc/hub.js | 215 ++++++++ server/vnc/rfb.js | 274 ++++++++++ test/e2e.js | 601 +++++++++++++++++++++ 34 files changed, 7057 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 agent/agent.js create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 public/app.js create mode 100644 public/enroll.html create mode 100644 public/index.html create mode 100644 public/particles.js create mode 100644 public/share.html create mode 100644 public/styles.css create mode 100644 public/viewer.html create mode 100644 public/viewer.js create mode 100644 server/auth.js create mode 100644 server/config.js create mode 100644 server/crypto.js create mode 100644 server/db.js create mode 100644 server/index.js create mode 100644 server/routes/clients.js create mode 100644 server/routes/invites.js create mode 100644 server/routes/public.js create mode 100644 server/routes/sessions.js create mode 100644 server/tickets.js create mode 100644 server/vnc/bridge.js create mode 100644 server/vnc/des.js create mode 100644 server/vnc/hub.js create mode 100644 server/vnc/rfb.js create mode 100644 test/e2e.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5603323 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +data +test +.git +.env +*.md +!PLAN.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bcb6e9f --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..80852db --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 }}" + } diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3ad628 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +data/ +*.log +.env +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eff5e67 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..c550756 --- /dev/null +++ b/PLAN.md @@ -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/` — 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/` — 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/` enrollment page +- [x] `/s/` 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fee98e7 --- /dev/null +++ b/README.md @@ -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 [--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 | diff --git a/agent/agent.js b/agent/agent.js new file mode 100644 index 0000000..e450f57 --- /dev/null +++ b/agent/agent.js @@ -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 '); + + 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 " 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 [--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(); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7011213 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/package.json b/package.json new file mode 100644 index 0000000..e4f834e --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..ef4b276 --- /dev/null +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..9dad60d --- /dev/null +++ b/public/app.js @@ -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(); diff --git a/public/enroll.html b/public/enroll.html new file mode 100644 index 0000000..292b282 --- /dev/null +++ b/public/enroll.html @@ -0,0 +1,115 @@ + + + + + + + Set up remote support + + + + + + +
+
+ +
+
RC
+
+ Remote Support + Machine enrollment +
+
+ +
+ One-time setup +

Set up remote support

+ +
Checking this link…
+ + + + +
+
+
+ + + + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..9f16d2f --- /dev/null +++ b/public/index.html @@ -0,0 +1,165 @@ + + + + + + + Remote Support + + + + + + + + + + + + + + + + + + + + + diff --git a/public/particles.js b/public/particles.js new file mode 100644 index 0000000..d8981b1 --- /dev/null +++ b/public/particles.js @@ -0,0 +1,273 @@ +/* ============================================================================ + particles.js — ambient "network of machines" field. + + Self-contained, no dependencies. Mounts a 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; diff --git a/public/share.html b/public/share.html new file mode 100644 index 0000000..6511812 --- /dev/null +++ b/public/share.html @@ -0,0 +1,106 @@ + + + + + + + Join remote session + + + + + + +
+
+ +
+
RC
+
+ Remote Support + Guest access +
+
+ +
+ Support link +

Join a remote session

+ +
Checking this link…
+ + + +
+
+
+ + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..b2ce737 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,1021 @@ +/* ============================================================================ + Remote Support — design system + Dark-first ops console. Warm-graphite neutrals, mint signal, copper accent. + Typography pairs the platform mono (structure, labels, identifiers) with the + platform sans (prose, controls) — no webfonts, this box may have no internet. + ========================================================================= */ + +:root { + /* --- neutrals: warm graphite, faint violet cast (not the usual blue-gray) */ + --bg: #0b0a0d; + --bg-deep: #070609; + --surface: #131218; + --surface-2: #1a1922; + --surface-3: #211f2b; + --well: #0e0d12; + + --line: #232230; + --line-strong: #333042; + --line-hover: #474357; + + --text: #eceaf2; + --text-dim: #a6a0b5; /* 7.4:1 on --surface */ + --text-faint: #857f96; /* 4.9:1 on --surface */ + + /* --- signal palette */ + --mint: #3ddc97; /* brand + primary action + "live" */ + --mint-bright: #5ff0b1; + --mint-deep: #1c6f52; + --mint-glow: rgba(61, 220, 151, .14); + --mint-ink: #04140c; + + --copper: #e9a05c; /* warnings, consent, secondary highlight */ + --copper-deep: #59401f; + --iris: #9aa2ff; /* informational: direct connections, links */ + --iris-deep: #2f3164; + --danger: #f4626f; + --danger-text: #ff9aa2; + --danger-deep: #4d2229; + + /* --- shape + motion */ + --r-sm: 7px; + --r-md: 10px; + --r-lg: 14px; + --r-xl: 18px; + + --ease-out-quart: cubic-bezier(.25, 1, .5, 1); + --ease-out-expo: cubic-bezier(.16, 1, .3, 1); + --t-fast: 130ms var(--ease-out-quart); + --t-mid: 260ms var(--ease-out-quart); + + --shadow-lift: 0 1px 2px rgba(0,0,0,.4), 0 12px 28px -14px rgba(0,0,0,.8); + --shadow-pop: 0 24px 70px -20px rgba(0,0,0,.85), 0 2px 6px rgba(0,0,0,.5); + + --sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + --mono: ui-monospace, SFMono-Regular, "SF Mono", "Cascadia Mono", + "Segoe UI Mono", Menlo, Consolas, "Liberation Mono", monospace; +} + +*, *::before, *::after { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font: 15px/1.55 var(--sans); + letter-spacing: .001em; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Ambient page texture: a faint engineering grid plus one cool bloom. + Fixed, pointer-transparent, and never applied to the live session view. */ +body:not(.viewer-body)::before { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: + linear-gradient(to right, rgba(255,255,255,.020) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255,255,255,.020) 1px, transparent 1px); + background-size: 68px 68px; + -webkit-mask-image: radial-gradient(120% 80% at 50% -10%, #000 15%, transparent 70%); + mask-image: radial-gradient(120% 80% at 50% -10%, #000 15%, transparent 70%); +} + +body:not(.viewer-body)::after { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + radial-gradient(70% 55% at 12% -8%, rgba(61,220,151,.09), transparent 62%), + radial-gradient(60% 50% at 92% 4%, rgba(154,162,255,.06), transparent 60%), + radial-gradient(90% 60% at 50% 108%, rgba(233,160,92,.045), transparent 65%); +} + +/* Everything real sits above the ambient layers. */ +#login, #app, #modal-root, .center-shell, #toast { position: relative; z-index: 1; } + +a { + color: var(--mint); + text-decoration-color: rgba(61,220,151,.35); + text-underline-offset: 3px; + transition: color var(--t-fast); +} +a:hover { color: var(--mint-bright); } + +h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -.012em; } +h1 { font-size: 18px; } +h2 { font-size: 16px; } +h3 { font-size: 14px; } + +p { margin: 0 0 12px; } + +::selection { background: rgba(61,220,151,.28); color: #fff; } + +/* --------------------------------------------------------------- utilities */ + +.hidden { display: none !important; } +[hidden] { display: none !important; } + +.mono { font-family: var(--mono); font-size: 12px; letter-spacing: 0; } +.dim { color: var(--text-dim); } +.faint { color: var(--text-faint); font-size: 13px; } +.row { display: flex; align-items: center; gap: 10px; } +.wrap { flex-wrap: wrap; } +.spacer { flex: 1 1 auto; } + +/* Small structural label used across panels, cards, tables and forms. */ +.eyebrow { + font-family: var(--mono); + font-size: 10.5px; + font-weight: 600; + letter-spacing: .13em; + text-transform: uppercase; + color: var(--text-faint); +} + +/* ----------------------------------------------------------- focus states */ + +:where(button, [href], input, select, textarea, [tabindex]):focus-visible { + outline: 2px solid var(--mint); + outline-offset: 2px; + border-radius: var(--r-sm); +} + +/* --------------------------------------------------------------- controls */ + +button { + font: 500 13.5px/1.2 var(--sans); + letter-spacing: .005em; + padding: 8px 14px; + border-radius: var(--r-sm); + border: 1px solid var(--line-strong); + background: var(--surface-2); + color: var(--text); + cursor: pointer; + white-space: nowrap; + transition: border-color var(--t-fast), background var(--t-fast), + color var(--t-fast), transform var(--t-fast), box-shadow var(--t-fast); +} +button:hover:not(:disabled) { background: var(--surface-3); border-color: var(--line-hover); } +button:active:not(:disabled) { transform: translateY(1px); } +button:disabled { opacity: .4; cursor: not-allowed; } + +button.primary { + background: var(--mint); + border-color: var(--mint); + color: var(--mint-ink); + font-weight: 650; +} +button.primary:hover:not(:disabled) { + background: var(--mint-bright); + border-color: var(--mint-bright); + box-shadow: 0 6px 20px -10px rgba(61,220,151,.9); +} + +button.danger { + color: var(--danger-text); + border-color: var(--danger-deep); + background: rgba(244,98,111,.06); +} +button.danger:hover:not(:disabled) { + background: rgba(244,98,111,.14); + border-color: var(--danger); + color: #ffd0d4; +} + +button.ghost { + background: transparent; + border-color: transparent; + color: var(--text-dim); +} +button.ghost:hover:not(:disabled) { background: var(--surface-2); color: var(--text); } +button.ghost.danger { background: transparent; border-color: transparent; } +button.ghost.danger:hover:not(:disabled) { background: rgba(244,98,111,.12); } + +button.small { padding: 5px 11px; font-size: 12.5px; border-radius: 6px; } + +/* ------------------------------------------------------------------ forms */ + +input, select, textarea { + font: 14px/1.4 var(--sans); + width: 100%; + padding: 9px 12px; + border-radius: var(--r-sm); + border: 1px solid var(--line); + background: var(--well); + color: var(--text); + transition: border-color var(--t-fast), box-shadow var(--t-fast), background var(--t-fast); +} +input::placeholder, textarea::placeholder { color: #635e73; } +input:hover:not(:focus), select:hover:not(:focus) { border-color: var(--line-strong); } +input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--mint-deep); + background: #0b0a0f; + box-shadow: 0 0 0 3px var(--mint-glow); +} +select { + appearance: none; + padding-right: 34px; + background-image: linear-gradient(45deg, transparent 50%, var(--text-faint) 50%), + linear-gradient(135deg, var(--text-faint) 50%, transparent 50%); + background-position: calc(100% - 17px) calc(50% + 1px), calc(100% - 12px) calc(50% + 1px); + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; +} + +label { + display: block; + margin-bottom: 6px; + font-family: var(--mono); + font-size: 10.5px; + font-weight: 600; + letter-spacing: .13em; + text-transform: uppercase; + color: var(--text-dim); +} + +.field { margin-bottom: 15px; } +.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } + +/* Checkbox rows are sentences, not labels — opt them back out of the mono cap style. */ +label.check { + display: flex; + align-items: flex-start; + gap: 9px; + margin-bottom: 0; + font: 13.5px/1.45 var(--sans); + letter-spacing: normal; + text-transform: none; + color: var(--text); + cursor: pointer; +} +label.check input { + width: auto; + flex: none; + margin-top: 1px; + accent-color: var(--mint); + padding: 0; +} + +/* ------------------------------------------------------------ brand mark */ + +.brand { display: flex; align-items: center; gap: 11px; } + +.brand-mark { + position: relative; + width: 32px; + height: 32px; + flex: none; + display: grid; + place-items: center; + border-radius: 9px; + background: + linear-gradient(150deg, rgba(61,220,151,.22), rgba(154,162,255,.14) 55%, transparent), + var(--surface-2); + border: 1px solid var(--line-strong); + box-shadow: inset 0 1px 0 rgba(255,255,255,.06); + font: 700 12px/1 var(--mono); + letter-spacing: .04em; + color: var(--mint); +} +/* a single lit node on the mark — the product in one glyph */ +.brand-mark::after { + content: ""; + position: absolute; + top: -2px; + right: -2px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--mint); + box-shadow: 0 0 0 2px var(--bg), 0 0 10px rgba(61,220,151,.8); +} +.brand h1 { font-size: 15px; font-weight: 600; letter-spacing: -.01em; } +.brand .brand-sub, +.card-header .brand-sub { + display: block; + margin-top: 1px; + font-family: var(--mono); + font-size: 9.5px; + font-weight: 600; + letter-spacing: .16em; + text-transform: uppercase; + color: var(--text-faint); +} + +/* --------------------------------------------------------- particle field */ + +.bg-field { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + opacity: 1; + transition: opacity 600ms var(--ease-out-quart); +} +.bg-field canvas { display: block; width: 100%; height: 100%; } +/* Inside the console it is a whisper, not a feature. */ +body[data-screen="console"] .bg-field { opacity: .38; } + +/* ------------------------------------------------------------------ login */ + +.login-shell { + min-height: 100vh; + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(340px, .95fr); + align-items: center; + gap: clamp(32px, 7vw, 90px); + max-width: 1080px; + margin: 0 auto; + padding: 40px 28px; +} + +.login-aside { max-width: 460px; } +.login-aside .brand { margin-bottom: 34px; } + +.login-headline { + font-size: clamp(28px, 4.2vw, 40px); + line-height: 1.08; + font-weight: 600; + letter-spacing: -.03em; + margin: 0 0 16px; + text-wrap: balance; +} +.login-headline em { + font-style: normal; + color: var(--mint); +} +.login-lede { + color: var(--text-dim); + font-size: 15px; + line-height: 1.6; + max-width: 42ch; + margin-bottom: 30px; +} + +.login-facts { + list-style: none; + margin: 0; + padding: 0; + border-top: 1px solid var(--line); +} +.login-facts li { + display: flex; + align-items: baseline; + gap: 12px; + padding: 11px 2px; + border-bottom: 1px solid var(--line); + font-family: var(--mono); + font-size: 11px; + letter-spacing: .11em; + text-transform: uppercase; + color: var(--text-dim); +} +.login-facts li::before { + content: ""; + width: 5px; + height: 5px; + flex: none; + border-radius: 1px; + background: var(--mint); + transform: translateY(-1px); + box-shadow: 0 0 8px rgba(61,220,151,.6); +} +.login-facts li:nth-child(2)::before { background: var(--copper); box-shadow: 0 0 8px rgba(233,160,92,.55); } +.login-facts li:nth-child(3)::before { background: var(--iris); box-shadow: 0 0 8px rgba(154,162,255,.55); } + +.login-card { + width: 100%; + background: linear-gradient(180deg, rgba(255,255,255,.028), transparent 42%), var(--surface); + border: 1px solid var(--line-strong); + border-radius: var(--r-xl); + padding: 30px 30px 32px; + box-shadow: var(--shadow-pop); + animation: card-rise 520ms var(--ease-out-expo) both; +} +/* the compact brand only appears once the aside is dropped on narrow screens */ +.card-brand { display: none; margin-bottom: 26px; } + +.login-card .form-title { + font-size: 17px; + font-weight: 600; + letter-spacing: -.015em; + margin-bottom: 4px; +} +.login-card .form-note { margin: 0 0 22px; } +.login-card button[type="submit"] { width: 100%; padding: 11px 14px; font-size: 14px; } + +.login-foot { + margin: 20px 0 0; + padding-top: 16px; + border-top: 1px solid var(--line); + font-family: var(--mono); + font-size: 10.5px; + letter-spacing: .1em; + text-transform: uppercase; + color: var(--text-faint); +} + +@keyframes card-rise { + from { opacity: 0; transform: translateY(14px) scale(.985); } + to { opacity: 1; transform: none; } +} + +@media (max-width: 900px) { + .login-shell { grid-template-columns: minmax(0, 1fr); max-width: 420px; gap: 30px; } + .login-aside { display: none; } + .login-shell .login-card .card-brand { display: flex; } +} + +/* ---------------------------------------------------------------- console */ + +header.topbar { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + gap: 16px; + padding: 11px 22px; + background: rgba(11,10,13,.86); + -webkit-backdrop-filter: blur(14px) saturate(140%); + backdrop-filter: blur(14px) saturate(140%); + border-bottom: 1px solid var(--line); +} +/* hairline of signal along the very top of the console */ +header.topbar::before { + content: ""; + position: absolute; + inset: 0 0 auto; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(61,220,151,.55) 18%, rgba(154,162,255,.4) 55%, transparent 88%); +} + +.topbar-divider { + width: 1px; + height: 22px; + background: var(--line-strong); + flex: none; +} + +nav.tabs { + display: flex; + gap: 3px; + overflow-x: auto; + scrollbar-width: none; +} +nav.tabs::-webkit-scrollbar { display: none; } +nav.tabs button { + background: none; + border: 1px solid transparent; + border-radius: 6px; + padding: 7px 12px; + color: var(--text-dim); + font-family: var(--mono); + font-size: 11px; + font-weight: 600; + letter-spacing: .12em; + text-transform: uppercase; +} +nav.tabs button:hover:not(:disabled) { background: var(--surface-2); color: var(--text); } +nav.tabs button.active { + color: var(--mint); + background: rgba(61,220,151,.10); + border-color: rgba(61,220,151,.24); +} + +#whoami { + font-family: var(--mono); + font-size: 11px; + letter-spacing: .06em; + color: var(--text-dim); +} + +main { + padding: 26px 24px 72px; + max-width: 1240px; + margin: 0 auto; +} + +.panel-head { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--line); +} +.panel-head h2 { + display: flex; + align-items: center; + gap: 9px; + font-family: var(--mono); + font-size: 12px; + font-weight: 700; + letter-spacing: .16em; + text-transform: uppercase; + color: var(--text); +} +.panel-head h2::before { + content: ""; + width: 3px; + height: 13px; + border-radius: 2px; + background: var(--mint); + box-shadow: 0 0 10px rgba(61,220,151,.5); +} +#machines-count { + font-family: var(--mono); + font-size: 10.5px; + letter-spacing: .1em; + text-transform: uppercase; + color: var(--text-faint); + padding: 3px 8px; + border: 1px solid var(--line); + border-radius: 20px; +} + +/* ------------------------------------------------------------------ cards */ + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(296px, 1fr)); + gap: 14px; +} + +.card { + position: relative; + overflow: hidden; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--r-md); + padding: 15px 16px; + display: flex; + flex-direction: column; + gap: 9px; + transition: border-color var(--t-fast), background var(--t-fast), transform var(--t-fast); +} +/* status rail: neutral by default, lit when the machine reports online */ +.card::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 2px; + background: var(--line-strong); + transition: background var(--t-fast); +} +.card:has(.dot.online)::before { background: linear-gradient(180deg, var(--mint), rgba(61,220,151,.15)); } +.card:has(.dot.direct)::before { background: linear-gradient(180deg, var(--iris), rgba(154,162,255,.15)); } +.card:hover { border-color: var(--line-strong); background: var(--surface-2); } + +.card-title { display: flex; align-items: center; gap: 9px; } +.card-title h3 { + font-family: var(--mono); + font-size: 13.5px; + font-weight: 600; + letter-spacing: -.005em; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: auto -16px -15px; + padding: 12px 16px; + border-top: 1px solid var(--line); + background: rgba(0,0,0,.16); +} + +/* ------------------------------------------------------------------- dots */ + +.dot { + width: 8px; + height: 8px; + flex: none; + border-radius: 50%; + background: var(--text-faint); +} +.dot.online { + background: var(--mint); + box-shadow: 0 0 0 3px var(--mint-glow); + animation: pulse 2.6s var(--ease-out-quart) infinite; +} +.dot.offline { background: #4b4757; box-shadow: none; animation: none; } +.dot.direct { background: var(--iris); box-shadow: 0 0 0 3px rgba(154,162,255,.13); animation: none; } + +@keyframes pulse { + 0%, 100% { box-shadow: 0 0 0 3px var(--mint-glow); } + 50% { box-shadow: 0 0 0 5px rgba(61,220,151,.05); } +} + +/* ------------------------------------------------------------------- tags */ + +.tag { + display: inline-flex; + align-items: center; + font-family: var(--mono); + font-size: 10px; + font-weight: 600; + letter-spacing: .1em; + text-transform: uppercase; + padding: 3px 8px; + border-radius: 20px; + border: 1px solid var(--line-strong); + background: rgba(255,255,255,.02); + color: var(--text-dim); + white-space: nowrap; +} +.tag.role { border-color: rgba(61,220,151,.32); color: var(--mint); background: rgba(61,220,151,.08); } +.tag.warn { border-color: rgba(233,160,92,.34); color: var(--copper); background: rgba(233,160,92,.08); } + +/* ----------------------------------------------------------------- tables */ + +.table-wrap { + overflow-x: auto; + border: 1px solid var(--line); + border-radius: var(--r-md); + background: var(--surface); +} +table { width: 100%; border-collapse: collapse; font-size: 13.5px; } +th { + text-align: left; + padding: 10px 12px; + font-family: var(--mono); + font-size: 10.5px; + font-weight: 700; + letter-spacing: .13em; + text-transform: uppercase; + color: var(--text-dim); + background: rgba(255,255,255,.022); + border-bottom: 1px solid var(--line); + white-space: nowrap; +} +td { + padding: 10px 12px; + border-bottom: 1px solid var(--line); + vertical-align: middle; +} +tbody tr:last-child td { border-bottom: none; } +tbody tr { transition: background var(--t-fast); } +tbody tr:hover { background: rgba(255,255,255,.028); } +td .faint, th .faint { font-size: 12.5px; } + +/* ------------------------------------------------------------------ modal */ + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + padding: 20px; + background: rgba(5,4,7,.74); + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); + animation: fade-in 180ms var(--ease-out-quart) both; +} +.modal { + width: 100%; + max-width: 470px; + max-height: 88vh; + overflow-y: auto; + background: linear-gradient(180deg, rgba(255,255,255,.03), transparent 30%), var(--surface); + border: 1px solid var(--line-strong); + border-radius: var(--r-lg); + padding: 22px 24px 24px; + box-shadow: var(--shadow-pop); + animation: card-rise 300ms var(--ease-out-expo) both; +} +.modal-head { + display: flex; + align-items: center; + gap: 10px; + margin: -22px -24px 20px; + padding: 16px 18px 15px 24px; + border-bottom: 1px solid var(--line); +} +.modal-head h2 { + font-size: 15px; + font-weight: 600; + letter-spacing: -.01em; +} +.modal-head button { padding: 3px 9px; font-size: 13px; line-height: 1; } +.modal-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin: 22px -24px -24px; + padding: 16px 24px; + border-top: 1px solid var(--line); + background: rgba(0,0,0,.18); +} + +@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } + +/* ---------------------------------------------------------------- notices */ + +.notice { + padding: 11px 14px; + border-radius: var(--r-sm); + border: 1px solid var(--line-strong); + border-left-width: 2px; + background: var(--well); + font-size: 13.5px; + line-height: 1.45; + margin-bottom: 15px; +} +.notice.error { + border-color: rgba(244,98,111,.34); + border-left-color: var(--danger); + color: var(--danger-text); + background: rgba(244,98,111,.08); +} +.notice.ok { + border-color: rgba(61,220,151,.3); + border-left-color: var(--mint); + color: var(--mint); + background: rgba(61,220,151,.07); +} + +.empty { + padding: 52px 24px; + text-align: center; + font-size: 13.5px; + color: var(--text-faint); + border: 1px dashed var(--line-strong); + border-radius: var(--r-md); + background: + repeating-linear-gradient(-45deg, rgba(255,255,255,.012) 0 8px, transparent 8px 16px); +} + +.copybox { display: flex; gap: 8px; align-items: stretch; } +.copybox input { + font-family: var(--mono); + font-size: 12px; + letter-spacing: 0; +} +.copybox button { flex: none; } + +#toast { + position: fixed; + left: 50%; + bottom: 26px; + z-index: 100; + transform: translateX(-50%); + padding: 11px 18px; + font-size: 13.5px; + background: var(--surface-2); + border: 1px solid var(--line-strong); + border-left: 2px solid var(--mint); + border-radius: var(--r-sm); + box-shadow: var(--shadow-pop); + animation: toast-in 300ms var(--ease-out-expo) both; +} +@keyframes toast-in { + from { opacity: 0; transform: translate(-50%, 12px); } + to { opacity: 1; transform: translate(-50%, 0); } +} + +/* ----------------------------------------------------- public page shells */ + +.center-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 40px 22px; +} +.center-card { + width: 100%; + max-width: 560px; + background: linear-gradient(180deg, rgba(255,255,255,.028), transparent 34%), var(--surface); + border: 1px solid var(--line-strong); + border-radius: var(--r-xl); + box-shadow: var(--shadow-pop); + animation: card-rise 520ms var(--ease-out-expo) both; + overflow: hidden; +} +.card-header { + display: flex; + align-items: center; + gap: 11px; + padding: 18px 26px; + border-bottom: 1px solid var(--line); + background: rgba(0,0,0,.2); +} +.card-body { padding: 26px; } +.card-body > h1 { + font-size: 22px; + letter-spacing: -.022em; + margin-bottom: 8px; +} +.card-kicker { + display: block; + margin-bottom: 10px; + font-family: var(--mono); + font-size: 10.5px; + font-weight: 600; + letter-spacing: .16em; + text-transform: uppercase; + color: var(--mint); +} + +pre.code { + background: var(--bg-deep); + border: 1px solid var(--line); + border-left: 2px solid rgba(61,220,151,.45); + border-radius: var(--r-sm); + padding: 13px 15px; + margin: 10px 0 0; + overflow-x: auto; + font-family: var(--mono); + font-size: 12.5px; + line-height: 1.65; + color: #cbe7da; +} + +ol.steps { + list-style: none; + counter-reset: step; + margin: 22px 0 0; + padding: 0; +} +ol.steps li { + counter-increment: step; + position: relative; + padding: 0 0 20px 40px; + font-size: 14px; + line-height: 1.55; +} +ol.steps li::before { + content: counter(step, decimal-leading-zero); + position: absolute; + left: 0; + top: 0; + width: 26px; + height: 26px; + display: grid; + place-items: center; + border-radius: 7px; + border: 1px solid var(--line-strong); + background: var(--surface-2); + font: 700 10.5px/1 var(--mono); + letter-spacing: .04em; + color: var(--mint); +} +/* connector line between steps — the "sequence" reads at a glance */ +ol.steps li::after { + content: ""; + position: absolute; + left: 13px; + top: 30px; + bottom: 8px; + width: 1px; + background: linear-gradient(180deg, var(--line-strong), transparent); +} +ol.steps li:last-child { padding-bottom: 0; } +ol.steps li:last-child::after { display: none; } +ol.steps li strong { font-weight: 600; } + +/* ----------------------------------------------------------------- viewer */ + +body.viewer-body { + overflow: hidden; + height: 100vh; + display: flex; + flex-direction: column; + background: #05070a; +} + +.viewer-bar { + flex: none; + display: flex; + align-items: center; + gap: 9px; + height: 38px; + padding: 0 10px 0 12px; + background: linear-gradient(180deg, #17161d, #121118); + border-bottom: 1px solid #262433; +} +.viewer-bar #client-name { + font-family: var(--mono); + font-size: 12.5px; + font-weight: 600; + letter-spacing: -.005em; + max-width: 34vw; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.viewer-bar #conn-status { + font-family: var(--mono); + font-size: 10.5px; + letter-spacing: .1em; + text-transform: uppercase; +} +.viewer-bar .tag { padding: 2px 7px; font-size: 9.5px; } +.viewer-bar button { + padding: 4px 10px; + font-size: 12px; + border-radius: 6px; + background: rgba(255,255,255,.045); + border-color: rgba(255,255,255,.10); +} +.viewer-bar button:hover:not(:disabled) { background: rgba(255,255,255,.10); border-color: rgba(255,255,255,.2); } +.viewer-bar button.danger { + background: rgba(244,98,111,.10); + border-color: rgba(244,98,111,.28); + color: var(--danger-text); +} +.viewer-bar button.danger:hover:not(:disabled) { background: rgba(244,98,111,.2); border-color: var(--danger); } +.viewer-sep { width: 1px; height: 18px; background: rgba(255,255,255,.10); flex: none; } + +#screen { + flex: 1 1 auto; + min-height: 0; + background: #05070a; + overflow: hidden; +} + +.viewer-status { + position: absolute; + inset: 0; + display: grid; + place-items: center; + pointer-events: none; + background: radial-gradient(60% 60% at 50% 45%, rgba(11,10,13,.72), rgba(5,7,10,.94)); +} +.viewer-status .box { + pointer-events: auto; + min-width: 300px; + max-width: 430px; + padding: 26px 30px 24px; + text-align: center; + background: var(--surface); + border: 1px solid var(--line-strong); + border-radius: var(--r-lg); + box-shadow: var(--shadow-pop); +} +.viewer-status .box h2 { + font-family: var(--mono); + font-size: 12px; + font-weight: 700; + letter-spacing: .15em; + text-transform: uppercase; + color: var(--text); + margin-bottom: 10px; +} +.viewer-status .box p { font-size: 13.5px; line-height: 1.5; } +/* indeterminate progress hairline under the status title */ +.viewer-status .box::before { + content: ""; + display: block; + width: 46px; + height: 2px; + margin: 0 auto 16px; + border-radius: 2px; + background: linear-gradient(90deg, transparent, var(--mint), transparent); + animation: scan 1.8s var(--ease-out-quart) infinite; +} +@keyframes scan { + 0%, 100% { opacity: .25; transform: scaleX(.5); } + 50% { opacity: 1; transform: scaleX(1); } +} + +/* ------------------------------------------------------------- responsive */ + +@media (max-width: 820px) { + header.topbar { flex-wrap: wrap; gap: 10px 12px; padding: 10px 16px; } + header.topbar .topbar-divider { display: none; } + nav.tabs { order: 3; width: 100%; } + main { padding: 20px 16px 60px; } + .panel-head { flex-wrap: wrap; } + .field-row { grid-template-columns: 1fr; } + .card-body, .card-header { padding-left: 20px; padding-right: 20px; } +} + +/* --------------------------------------------------------- reduced motion */ + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: .001ms !important; + animation-iteration-count: 1 !important; + transition-duration: .001ms !important; + scroll-behavior: auto !important; + } +} diff --git a/public/viewer.html b/public/viewer.html new file mode 100644 index 0000000..e4b375c --- /dev/null +++ b/public/viewer.html @@ -0,0 +1,40 @@ + + + + + + + Remote session + + + + + +
+ + Connecting… + + + +
+ + + + + + +
+ +
+
+
+

Connecting…

+

Setting up the session.

+
+
+
+
+ + + + diff --git a/public/viewer.js b/public/viewer.js new file mode 100644 index 0000000..dfc8e39 --- /dev/null +++ b/public/viewer.js @@ -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 = '/'; } }, + ]); + } +})(); diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..d3c27ad --- /dev/null +++ b/server/auth.js @@ -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, +}; diff --git a/server/config.js b/server/config.js new file mode 100644 index 0000000..b0c2bbd --- /dev/null +++ b/server/config.js @@ -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; diff --git a/server/crypto.js b/server/crypto.js new file mode 100644 index 0000000..c5e582f --- /dev/null +++ b/server/crypto.js @@ -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 }; diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..1c721a2 --- /dev/null +++ b/server/db.js @@ -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 }; diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..6c792ce --- /dev/null +++ b/server/index.js @@ -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 }; diff --git a/server/routes/clients.js b/server/routes/clients.js new file mode 100644 index 0000000..8f9383e --- /dev/null +++ b/server/routes/clients.js @@ -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 }; diff --git a/server/routes/invites.js b/server/routes/invites.js new file mode 100644 index 0000000..bfcaaf5 --- /dev/null +++ b/server/routes/invites.js @@ -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 }; diff --git a/server/routes/public.js b/server/routes/public.js new file mode 100644 index 0000000..a5e330a --- /dev/null +++ b/server/routes/public.js @@ -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 }; diff --git a/server/routes/sessions.js b/server/routes/sessions.js new file mode 100644 index 0000000..f3d1770 --- /dev/null +++ b/server/routes/sessions.js @@ -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 }; diff --git a/server/tickets.js b/server/tickets.js new file mode 100644 index 0000000..6de43b5 --- /dev/null +++ b/server/tickets.js @@ -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 }; diff --git a/server/vnc/bridge.js b/server/vnc/bridge.js new file mode 100644 index 0000000..9baeafe --- /dev/null +++ b/server/vnc/bridge.js @@ -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 }; diff --git a/server/vnc/des.js b/server/vnc/des.js new file mode 100644 index 0000000..c3f53f7 --- /dev/null +++ b/server/vnc/des.js @@ -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 }; diff --git a/server/vnc/hub.js b/server/vnc/hub.js new file mode 100644 index 0000000..181c225 --- /dev/null +++ b/server/vnc/hub.js @@ -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(); diff --git a/server/vnc/rfb.js b/server/vnc/rfb.js new file mode 100644 index 0000000..9ea9262 --- /dev/null +++ b/server/vnc/rfb.js @@ -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, +}; diff --git a/test/e2e.js b/test/e2e.js new file mode 100644 index 0000000..9c987c4 --- /dev/null +++ b/test/e2e.js @@ -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();