feat: self-hosted remote support over VNC
Browser-based remote control (noVNC) with invite links, per-user access control, garagedoor SSO and a persisted client list. The hub proxies RFB rather than pointing the browser at a VNC server. That is what lets it authenticate upstream with a stored password the browser never sees, and enforce view-only by dropping input messages on the client->server stream instead of hiding buttons. Machines are reachable two ways: direct TCP for LAN hosts, or an outbound agent tunnel for anything behind NAT. Node 22's global WebSocket keeps the agent dependency-free, and node:sqlite keeps the image free of native builds. Ships with an end-to-end suite that boots the real server against a fake VNC server and a fake auth service (72 assertions), plus Gitea Actions CI/CD to Portainer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
data
|
||||
test
|
||||
.git
|
||||
.env
|
||||
*.md
|
||||
!PLAN.md
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
data/
|
||||
*.log
|
||||
.env
|
||||
.DS_Store
|
||||
+25
@@ -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"]
|
||||
@@ -0,0 +1,170 @@
|
||||
# Remote Control Support Webapp — Plan
|
||||
|
||||
Self-hosted TeamViewer-style remote support tool built on **VNC/RFB**, browser-based
|
||||
(noVNC), with invite links, access control, garagedoor SSO, and a persisted client list.
|
||||
|
||||
Status legend: `[ ]` todo · `[~]` in progress · `[x]` done
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
```
|
||||
browser (noVNC) hub (this app) client machine
|
||||
┌───────────────────────┐ ┌────────────────────────┐ ┌───────────────────┐
|
||||
│ viewer.html │ wss │ express + ws │ │ VNC server :5900 │
|
||||
│ RFB over WebSocket │◄──────►│ /vnc?ticket=… │ │ │
|
||||
└───────────────────────┘ │ │ │ │
|
||||
│ bridge: │ tcp │ │
|
||||
│ direct mode ─────────┼───────►│ │
|
||||
│ │ │ │
|
||||
│ agent mode │ wss │ agent.js │
|
||||
│ /agent (control) ◄───┼────────┤ outbound only │
|
||||
│ /tunnel (data) ◄───┼────────┤ pipes to :5900 │
|
||||
└────────────────────────┘ └───────────────────┘
|
||||
│
|
||||
SQLite (clients, grants,
|
||||
invites, sessions, audit)
|
||||
```
|
||||
|
||||
**Two connection modes** — a client row is one or the other:
|
||||
|
||||
| Mode | How | Use for |
|
||||
|---|---|---|
|
||||
| `direct` | Hub opens TCP to `host:port`. | LAN machines with a reachable VNC server. |
|
||||
| `agent` | Client runs `agent/agent.js`, dials **out** to the hub over WSS and holds it open. Hub asks it to open a data tunnel per session. | NAT'd / remote machines. The TeamViewer-shaped path. |
|
||||
|
||||
**Why a proxy and not raw noVNC:** the hub terminates the RFB handshake itself. That
|
||||
lets it (a) authenticate to the real VNC server with a **server-side stored password**
|
||||
the browser never sees, and (b) enforce **view-only** by dropping input messages on the
|
||||
client→server stream. Both are required for invite links to be safe.
|
||||
|
||||
## 2. Tech stack
|
||||
|
||||
- Node 22, CommonJS, Express 4 — matches `stash-ex-webapp`.
|
||||
- Deployed by Gitea Actions to Portainer's Swarm endpoint; single replica, pinned
|
||||
placement, named volume (see README).
|
||||
- `ws` for all WebSocket endpoints; Node's built-in `node:sqlite` for persistence
|
||||
(no native modules, so the container has no build stage).
|
||||
- `@novnc/novnc` vendored and served as browser ES modules.
|
||||
- Vanilla JS frontend in `public/` (no build step).
|
||||
- Auth delegated to **garagedoor-node-ws** (`http://192.168.4.208:8000`) — proxy pattern,
|
||||
never hold the JWT secret locally.
|
||||
|
||||
## 3. Data model (SQLite)
|
||||
|
||||
- `clients` — id, name, mode, host/port, encrypted VNC password, agent key hash,
|
||||
`require_consent`, tags, os, agent_version, last_seen_at, status, created_by.
|
||||
- `grants` — per-user access to a client with a role and optional expiry.
|
||||
- `invites` — hashed token, kind (`enroll` | `session`), target client, role, max_uses,
|
||||
uses, expiry, revocation.
|
||||
- `sessions` — audit trail of every connection: who, what client, role, bytes, duration,
|
||||
source (`web` | `invite`), end reason.
|
||||
- `audit` — admin actions (client created/deleted, invite issued/revoked, grant changed).
|
||||
|
||||
Secrets at rest (VNC passwords, agent keys) are AES-256-GCM encrypted with a key derived
|
||||
from `ENCRYPTION_KEY`. Invite and agent tokens are stored **hashed**, never plaintext.
|
||||
|
||||
## 4. Access control model
|
||||
|
||||
Roles resolved per (user, client) at connect time:
|
||||
|
||||
| Role | Can |
|
||||
|---|---|
|
||||
| `admin` | Everything: CRUD clients, issue/revoke invites, manage grants, view audit. |
|
||||
| `operator` | Connect with full keyboard/mouse control to granted clients. |
|
||||
| `viewer` | Connect **view-only** (input filtered at the proxy) to granted clients. |
|
||||
|
||||
- Admin = garagedoor username in `ADMIN_USERS`, or garagedoor `level` ≥ `ADMIN_LEVEL`.
|
||||
- Non-admins see only clients they hold a grant for.
|
||||
- `require_consent` on a client makes the agent prompt the local user before each session.
|
||||
- Every WS connect uses a **one-time, 30-second ticket** minted by `POST /api/sessions`,
|
||||
so long-lived JWTs never appear in URLs or proxy logs.
|
||||
|
||||
## 5. Invite links — two kinds
|
||||
|
||||
1. **Enrollment invite** `/enroll/<token>` — hand to a machine you want to manage. Page
|
||||
shows the install one-liner with a single-use token baked in; running it registers the
|
||||
machine as a client and it appears in the list.
|
||||
2. **Session invite** `/s/<token>` — hand to a person. Time-limited, use-limited access to
|
||||
**one** client at a fixed role (`viewer` or `operator`), no login required. This is the
|
||||
"send the customer a link" flow.
|
||||
|
||||
Both are revocable, expiring, and logged.
|
||||
|
||||
---
|
||||
|
||||
## 6. Feature checklist
|
||||
|
||||
### Phase 1 — Foundation
|
||||
- [x] Project scaffold, package.json, env config, .gitignore
|
||||
- [x] SQLite schema + migrations on boot
|
||||
- [x] Secret encryption helper (AES-256-GCM)
|
||||
- [x] garagedoor auth proxy + `requireAuth` / `requireAdmin` middleware
|
||||
- [x] Login screen, token in localStorage, 401 → re-login
|
||||
|
||||
### Phase 2 — Clients & persistence
|
||||
- [x] `GET/POST/PATCH/DELETE /api/clients`
|
||||
- [x] Client list UI with status, tags, last-seen
|
||||
- [x] Add/edit client form (direct mode: host/port/password)
|
||||
- [x] Online/offline status tracking for agent clients
|
||||
|
||||
### Phase 3 — VNC bridge
|
||||
- [x] WS↔TCP bridge for direct mode
|
||||
- [x] Server-side RFB handshake + VNC Authentication (password never reaches browser)
|
||||
- [x] View-only enforcement by filtering client→server RFB messages
|
||||
- [x] noVNC viewer page: scaling, fullscreen, clipboard, Ctrl-Alt-Del
|
||||
- [x] One-time session tickets
|
||||
|
||||
### Phase 4 — Agent (NAT traversal)
|
||||
- [x] Agent control channel `/agent` with heartbeat + reconnect
|
||||
- [x] Data tunnel `/tunnel` paired to a waiting browser socket
|
||||
- [x] `agent/agent.js` — zero npm dependencies, uses Node 22's global WebSocket
|
||||
- [x] Enrollment via invite token → agent key issued once
|
||||
- [x] Local consent prompt when `require_consent` is set
|
||||
|
||||
### Phase 5 — Invites & access control
|
||||
- [x] Issue/list/revoke invites (both kinds)
|
||||
- [x] `/enroll/<token>` enrollment page
|
||||
- [x] `/s/<token>` session invite page (no login)
|
||||
- [x] Per-user grants CRUD
|
||||
- [x] Role resolution + enforcement at connect
|
||||
|
||||
### Phase 6 — Audit & operations
|
||||
- [x] Session history table + live "who is connected now"
|
||||
- [x] Audit log of admin actions
|
||||
- [x] Admin can force-disconnect an active session
|
||||
- [x] Dockerfile + docker-compose + Gitea Actions CI/CD to Portainer
|
||||
- [x] README with deploy + client setup instructions
|
||||
- [x] End-to-end test suite (`pnpm test`) — fake VNC server + fake auth service,
|
||||
72 assertions covering auth, RBAC, VNC auth, view-only, invites, agent tunnel
|
||||
|
||||
### Phase 7 — Front end polish
|
||||
- [~] Modern visual design pass across all five pages
|
||||
- [~] Particle background (network-of-machines motif), reduced-motion aware,
|
||||
never rendered behind a live VNC canvas
|
||||
|
||||
### Phase 8 — Later / nice to have
|
||||
- [ ] File transfer between operator and client
|
||||
- [ ] Clipboard sync toggle per session
|
||||
- [ ] Multi-monitor selection
|
||||
- [ ] Session recording (RFB stream capture + replay)
|
||||
- [ ] Chat sidebar during a support session
|
||||
- [ ] Wake-on-LAN integration (sibling `wol-fleet-webapp`)
|
||||
- [ ] TOTP step-up before controlling a flagged client
|
||||
- [ ] Agent auto-update
|
||||
- [ ] RDP backend alongside VNC
|
||||
|
||||
---
|
||||
|
||||
## 7. Security notes
|
||||
|
||||
- The hub is the only thing that knows VNC passwords; browsers get an RFB stream that has
|
||||
already cleared authentication.
|
||||
- View-only is enforced **server-side**, not by hiding UI.
|
||||
- Invite tokens: 32 bytes of `crypto.randomBytes`, stored as SHA-256, compared in constant
|
||||
time, single-use by default.
|
||||
- Deploy behind HTTPS (see the `lan-https-host` setup) — RFB over plain `ws://` is
|
||||
cleartext framebuffer data.
|
||||
- Known upstream debt in garagedoor (SQL injection, hardcoded secret) is *not* inherited:
|
||||
this app never touches that DB or that secret.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Remote Control Support
|
||||
|
||||
Self-hosted remote support over VNC. A TeamViewer-shaped console that runs in the
|
||||
browser: pick a machine, get its screen, take over the mouse and keyboard.
|
||||
|
||||
- **noVNC in the browser** — nothing to install for the person giving support.
|
||||
- **Two ways to reach a machine** — connect straight to a VNC server on the LAN, or
|
||||
have the machine run a small agent that dials *out*, so NAT and firewalls stop
|
||||
mattering.
|
||||
- **Invite links** — one kind adds a machine, the other hands someone time-limited
|
||||
access to a single machine with no account at all.
|
||||
- **Access control that is actually enforced** — view-only is applied at the proxy by
|
||||
dropping input messages, not by hiding buttons. Stored VNC passwords never reach
|
||||
the browser.
|
||||
- **Persisted** — machines, grants, invites, session history and an audit log live in
|
||||
SQLite.
|
||||
- Login delegated to the existing **garagedoor-node-ws** auth service.
|
||||
|
||||
See [PLAN.md](PLAN.md) for the architecture and feature checklist.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env # set ADMIN_USERS at minimum
|
||||
pnpm start # http://localhost:8080
|
||||
```
|
||||
|
||||
Run the test suite (boots the real server against a fake VNC server and a fake auth
|
||||
service, then drives it as a browser would):
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
CI/CD is Gitea Actions → registry → Portainer, same shape as the other services here.
|
||||
Pushing to `main` runs the test suite, builds `git.mancinas.io/rmancinas/remote-control-support-webapp:latest`,
|
||||
and deploys the stack (`.gitea/workflows/deploy.yml`).
|
||||
|
||||
Gitea repo secrets required:
|
||||
|
||||
| Secret | Value |
|
||||
|---|---|
|
||||
| `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` | git.mancinas.io login |
|
||||
| `PORTAINER_URL` | `https://192.168.4.212:9443` |
|
||||
| `PORTAINER_API_KEY` | Portainer → user icon → Access tokens |
|
||||
| `PORTAINER_ENDPOINT_ID` | `2` (the local Swarm endpoint) |
|
||||
| `PORTAINER_STACK_NAME` | e.g. `remote-control-support` |
|
||||
| `AUTH_URL` | `http://192.168.4.208:8000` |
|
||||
| `ADMIN_USERS` | e.g. `rmancinas` |
|
||||
| `ENCRYPTION_KEY` | `openssl rand -hex 32` — **set this**, see below |
|
||||
| `PUBLIC_URL` | e.g. `https://remote.mancinas.dev` |
|
||||
|
||||
Two things the compose file must keep, both because state lives in the process and on
|
||||
one node:
|
||||
|
||||
- **`replicas: 1`.** Agent control sockets and live sessions are held in memory. A
|
||||
second replica would not see the first one's agents, and connects would fail at
|
||||
random depending on which task the browser landed on.
|
||||
- **`node.role == manager` placement + named volume.** The SQLite volume is node-local.
|
||||
If the task reschedules elsewhere it comes up with an empty database.
|
||||
|
||||
`ENCRYPTION_KEY` is worth setting explicitly rather than letting the container generate
|
||||
one: the generated key lives in the same volume as the database, so losing the volume
|
||||
loses both, and the stored VNC passwords with them.
|
||||
|
||||
Locally:
|
||||
|
||||
```bash
|
||||
docker build -t rcs . && docker run --rm -p 8080:8080 -v rcs_data:/data rcs
|
||||
```
|
||||
|
||||
Put it behind HTTPS before using it for real: RFB is a raw framebuffer stream and
|
||||
`ws://` sends it in the clear. The `lan-https-host` setup covers this — and Nginx Proxy
|
||||
Manager in front is also what supplies the real client IP, since the app runs with
|
||||
`TRUST_PROXY=true`. **Enable WebSocket support on the proxy host** or nothing connects.
|
||||
|
||||
---
|
||||
|
||||
## Adding a machine
|
||||
|
||||
### Direct (the hub can reach its VNC port)
|
||||
|
||||
**Add machine** → host, port, VNC password. Good for servers and desktops on the same
|
||||
LAN as the hub.
|
||||
|
||||
### Agent (the machine dials out)
|
||||
|
||||
**Invite a machine** produces a link. Open it on the target machine and it shows a
|
||||
one-liner:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://your-hub/download/agent.js -o rcs-agent.js \
|
||||
&& node rcs-agent.js enroll https://your-hub/enroll/TOKEN \
|
||||
&& node rcs-agent.js run
|
||||
```
|
||||
|
||||
The agent needs Node 22+ and a VNC server listening on `127.0.0.1:5900`:
|
||||
|
||||
| OS | VNC server |
|
||||
|---|---|
|
||||
| macOS | System Settings → General → Sharing → Screen Sharing |
|
||||
| Windows | TightVNC or UltraVNC |
|
||||
| Linux | `x11vnc -localhost -rfbport 5900` |
|
||||
|
||||
Enrolment issues an agent key, stored hashed on the hub and written to
|
||||
`~/.rcs-agent.json` (mode 600) on the machine. To keep it running, wrap
|
||||
`node rcs-agent.js run` in a systemd unit, a launchd plist, or a scheduled task.
|
||||
|
||||
Agent commands:
|
||||
|
||||
```
|
||||
node agent.js enroll <link|token> [--hub URL] [--vnc-host H] [--vnc-port N] [--name NAME]
|
||||
node agent.js run [--vnc-host H] [--vnc-port N]
|
||||
node agent.js status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Access control
|
||||
|
||||
| Role | Gets |
|
||||
|---|---|
|
||||
| admin | Everything: machines, invites, grants, audit, force-disconnect |
|
||||
| operator | Full keyboard and mouse on machines they were granted |
|
||||
| viewer | Screen only — input is dropped by the proxy |
|
||||
|
||||
Admins are set by `ADMIN_USERS` (comma-separated usernames) or `ADMIN_LEVEL`
|
||||
(garagedoor `level` threshold). **With neither set, every authenticated user is an
|
||||
admin** — fine for a single operator, wrong for a shared install.
|
||||
|
||||
Everyone else sees only machines they hold a grant for (**⋯ → Who has access**).
|
||||
|
||||
Turning on *ask first* for a machine makes its agent prompt whoever is sitting there
|
||||
before each session, and the session does not start until they accept.
|
||||
|
||||
### Support links
|
||||
|
||||
**Support link** creates a URL that grants one machine, one role, until it expires. The
|
||||
person opening it types a name and connects — no account. Revoke it from the Invites
|
||||
tab at any time; live sessions can be cut from the Sessions tab.
|
||||
|
||||
---
|
||||
|
||||
## How a session actually works
|
||||
|
||||
```
|
||||
browser ──wss /ws/vnc?ticket=…──► hub ──tcp──► VNC server (direct)
|
||||
browser ──wss /ws/vnc?ticket=…──► hub ◄─wss /ws/tunnel── agent ──tcp──► VNC server (agent)
|
||||
```
|
||||
|
||||
The hub is a deliberate man-in-the-middle. It completes the RFB handshake with the real
|
||||
VNC server itself — including VNC Authentication, using the password it holds
|
||||
encrypted — and then presents the browser a handshake that needs no password. Because
|
||||
it sits in the middle of the message stream it can also parse the browser→server
|
||||
direction and drop `KeyEvent`, `PointerEvent`, `ClientCutText`, `SetDesktopSize` and
|
||||
`xvp` for view-only sessions.
|
||||
|
||||
WebSockets cannot carry an `Authorization` header, so `POST /api/sessions` mints a
|
||||
**single-use ticket that expires in 30 seconds** and the socket carries only that.
|
||||
|
||||
Node's OpenSSL 3 build dropped `des-ecb` from the default provider, so
|
||||
`server/vnc/des.js` carries a small DES implementation purely to answer the VNC auth
|
||||
challenge. It is verified against the standard test vectors in the test suite.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Everything is environment variables — see [.env.example](.env.example).
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|---|---|---|
|
||||
| `PORT` / `HOST` | `8080` / `0.0.0.0` | |
|
||||
| `AUTH_URL` | `http://192.168.4.208:8000` | garagedoor-node-ws |
|
||||
| `ADMIN_USERS` | *(empty)* | Comma-separated. Empty + no `ADMIN_LEVEL` = everyone is admin |
|
||||
| `ADMIN_LEVEL` | *(unset)* | garagedoor `level` at or above this is admin |
|
||||
| `DB_PATH` | `./data/rcs.db` | |
|
||||
| `ENCRYPTION_KEY` | *(generated)* | Encrypts stored VNC passwords. Back it up |
|
||||
| `PUBLIC_URL` | *(request host)* | Base URL used when rendering invite links |
|
||||
| `ALLOW_SESSION_INVITES` | `true` | `false` disables no-login support links |
|
||||
| `TICKET_TTL_MS` | `30000` | |
|
||||
| `INVITE_TTL_MS` | `86400000` | Default invite lifetime |
|
||||
| `CONSENT_TIMEOUT_MS` | `45000` | How long to wait for someone to accept |
|
||||
|
||||
## API sketch
|
||||
|
||||
| Method | Path | |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/login` | → token |
|
||||
| `GET` | `/api/clients` | machines you can see |
|
||||
| `POST` | `/api/clients` | admin |
|
||||
| `POST` | `/api/clients/:id/grants` | admin |
|
||||
| `POST` | `/api/clients/:id/agent-key` | admin, returns the key once |
|
||||
| `POST` | `/api/invites` | admin, `kind: enroll \| session` |
|
||||
| `POST` | `/api/sessions` | mint a connect ticket |
|
||||
| `GET` | `/api/sessions/live` | who is connected now |
|
||||
| `POST` | `/api/sessions/:id/kill` | admin |
|
||||
| `GET` | `/api/sessions/history` | |
|
||||
| `GET` | `/api/sessions/audit` | admin |
|
||||
| `POST` | `/api/public/enroll` | no auth, enrolment token |
|
||||
| `POST` | `/api/public/session/:token` | no auth, support link |
|
||||
| WS | `/ws/vnc?ticket=` | browser session |
|
||||
| WS | `/ws/agent?clientId=&key=` | agent control channel |
|
||||
| WS | `/ws/tunnel?clientId=&key=&tunnelId=` | agent data tunnel |
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
// Remote-control support agent.
|
||||
//
|
||||
// Runs on the machine being supported. Dials *out* to the hub and keeps a
|
||||
// control WebSocket open, so the machine never needs an inbound port or a
|
||||
// public address. When the hub asks for a session, the agent opens a second
|
||||
// WebSocket and pipes it to the VNC server listening on localhost.
|
||||
//
|
||||
// Deliberately dependency-free: Node 22 ships a global WebSocket, so this file
|
||||
// can be copied onto a machine and run with nothing but `node`.
|
||||
|
||||
const fs = require('fs');
|
||||
const net = require('net');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
const VERSION = '0.1.0';
|
||||
const DEFAULT_CONFIG = process.env.RCS_AGENT_CONFIG
|
||||
|| path.join(os.homedir(), '.rcs-agent.json');
|
||||
|
||||
const RECONNECT_MIN_MS = 2_000;
|
||||
const RECONNECT_MAX_MS = 60_000;
|
||||
|
||||
/* ----------------------------------------------------------------- utils */
|
||||
|
||||
function log(...args) {
|
||||
console.log(new Date().toISOString(), '[agent]', ...args);
|
||||
}
|
||||
|
||||
function readConfig(file = DEFAULT_CONFIG) {
|
||||
if (!fs.existsSync(file)) return null;
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
|
||||
function writeConfig(cfg, file = DEFAULT_CONFIG) {
|
||||
fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
|
||||
}
|
||||
|
||||
/** Accepts either a full enrolment URL or a bare token. */
|
||||
function parseInvite(input) {
|
||||
const raw = String(input || '').trim();
|
||||
if (!raw) throw new Error('an enrolment link or token is required');
|
||||
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
const url = new URL(raw);
|
||||
const token = url.pathname.split('/').filter(Boolean).pop();
|
||||
if (!token) throw new Error(`cannot find a token in ${raw}`);
|
||||
return { hub: `${url.protocol}//${url.host}`, token };
|
||||
}
|
||||
return { hub: null, token: raw };
|
||||
}
|
||||
|
||||
function wsBase(hub) {
|
||||
return hub.replace(/^http/i, 'ws').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { _: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a.startsWith('--')) {
|
||||
const key = a.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined || next.startsWith('--')) out[key] = true;
|
||||
else {
|
||||
out[key] = next;
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
out._.push(a);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- consent */
|
||||
|
||||
/**
|
||||
* Ask the person sitting at this machine whether to allow the session.
|
||||
* Falls back to denying if no dialog tool is available — failing closed is the
|
||||
* right default when the whole point of the flag is that a human must agree.
|
||||
*/
|
||||
function askConsent({ operator, role, timeoutMs = 40_000 }) {
|
||||
const message = `${operator} wants to ${role === 'viewer' ? 'view' : 'control'} this computer.\n\nAllow the connection?`;
|
||||
|
||||
const attempt = (cmd, args) => new Promise((resolve) => {
|
||||
const child = execFile(cmd, args, { timeout: timeoutMs }, (err) => resolve(!err));
|
||||
child.on('error', () => resolve(null)); // tool missing — try the next one
|
||||
});
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
return attempt('osascript', [
|
||||
'-e',
|
||||
`display dialog ${JSON.stringify(message)} with title "Remote Support" buttons {"Deny","Allow"} default button "Allow" giving up after ${Math.floor(timeoutMs / 1000)}`,
|
||||
'-e',
|
||||
'if button returned of result is not "Allow" then error number 1',
|
||||
]);
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const ps = `Add-Type -AssemblyName PresentationFramework;`
|
||||
+ `$r=[System.Windows.MessageBox]::Show(${JSON.stringify(message)},'Remote Support','YesNo','Question');`
|
||||
+ `if($r -ne 'Yes'){exit 1}`;
|
||||
return attempt('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps]);
|
||||
}
|
||||
|
||||
// Linux: try the common dialog helpers in turn.
|
||||
return (async () => {
|
||||
for (const [cmd, args] of [
|
||||
['zenity', ['--question', '--title=Remote Support', `--text=${message}`, `--timeout=${Math.floor(timeoutMs / 1000)}`]],
|
||||
['kdialog', ['--title', 'Remote Support', '--yesno', message]],
|
||||
]) {
|
||||
const result = await attempt(cmd, args);
|
||||
if (result !== null) return result;
|
||||
}
|
||||
log('consent required but no dialog tool (zenity/kdialog) is installed — denying');
|
||||
return false;
|
||||
})();
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- enroll */
|
||||
|
||||
async function cmdEnroll(args) {
|
||||
const { hub: linkHub, token } = parseInvite(args._[0]);
|
||||
const hub = (args.hub || linkHub || process.env.RCS_HUB || '').replace(/\/$/, '');
|
||||
if (!hub) throw new Error('cannot tell which hub to enrol with — pass a full link or --hub <url>');
|
||||
|
||||
const vncPort = Number(args['vnc-port'] || process.env.RCS_VNC_PORT || 5900);
|
||||
const vncHost = String(args['vnc-host'] || process.env.RCS_VNC_HOST || '127.0.0.1');
|
||||
|
||||
const res = await fetch(`${hub}/api/public/enroll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
hostname: os.hostname(),
|
||||
os: `${os.type()} ${os.release()} (${os.arch()})`,
|
||||
agentVersion: VERSION,
|
||||
vncPort,
|
||||
name: args.name || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error || `enrolment failed (HTTP ${res.status})`);
|
||||
|
||||
const cfg = {
|
||||
hub,
|
||||
clientId: body.clientId,
|
||||
agentKey: body.agentKey,
|
||||
name: body.name,
|
||||
vncHost,
|
||||
vncPort,
|
||||
autoAccept: !body.requireConsent,
|
||||
};
|
||||
const file = args.config || DEFAULT_CONFIG;
|
||||
writeConfig(cfg, file);
|
||||
|
||||
log(`enrolled as "${body.name}"`);
|
||||
log(`config written to ${file}`);
|
||||
log(`consent prompt: ${body.requireConsent ? 'on' : 'off'}`);
|
||||
log('start the agent with: node agent.js run');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ run */
|
||||
|
||||
class Agent {
|
||||
constructor(cfg) {
|
||||
this.cfg = cfg;
|
||||
this.backoff = RECONNECT_MIN_MS;
|
||||
this.ws = null;
|
||||
this.stopping = false;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.connect();
|
||||
process.on('SIGINT', () => this.stop());
|
||||
process.on('SIGTERM', () => this.stop());
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopping = true;
|
||||
if (this.ws) try { this.ws.close(); } catch { /* already gone */ }
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
connect() {
|
||||
const { hub, clientId, agentKey } = this.cfg;
|
||||
const url = `${wsBase(hub)}/ws/agent?clientId=${encodeURIComponent(clientId)}&key=${encodeURIComponent(agentKey)}`;
|
||||
log(`connecting to ${hub}`);
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
this.ws = ws;
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
this.backoff = RECONNECT_MIN_MS;
|
||||
log('connected');
|
||||
ws.send(JSON.stringify({
|
||||
type: 'hello',
|
||||
version: VERSION,
|
||||
os: `${os.type()} ${os.release()} (${os.arch()})`,
|
||||
hostname: os.hostname(),
|
||||
vncPort: this.cfg.vncPort,
|
||||
}));
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
this.onMessage(msg).catch((err) => log('message handler failed:', err.message));
|
||||
});
|
||||
|
||||
ws.addEventListener('close', (event) => {
|
||||
this.ws = null;
|
||||
if (this.stopping) return;
|
||||
// 4001/4003 mean the hub deliberately dropped us; still retry, more slowly.
|
||||
log(`disconnected (${event.code}${event.reason ? `: ${event.reason}` : ''}), retrying in ${Math.round(this.backoff / 1000)}s`);
|
||||
setTimeout(() => this.connect(), this.backoff);
|
||||
this.backoff = Math.min(this.backoff * 2, RECONNECT_MAX_MS);
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => { /* close always follows */ });
|
||||
}
|
||||
|
||||
async onMessage(msg) {
|
||||
switch (msg.type) {
|
||||
case 'welcome':
|
||||
log(`registered as "${msg.name}"${msg.requireConsent ? ' (consent required)' : ''}`);
|
||||
this.requireConsent = !!msg.requireConsent;
|
||||
break;
|
||||
|
||||
case 'open': {
|
||||
const needsConsent = msg.requireConsent && !this.cfg.autoAccept;
|
||||
if (needsConsent) {
|
||||
log(`${msg.operator} is requesting ${msg.role} access — prompting`);
|
||||
const allowed = await askConsent({ operator: msg.operator, role: msg.role });
|
||||
if (!allowed) {
|
||||
log('connection denied at the client');
|
||||
this.send({ type: 'denied', tunnelId: msg.tunnelId, reason: 'the person at that machine declined' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.openTunnel(msg);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'session-ended':
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
send(obj) {
|
||||
if (this.ws && this.ws.readyState === 1) this.ws.send(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
openTunnel(msg) {
|
||||
const { hub, clientId, agentKey, vncHost, vncPort } = this.cfg;
|
||||
const url = `${wsBase(hub)}/ws/tunnel?clientId=${encodeURIComponent(clientId)}`
|
||||
+ `&key=${encodeURIComponent(agentKey)}&tunnelId=${encodeURIComponent(msg.tunnelId)}`;
|
||||
|
||||
const socket = net.connect({ host: vncHost || '127.0.0.1', port: vncPort || 5900 });
|
||||
socket.setNoDelay(true);
|
||||
|
||||
const tunnel = new WebSocket(url);
|
||||
tunnel.binaryType = 'arraybuffer';
|
||||
|
||||
let closed = false;
|
||||
const shutdown = (why) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
log(`session ended (${why})`);
|
||||
try { socket.destroy(); } catch { /* already gone */ }
|
||||
try { tunnel.close(); } catch { /* already gone */ }
|
||||
};
|
||||
|
||||
socket.on('error', (err) => {
|
||||
log(`cannot reach the local VNC server at ${vncHost}:${vncPort} — ${err.code || err.message}`);
|
||||
this.send({ type: 'error', tunnelId: msg.tunnelId, message: `no VNC server on ${vncHost}:${vncPort}` });
|
||||
shutdown('local VNC error');
|
||||
});
|
||||
socket.on('close', () => shutdown('VNC server closed'));
|
||||
|
||||
tunnel.addEventListener('open', () => {
|
||||
log(`session started for ${msg.operator} (${msg.role})`);
|
||||
socket.on('data', (chunk) => {
|
||||
if (tunnel.readyState === 1) tunnel.send(chunk);
|
||||
});
|
||||
});
|
||||
|
||||
tunnel.addEventListener('message', (event) => {
|
||||
const data = typeof event.data === 'string' ? Buffer.from(event.data) : Buffer.from(event.data);
|
||||
socket.write(data);
|
||||
});
|
||||
|
||||
tunnel.addEventListener('close', () => shutdown('hub closed the tunnel'));
|
||||
tunnel.addEventListener('error', () => shutdown('tunnel error'));
|
||||
}
|
||||
}
|
||||
|
||||
function cmdRun(args) {
|
||||
const file = args.config || DEFAULT_CONFIG;
|
||||
const cfg = readConfig(file);
|
||||
if (!cfg) throw new Error(`no agent config at ${file} — run "node agent.js enroll <link>" first`);
|
||||
if (args['vnc-port']) cfg.vncPort = Number(args['vnc-port']);
|
||||
if (args['vnc-host']) cfg.vncHost = String(args['vnc-host']);
|
||||
log(`agent ${VERSION}, hub ${cfg.hub}, VNC ${cfg.vncHost || '127.0.0.1'}:${cfg.vncPort || 5900}`);
|
||||
new Agent(cfg).start();
|
||||
}
|
||||
|
||||
function cmdStatus(args) {
|
||||
const file = args.config || DEFAULT_CONFIG;
|
||||
const cfg = readConfig(file);
|
||||
if (!cfg) {
|
||||
console.log(`not enrolled (no config at ${file})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(JSON.stringify({ ...cfg, agentKey: '***' }, null, 2));
|
||||
}
|
||||
|
||||
const USAGE = `remote-control-support agent ${VERSION}
|
||||
|
||||
node agent.js enroll <link|token> [--hub URL] [--vnc-host H] [--vnc-port N] [--name NAME]
|
||||
node agent.js run [--vnc-host H] [--vnc-port N]
|
||||
node agent.js status
|
||||
|
||||
--config PATH agent config file (default ${DEFAULT_CONFIG})
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cmd = args._.shift();
|
||||
|
||||
try {
|
||||
if (cmd === 'enroll') await cmdEnroll(args);
|
||||
else if (cmd === 'run') cmdRun(args);
|
||||
else if (cmd === 'status') cmdStatus(args);
|
||||
else {
|
||||
console.log(USAGE);
|
||||
process.exitCode = cmd ? 1 : 0;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`error: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -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:
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+605
@@ -0,0 +1,605 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@novnc/novnc':
|
||||
specifier: ^1.6.0
|
||||
version: 1.7.0
|
||||
express:
|
||||
specifier: ^4.19.2
|
||||
version: 4.22.2
|
||||
ws:
|
||||
specifier: ^8.18.0
|
||||
version: 8.21.3
|
||||
|
||||
packages:
|
||||
|
||||
'@novnc/novnc@1.7.0':
|
||||
resolution: {integrity: sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==}
|
||||
|
||||
accepts@1.3.8:
|
||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
array-flatten@1.1.1:
|
||||
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
|
||||
|
||||
body-parser@1.20.6:
|
||||
resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==}
|
||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||
|
||||
bytes@3.1.2:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
call-bound@1.0.4:
|
||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
content-disposition@0.5.4:
|
||||
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
content-type@1.0.5:
|
||||
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
cookie-signature@1.0.7:
|
||||
resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
|
||||
|
||||
cookie@0.7.2:
|
||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
debug@2.6.9:
|
||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
depd@2.0.0:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
destroy@1.2.0:
|
||||
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
|
||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
encodeurl@2.0.0:
|
||||
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
es-define-property@1.0.1:
|
||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-errors@1.3.0:
|
||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-object-atoms@1.1.2:
|
||||
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
escape-html@1.0.3:
|
||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||
|
||||
etag@1.8.1:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
express@4.22.2:
|
||||
resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==}
|
||||
engines: {node: '>= 0.10.0'}
|
||||
|
||||
finalhandler@1.3.2:
|
||||
resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
forwarded@0.2.0:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
fresh@0.5.2:
|
||||
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
get-proto@1.0.1:
|
||||
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
has-symbols@1.1.0:
|
||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hasown@2.0.4:
|
||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
http-errors@2.0.1:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
media-typer@0.3.0:
|
||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
merge-descriptors@1.0.3:
|
||||
resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
|
||||
|
||||
methods@1.1.2:
|
||||
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-db@1.52.0:
|
||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@2.1.35:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime@1.6.0:
|
||||
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
ms@2.0.0:
|
||||
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
negotiator@0.6.3:
|
||||
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
on-finished@2.4.1:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
parseurl@1.3.3:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
path-to-regexp@0.1.13:
|
||||
resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
qs@6.15.3:
|
||||
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
range-parser@1.2.1:
|
||||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
raw-body@2.5.3:
|
||||
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
send@0.19.2:
|
||||
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
serve-static@1.16.3:
|
||||
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-map@1.0.1:
|
||||
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-weakmap@1.0.2:
|
||||
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel@1.1.1:
|
||||
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
toidentifier@1.0.1:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
type-is@1.6.18:
|
||||
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
unpipe@1.0.0:
|
||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
utils-merge@1.0.1:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
vary@1.1.2:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
ws@8.21.3:
|
||||
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@novnc/novnc@1.7.0': {}
|
||||
|
||||
accepts@1.3.8:
|
||||
dependencies:
|
||||
mime-types: 2.1.35
|
||||
negotiator: 0.6.3
|
||||
|
||||
array-flatten@1.1.1: {}
|
||||
|
||||
body-parser@1.20.6:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 1.0.5
|
||||
debug: 2.6.9
|
||||
depd: 2.0.0
|
||||
destroy: 1.2.0
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.4.24
|
||||
on-finished: 2.4.1
|
||||
qs: 6.15.3
|
||||
raw-body: 2.5.3
|
||||
type-is: 1.6.18
|
||||
unpipe: 1.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
bytes@3.1.2: {}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
call-bound@1.0.4:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
content-disposition@0.5.4:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
content-type@1.0.5: {}
|
||||
|
||||
cookie-signature@1.0.7: {}
|
||||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
debug@2.6.9:
|
||||
dependencies:
|
||||
ms: 2.0.0
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
destroy@1.2.0: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
encodeurl@2.0.0: {}
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
|
||||
es-object-atoms@1.1.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
escape-html@1.0.3: {}
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
express@4.22.2:
|
||||
dependencies:
|
||||
accepts: 1.3.8
|
||||
array-flatten: 1.1.1
|
||||
body-parser: 1.20.6
|
||||
content-disposition: 0.5.4
|
||||
content-type: 1.0.5
|
||||
cookie: 0.7.2
|
||||
cookie-signature: 1.0.7
|
||||
debug: 2.6.9
|
||||
depd: 2.0.0
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
finalhandler: 1.3.2
|
||||
fresh: 0.5.2
|
||||
http-errors: 2.0.1
|
||||
merge-descriptors: 1.0.3
|
||||
methods: 1.1.2
|
||||
on-finished: 2.4.1
|
||||
parseurl: 1.3.3
|
||||
path-to-regexp: 0.1.13
|
||||
proxy-addr: 2.0.7
|
||||
qs: 6.15.3
|
||||
range-parser: 1.2.1
|
||||
safe-buffer: 5.2.1
|
||||
send: 0.19.2
|
||||
serve-static: 1.16.3
|
||||
setprototypeof: 1.2.0
|
||||
statuses: 2.0.2
|
||||
type-is: 1.6.18
|
||||
utils-merge: 1.0.1
|
||||
vary: 1.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
finalhandler@1.3.2:
|
||||
dependencies:
|
||||
debug: 2.6.9
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
on-finished: 2.4.1
|
||||
parseurl: 1.3.3
|
||||
statuses: 2.0.2
|
||||
unpipe: 1.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
fresh@0.5.2: {}
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.2
|
||||
function-bind: 1.1.2
|
||||
get-proto: 1.0.1
|
||||
gopd: 1.2.0
|
||||
has-symbols: 1.1.0
|
||||
hasown: 2.0.4
|
||||
math-intrinsics: 1.1.0
|
||||
|
||||
get-proto@1.0.1:
|
||||
dependencies:
|
||||
dunder-proto: 1.0.1
|
||||
es-object-atoms: 1.1.2
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
hasown@2.0.4:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
http-errors@2.0.1:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
inherits: 2.0.4
|
||||
setprototypeof: 1.2.0
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
|
||||
merge-descriptors@1.0.3: {}
|
||||
|
||||
methods@1.1.2: {}
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
mime-types@2.1.35:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
mime@1.6.0: {}
|
||||
|
||||
ms@2.0.0: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
negotiator@0.6.3: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
on-finished@2.4.1:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
path-to-regexp@0.1.13: {}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
ipaddr.js: 1.9.1
|
||||
|
||||
qs@6.15.3:
|
||||
dependencies:
|
||||
es-define-property: 1.0.1
|
||||
side-channel: 1.1.1
|
||||
|
||||
range-parser@1.2.1: {}
|
||||
|
||||
raw-body@2.5.3:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.4.24
|
||||
unpipe: 1.0.0
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
send@0.19.2:
|
||||
dependencies:
|
||||
debug: 2.6.9
|
||||
depd: 2.0.0
|
||||
destroy: 1.2.0
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
fresh: 0.5.2
|
||||
http-errors: 2.0.1
|
||||
mime: 1.6.0
|
||||
ms: 2.1.3
|
||||
on-finished: 2.4.1
|
||||
range-parser: 1.2.1
|
||||
statuses: 2.0.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
serve-static@1.16.3:
|
||||
dependencies:
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
parseurl: 1.3.3
|
||||
send: 0.19.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
side-channel-map@1.0.1:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
side-channel-weakmap@1.0.2:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
side-channel-map: 1.0.1
|
||||
|
||||
side-channel@1.1.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
side-channel-list: 1.0.1
|
||||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
type-is@1.6.18:
|
||||
dependencies:
|
||||
media-typer: 0.3.0
|
||||
mime-types: 2.1.35
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
utils-merge@1.0.1: {}
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
ws@8.21.3: {}
|
||||
+718
@@ -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();
|
||||
@@ -0,0 +1,115 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Set up remote support</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="bg-field" id="bg-field" aria-hidden="true"></div>
|
||||
|
||||
<div class="center-shell">
|
||||
<div class="center-card">
|
||||
|
||||
<div class="card-header">
|
||||
<div class="brand-mark">RC</div>
|
||||
<div>
|
||||
<strong style="font-size:14px">Remote Support</strong>
|
||||
<span class="brand-sub">Machine enrollment</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<span class="card-kicker">One-time setup</span>
|
||||
<h1>Set up remote support</h1>
|
||||
|
||||
<div id="state-loading" class="faint">Checking this link…</div>
|
||||
|
||||
<div id="state-invalid" class="notice error hidden"></div>
|
||||
|
||||
<div id="state-ok" class="hidden">
|
||||
<p class="faint">
|
||||
This registers this computer so it can be supported remotely. It stays connected
|
||||
until you stop the agent.
|
||||
</p>
|
||||
|
||||
<ol class="steps">
|
||||
<li>
|
||||
<strong>Make sure a VNC server is running</strong> on this machine, listening on
|
||||
<span class="mono">127.0.0.1:5900</span>.
|
||||
<div class="faint" style="margin-top:6px">
|
||||
macOS: System Settings → General → Sharing → Screen Sharing.<br>
|
||||
Windows: install TightVNC or UltraVNC.<br>
|
||||
Linux: <span class="mono">x11vnc -localhost -rfbport 5900</span>.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Install <a href="https://nodejs.org" target="_blank" rel="noopener">Node.js 22+</a></strong> if it is not already there.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Run this</strong> in a terminal:
|
||||
<pre class="code" id="oneliner"></pre>
|
||||
<div class="row wrap" style="margin-top:10px">
|
||||
<button class="small" id="copy-unix">Copy for macOS / Linux</button>
|
||||
<button class="small" id="copy-win">Copy for Windows</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p class="login-foot">
|
||||
Link expires <span id="expiry"></span> · once enrolled, this machine appears in the operator console
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const token = location.pathname.split('/').filter(Boolean).pop();
|
||||
const origin = location.origin;
|
||||
const link = `${origin}/enroll/${token}`;
|
||||
|
||||
const unix = `curl -fsSL ${origin}/download/agent.js -o rcs-agent.js \\\n && node rcs-agent.js enroll ${link} \\\n && node rcs-agent.js run`;
|
||||
const win = `iwr ${origin}/download/agent.js -OutFile rcs-agent.js; `
|
||||
+ `node rcs-agent.js enroll ${link}; node rcs-agent.js run`;
|
||||
|
||||
function copy(text, button) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const old = button.textContent;
|
||||
button.textContent = 'Copied';
|
||||
setTimeout(() => { button.textContent = old; }, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('oneliner').textContent = unix;
|
||||
document.getElementById('copy-unix').onclick = (e) => copy(unix, e.currentTarget);
|
||||
document.getElementById('copy-win').onclick = (e) => copy(win, e.currentTarget);
|
||||
|
||||
fetch(`/api/public/invite/${encodeURIComponent(token)}`)
|
||||
.then((r) => r.json().then((body) => ({ ok: r.ok, body })))
|
||||
.then(({ ok, body }) => {
|
||||
document.getElementById('state-loading').classList.add('hidden');
|
||||
if (!ok || !body.usable || body.kind !== 'enroll') {
|
||||
const box = document.getElementById('state-invalid');
|
||||
box.textContent = body.error || `This enrollment link is ${body.reason || 'not valid'}.`;
|
||||
box.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
const expiry = document.getElementById('expiry');
|
||||
expiry.textContent = body.expiresAt ? new Date(body.expiresAt).toLocaleString() : 'never';
|
||||
document.getElementById('state-ok').classList.remove('hidden');
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('state-loading').textContent = 'Could not reach the server.';
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
import { mountParticles } from '/particles.js';
|
||||
mountParticles(document.getElementById('bg-field'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,165 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Remote Support</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ambient particle field: login screen full strength, console dimmed ------ -->
|
||||
<div class="bg-field" id="bg-field" aria-hidden="true"></div>
|
||||
|
||||
<!-- login ---------------------------------------------------------------- -->
|
||||
<div id="login" class="login-shell hidden">
|
||||
|
||||
<section class="login-aside">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">RC</div>
|
||||
<div>
|
||||
<h1>Remote Support</h1>
|
||||
<span class="brand-sub">Operator console</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="login-headline">Sit down at any desk<br>on the <em>network</em>.</h2>
|
||||
<p class="login-lede">
|
||||
Screen sharing and remote control for the machines you look after — running
|
||||
on your own hardware, on your own wire. Nothing leaves the building.
|
||||
</p>
|
||||
|
||||
<ul class="login-facts">
|
||||
<li>Self-hosted · no cloud relay</li>
|
||||
<li>Consent prompt on the remote desk</li>
|
||||
<li>Every session recorded in the audit log</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<form class="login-card" id="login-form">
|
||||
<div class="brand card-brand">
|
||||
<div class="brand-mark">RC</div>
|
||||
<div>
|
||||
<h1>Remote Support</h1>
|
||||
<span class="brand-sub">Operator console</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="form-title">Sign in</h2>
|
||||
<p class="faint form-note">Use your usual account.</p>
|
||||
|
||||
<div id="login-error" class="notice error hidden"></div>
|
||||
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" autocomplete="username" required autofocus>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
|
||||
<button class="primary" type="submit" id="login-button">Sign in</button>
|
||||
|
||||
<p class="login-foot">Sessions expire after one hour</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- app ------------------------------------------------------------------ -->
|
||||
<div id="app" class="hidden">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">RC</div>
|
||||
<div>
|
||||
<h1>Remote Support</h1>
|
||||
<span class="brand-sub">Operator console</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-divider"></div>
|
||||
|
||||
<nav class="tabs" id="tabs">
|
||||
<button data-tab="machines" class="active">Machines</button>
|
||||
<button data-tab="invites">Invites</button>
|
||||
<button data-tab="sessions">Sessions</button>
|
||||
<button data-tab="audit">Audit</button>
|
||||
</nav>
|
||||
|
||||
<div class="spacer"></div>
|
||||
<span class="faint" id="whoami"></span>
|
||||
<button class="ghost small" id="logout">Sign out</button>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- machines -->
|
||||
<section id="tab-machines">
|
||||
<div class="panel-head">
|
||||
<h2>Machines</h2>
|
||||
<span class="faint" id="machines-count"></span>
|
||||
<div class="spacer"></div>
|
||||
<button class="small" id="btn-enroll-invite">Invite a machine</button>
|
||||
<button class="primary small" id="btn-add-client">Add machine</button>
|
||||
</div>
|
||||
<div id="machines" class="grid"></div>
|
||||
</section>
|
||||
|
||||
<!-- invites -->
|
||||
<section id="tab-invites" class="hidden">
|
||||
<div class="panel-head">
|
||||
<h2>Invite links</h2>
|
||||
<div class="spacer"></div>
|
||||
<button class="small" id="btn-new-enroll">New enrollment link</button>
|
||||
<button class="primary small" id="btn-new-share">New support link</button>
|
||||
</div>
|
||||
<div id="invites"></div>
|
||||
</section>
|
||||
|
||||
<!-- sessions -->
|
||||
<section id="tab-sessions" class="hidden">
|
||||
<div class="panel-head">
|
||||
<h2>Live now</h2>
|
||||
<div class="spacer"></div>
|
||||
<button class="ghost small" id="btn-refresh-sessions">Refresh</button>
|
||||
</div>
|
||||
<div id="live-sessions"></div>
|
||||
|
||||
<div class="panel-head" style="margin-top:34px">
|
||||
<h2>History</h2>
|
||||
</div>
|
||||
<div id="session-history"></div>
|
||||
</section>
|
||||
|
||||
<!-- audit -->
|
||||
<section id="tab-audit" class="hidden">
|
||||
<div class="panel-head"><h2>Audit log</h2></div>
|
||||
<div id="audit"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="modal-root"></div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
|
||||
<!-- Particle field. Purely decorative; app.js is untouched by this. -->
|
||||
<script type="module">
|
||||
import { mountParticles } from '/particles.js';
|
||||
|
||||
const layer = document.getElementById('bg-field');
|
||||
const field = mountParticles(layer);
|
||||
|
||||
// app.js flips .hidden on #login / #app. Mirror that onto <body data-screen>
|
||||
// so the field can dim itself, and freeze the animation inside the console.
|
||||
const app = document.getElementById('app');
|
||||
const sync = () => {
|
||||
const inConsole = !app.classList.contains('hidden');
|
||||
document.body.dataset.screen = inConsole ? 'console' : 'login';
|
||||
field.setMode(inConsole ? 'static' : 'animate');
|
||||
};
|
||||
|
||||
new MutationObserver(sync).observe(app, { attributes: true, attributeFilter: ['class'] });
|
||||
sync();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,273 @@
|
||||
/* ============================================================================
|
||||
particles.js — ambient "network of machines" field.
|
||||
|
||||
Self-contained, no dependencies. Mounts a <canvas> into a host element and
|
||||
draws slow-drifting nodes with proximity links.
|
||||
|
||||
import { mountParticles } from '/particles.js';
|
||||
const field = mountParticles(document.getElementById('bg-field'));
|
||||
field.setMode('static'); // freeze: draws one frame, then zero cost
|
||||
field.destroy();
|
||||
|
||||
Budget rules baked in, because this runs on whatever desktop is on the desk:
|
||||
- node count is derived from viewport area and hard-capped
|
||||
- device pixel ratio is capped at 2
|
||||
- the loop is throttled to ~30fps and uses no shadows or gradients
|
||||
- rAF is cancelled outright when the tab is hidden or the mode is static
|
||||
- prefers-reduced-motion renders a single static frame and never loops
|
||||
========================================================================= */
|
||||
|
||||
'use strict';
|
||||
|
||||
const DEFAULTS = {
|
||||
/* one node per this many CSS pixels of area */
|
||||
areaPerNode: 26000,
|
||||
minNodes: 14,
|
||||
maxNodes: 78,
|
||||
/* proximity links */
|
||||
linkDistance: 138,
|
||||
linkAlpha: 0.20,
|
||||
/* nodes */
|
||||
nodeAlpha: 0.62,
|
||||
nodeSize: 2,
|
||||
hubEvery: 7, /* every Nth node is drawn as a larger "hub" */
|
||||
/* drift, CSS px per second */
|
||||
speed: 7,
|
||||
fps: 30,
|
||||
colors: ['#3ddc97', '#3ddc97', '#3ddc97', '#9aa2ff', '#e9a05c'],
|
||||
linkColor: '61, 220, 151',
|
||||
mode: 'animate',
|
||||
};
|
||||
|
||||
const NOOP_HANDLE = {
|
||||
setMode() {},
|
||||
destroy() {},
|
||||
canvas: null,
|
||||
};
|
||||
|
||||
export function mountParticles(host, options = {}) {
|
||||
if (!host || typeof document === 'undefined') return NOOP_HANDLE;
|
||||
|
||||
const cfg = { ...DEFAULTS, ...options };
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.setAttribute('aria-hidden', 'true');
|
||||
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
|
||||
if (!ctx) return NOOP_HANDLE;
|
||||
host.append(canvas);
|
||||
|
||||
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
let nodes = [];
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let raf = 0;
|
||||
let lastFrame = 0;
|
||||
let mode = cfg.mode;
|
||||
let destroyed = false;
|
||||
|
||||
/* ------------------------------------------------------------ geometry */
|
||||
|
||||
function targetCount() {
|
||||
const raw = Math.round((width * height) / cfg.areaPerNode);
|
||||
return Math.max(cfg.minNodes, Math.min(cfg.maxNodes, raw));
|
||||
}
|
||||
|
||||
function makeNode(index) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
return {
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height,
|
||||
vx: Math.cos(angle) * cfg.speed * (0.35 + Math.random() * 0.85),
|
||||
vy: Math.sin(angle) * cfg.speed * (0.35 + Math.random() * 0.85),
|
||||
color: cfg.colors[index % cfg.colors.length],
|
||||
hub: index % cfg.hubEvery === 0,
|
||||
/* per-node brightness keeps the field from looking like a lattice */
|
||||
alpha: cfg.nodeAlpha * (0.45 + Math.random() * 0.55),
|
||||
};
|
||||
}
|
||||
|
||||
function reconcileNodes() {
|
||||
const want = targetCount();
|
||||
while (nodes.length > want) nodes.pop();
|
||||
while (nodes.length < want) nodes.push(makeNode(nodes.length));
|
||||
for (const n of nodes) {
|
||||
if (n.x > width) n.x = Math.random() * width;
|
||||
if (n.y > height) n.y = Math.random() * height;
|
||||
}
|
||||
}
|
||||
|
||||
function resize() {
|
||||
if (destroyed) return;
|
||||
const w = host.clientWidth || window.innerWidth;
|
||||
const h = host.clientHeight || window.innerHeight;
|
||||
if (!w || !h) return;
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
width = w;
|
||||
height = h;
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
reconcileNodes();
|
||||
if (!running()) draw();
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- paint */
|
||||
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
/* links first, so nodes sit on top of them */
|
||||
const max = cfg.linkDistance;
|
||||
const maxSq = max * max;
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const a = nodes[i];
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const b = nodes[j];
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
const distSq = dx * dx + dy * dy;
|
||||
if (distSq > maxSq) continue;
|
||||
const strength = 1 - Math.sqrt(distSq) / max;
|
||||
ctx.strokeStyle = `rgba(${cfg.linkColor}, ${(strength * strength * cfg.linkAlpha).toFixed(3)})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/* nodes: small squares read as devices, not as bokeh */
|
||||
ctx.globalAlpha = 1;
|
||||
for (const n of nodes) {
|
||||
const s = n.hub ? cfg.nodeSize + 1.5 : cfg.nodeSize;
|
||||
ctx.fillStyle = withAlpha(n.color, n.alpha);
|
||||
ctx.fillRect(n.x - s / 2, n.y - s / 2, s, s);
|
||||
if (n.hub) {
|
||||
ctx.strokeStyle = withAlpha(n.color, n.alpha * 0.30);
|
||||
ctx.strokeRect(n.x - s * 1.6, n.y - s * 1.6, s * 3.2, s * 3.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function step(now) {
|
||||
raf = window.requestAnimationFrame(step);
|
||||
|
||||
const interval = 1000 / cfg.fps;
|
||||
const elapsed = now - lastFrame;
|
||||
if (elapsed < interval) return;
|
||||
/* keep the phase, but never integrate a huge dt after a stall */
|
||||
lastFrame = now - (elapsed % interval);
|
||||
const dt = Math.min(elapsed, 100) / 1000;
|
||||
|
||||
for (const n of nodes) {
|
||||
n.x += n.vx * dt;
|
||||
n.y += n.vy * dt;
|
||||
if (n.x < 0) { n.x = 0; n.vx = -n.vx; }
|
||||
else if (n.x > width) { n.x = width; n.vx = -n.vx; }
|
||||
if (n.y < 0) { n.y = 0; n.vy = -n.vy; }
|
||||
else if (n.y > height) { n.y = height; n.vy = -n.vy; }
|
||||
}
|
||||
|
||||
draw();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ lifecycle */
|
||||
|
||||
function running() { return raf !== 0; }
|
||||
|
||||
function shouldAnimate() {
|
||||
return !destroyed
|
||||
&& mode === 'animate'
|
||||
&& !document.hidden
|
||||
&& !motionQuery.matches;
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (raf) window.cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
|
||||
function sync() {
|
||||
if (shouldAnimate()) {
|
||||
if (!running()) {
|
||||
lastFrame = performance.now();
|
||||
raf = window.requestAnimationFrame(step);
|
||||
}
|
||||
return;
|
||||
}
|
||||
stop();
|
||||
if (!destroyed && mode !== 'off') draw();
|
||||
if (mode === 'off') ctx.clearRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- plumbing */
|
||||
|
||||
let resizeTimer = 0;
|
||||
const onResize = () => {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(resize, 140);
|
||||
};
|
||||
|
||||
const onVisibility = () => sync();
|
||||
|
||||
window.addEventListener('resize', onResize, { passive: true });
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
addMediaListener(motionQuery, sync);
|
||||
|
||||
let observer = null;
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
observer = new ResizeObserver(onResize);
|
||||
observer.observe(host);
|
||||
}
|
||||
|
||||
resize();
|
||||
sync();
|
||||
|
||||
return {
|
||||
canvas,
|
||||
/** 'animate' | 'static' (one frozen frame) | 'off' (blank) */
|
||||
setMode(next) {
|
||||
if (next === mode) return;
|
||||
mode = next;
|
||||
sync();
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
stop();
|
||||
window.clearTimeout(resizeTimer);
|
||||
window.removeEventListener('resize', onResize);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
removeMediaListener(motionQuery, sync);
|
||||
observer?.disconnect();
|
||||
canvas.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ utils */
|
||||
|
||||
function withAlpha(hex, alpha) {
|
||||
const h = hex.replace('#', '');
|
||||
const n = parseInt(h.length === 3 ? h.replace(/./g, (c) => c + c) : h, 16);
|
||||
const r = (n >> 16) & 255;
|
||||
const g = (n >> 8) & 255;
|
||||
const b = n & 255;
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha.toFixed(3)})`;
|
||||
}
|
||||
|
||||
/* Safari < 14 only has the deprecated listener API. */
|
||||
function addMediaListener(query, fn) {
|
||||
if (query.addEventListener) query.addEventListener('change', fn);
|
||||
else if (query.addListener) query.addListener(fn);
|
||||
}
|
||||
function removeMediaListener(query, fn) {
|
||||
if (query.removeEventListener) query.removeEventListener('change', fn);
|
||||
else if (query.removeListener) query.removeListener(fn);
|
||||
}
|
||||
|
||||
export default mountParticles;
|
||||
@@ -0,0 +1,106 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Join remote session</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="bg-field" id="bg-field" aria-hidden="true"></div>
|
||||
|
||||
<div class="center-shell">
|
||||
<div class="center-card" style="max-width:460px">
|
||||
|
||||
<div class="card-header">
|
||||
<div class="brand-mark">RC</div>
|
||||
<div>
|
||||
<strong style="font-size:14px">Remote Support</strong>
|
||||
<span class="brand-sub">Guest access</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<span class="card-kicker">Support link</span>
|
||||
<h1>Join a remote session</h1>
|
||||
|
||||
<div id="loading" class="faint">Checking this link…</div>
|
||||
<div id="invalid" class="notice error hidden"></div>
|
||||
|
||||
<form id="join" class="hidden">
|
||||
<p class="faint">
|
||||
You have been given <strong id="role-text"></strong> access to
|
||||
<strong id="client-name"></strong>.
|
||||
</p>
|
||||
|
||||
<div class="field">
|
||||
<label for="name">Your name</label>
|
||||
<input id="name" placeholder="so the session log knows who connected" autofocus>
|
||||
</div>
|
||||
|
||||
<button class="primary" type="submit" style="width:100%;padding:11px 14px;font-size:14px" id="join-button">Connect</button>
|
||||
|
||||
<p class="login-foot">Access expires <span id="expiry"></span></p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const token = location.pathname.split('/').filter(Boolean).pop();
|
||||
const el = (id) => document.getElementById(id);
|
||||
|
||||
fetch(`/api/public/invite/${encodeURIComponent(token)}`)
|
||||
.then((r) => r.json().then((body) => ({ ok: r.ok, body })))
|
||||
.then(({ ok, body }) => {
|
||||
el('loading').classList.add('hidden');
|
||||
if (!ok || !body.usable || body.kind !== 'session') {
|
||||
el('invalid').textContent = body.error || `This link is ${body.reason || 'not valid'}.`;
|
||||
el('invalid').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
el('role-text').textContent = body.role === 'operator' ? 'full control' : 'view only';
|
||||
el('client-name').textContent = body.clientName || 'a machine';
|
||||
el('expiry').textContent = body.expiresAt ? new Date(body.expiresAt).toLocaleString() : 'never';
|
||||
el('join').classList.remove('hidden');
|
||||
})
|
||||
.catch(() => { el('loading').textContent = 'Could not reach the server.'; });
|
||||
|
||||
el('join').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const button = el('join-button');
|
||||
button.disabled = true;
|
||||
button.textContent = 'Connecting…';
|
||||
try {
|
||||
const res = await fetch(`/api/public/session/${encodeURIComponent(token)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: el('name').value }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'could not start the session');
|
||||
|
||||
// Tickets are single-use and expire in seconds, so handing one to the
|
||||
// viewer in the URL is fine — the viewer strips it immediately.
|
||||
const url = new URL('/viewer', location.origin);
|
||||
url.searchParams.set('ticket', data.ticket);
|
||||
url.searchParams.set('name', data.clientName);
|
||||
url.searchParams.set('role', data.role);
|
||||
location.href = url.toString();
|
||||
} catch (err) {
|
||||
el('invalid').textContent = err.message;
|
||||
el('invalid').classList.remove('hidden');
|
||||
button.disabled = false;
|
||||
button.textContent = 'Connect';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
import { mountParticles } from '/particles.js';
|
||||
mountParticles(document.getElementById('bg-field'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+1021
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Remote session</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<!-- No particle field here: every frame belongs to the remote desktop. -->
|
||||
<body class="viewer-body">
|
||||
|
||||
<div class="viewer-bar">
|
||||
<span class="dot" id="conn-dot"></span>
|
||||
<strong id="client-name">Connecting…</strong>
|
||||
<span class="tag" id="role-tag" hidden></span>
|
||||
<span class="viewer-sep"></span>
|
||||
<span class="faint" id="conn-status"></span>
|
||||
<div class="spacer"></div>
|
||||
<button class="small" id="btn-cad" disabled title="Send Ctrl-Alt-Delete">Ctrl-Alt-Del</button>
|
||||
<button class="small" id="btn-clipboard" disabled title="Send clipboard text to the remote machine">Paste</button>
|
||||
<button class="small" id="btn-fit" disabled title="Toggle between fit-to-window and actual size">Fit</button>
|
||||
<button class="small" id="btn-fullscreen" disabled>Fullscreen</button>
|
||||
<span class="viewer-sep"></span>
|
||||
<button class="small danger" id="btn-disconnect" disabled>Disconnect</button>
|
||||
</div>
|
||||
|
||||
<div id="screen" style="position:relative">
|
||||
<div class="viewer-status" id="status-overlay">
|
||||
<div class="box">
|
||||
<h2 id="status-title">Connecting…</h2>
|
||||
<p class="faint" id="status-text" style="margin-bottom:0">Setting up the session.</p>
|
||||
<div id="status-actions" class="row" style="justify-content:center;margin-top:18px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/viewer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,159 @@
|
||||
import RFB from '/novnc/core/rfb.js';
|
||||
|
||||
// The viewer takes either a client id (mint a ticket via the API) or a ticket
|
||||
// that has already been minted for it — the support-link page does the latter,
|
||||
// since those visitors have no login at all.
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = localStorage.getItem('rcs.token');
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
const overlay = el('status-overlay');
|
||||
|
||||
let rfb = null;
|
||||
let scaled = true;
|
||||
|
||||
function status(title, text, actions = []) {
|
||||
el('status-title').textContent = title;
|
||||
el('status-text').textContent = text || '';
|
||||
const box = el('status-actions');
|
||||
box.innerHTML = '';
|
||||
for (const a of actions) {
|
||||
const b = document.createElement('button');
|
||||
b.textContent = a.label;
|
||||
b.className = a.primary ? 'primary' : '';
|
||||
b.addEventListener('click', a.onClick);
|
||||
box.append(b);
|
||||
}
|
||||
overlay.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideStatus() {
|
||||
overlay.classList.add('hidden');
|
||||
}
|
||||
|
||||
function setControls(enabled, { control = true } = {}) {
|
||||
for (const id of ['btn-fit', 'btn-fullscreen', 'btn-disconnect']) el(id).disabled = !enabled;
|
||||
for (const id of ['btn-cad', 'btn-clipboard']) el(id).disabled = !enabled || !control;
|
||||
}
|
||||
|
||||
async function mintTicket() {
|
||||
const clientId = params.get('client');
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ clientId, viewOnly: params.get('viewOnly') === '1' }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `could not start a session (${res.status})`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function connect(session) {
|
||||
el('client-name').textContent = session.clientName || 'Remote machine';
|
||||
document.title = `${session.clientName || 'Remote'} — session`;
|
||||
|
||||
const viewOnly = session.role === 'viewer';
|
||||
const roleTag = el('role-tag');
|
||||
roleTag.textContent = viewOnly ? 'view only' : 'full control';
|
||||
roleTag.className = viewOnly ? 'tag' : 'tag role';
|
||||
roleTag.hidden = false;
|
||||
|
||||
if (session.requireConsent) {
|
||||
status('Waiting for permission', 'Someone at that machine has to allow the connection.');
|
||||
} else {
|
||||
status('Connecting…', 'Negotiating with the remote desktop.');
|
||||
}
|
||||
|
||||
const url = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/vnc?ticket=${encodeURIComponent(session.ticket)}`;
|
||||
|
||||
rfb = new RFB(el('screen'), url);
|
||||
rfb.viewOnly = viewOnly; // the hub enforces this too; this just avoids noise
|
||||
rfb.scaleViewport = true;
|
||||
rfb.resizeSession = false;
|
||||
rfb.background = '#05070a';
|
||||
rfb.clipViewport = false;
|
||||
rfb.focusOnClick = true;
|
||||
|
||||
rfb.addEventListener('connect', () => {
|
||||
hideStatus();
|
||||
el('conn-dot').className = 'dot online';
|
||||
el('conn-status').textContent = 'connected';
|
||||
setControls(true, { control: !viewOnly });
|
||||
});
|
||||
|
||||
rfb.addEventListener('disconnect', (e) => {
|
||||
el('conn-dot').className = 'dot offline';
|
||||
el('conn-status').textContent = 'disconnected';
|
||||
setControls(false);
|
||||
const reason = e.detail?.reason || (e.detail?.clean ? 'The session ended.' : 'The connection dropped.');
|
||||
status(e.detail?.clean ? 'Session ended' : 'Disconnected', reason, [
|
||||
{ label: 'Reconnect', primary: true, onClick: () => location.reload() },
|
||||
{ label: 'Close', onClick: () => window.close() },
|
||||
]);
|
||||
});
|
||||
|
||||
rfb.addEventListener('credentialsrequired', () => {
|
||||
// The hub authenticates upstream, so the browser should never be asked.
|
||||
status('Unexpected password prompt', 'The hub could not complete VNC authentication for this machine.');
|
||||
rfb.disconnect();
|
||||
});
|
||||
|
||||
rfb.addEventListener('securityfailure', (e) => {
|
||||
status('Rejected', e.detail?.reason || 'The remote machine refused the connection.');
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- toolbar */
|
||||
|
||||
el('btn-cad').addEventListener('click', () => rfb?.sendCtrlAltDel());
|
||||
|
||||
el('btn-clipboard').addEventListener('click', async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) rfb?.clipboardPasteFrom(text);
|
||||
} catch {
|
||||
const text = prompt('Text to send to the remote machine:');
|
||||
if (text) rfb?.clipboardPasteFrom(text);
|
||||
}
|
||||
});
|
||||
|
||||
el('btn-fit').addEventListener('click', (e) => {
|
||||
scaled = !scaled;
|
||||
if (rfb) rfb.scaleViewport = scaled;
|
||||
e.currentTarget.textContent = scaled ? 'Fit' : '1:1';
|
||||
});
|
||||
|
||||
el('btn-fullscreen').addEventListener('click', () => {
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else document.documentElement.requestFullscreen();
|
||||
});
|
||||
|
||||
el('btn-disconnect').addEventListener('click', () => rfb?.disconnect());
|
||||
|
||||
/* ---------------------------------------------------------------- boot */
|
||||
|
||||
(async function start() {
|
||||
try {
|
||||
if (params.get('ticket')) {
|
||||
connect({
|
||||
ticket: params.get('ticket'),
|
||||
clientName: params.get('name') || 'Remote machine',
|
||||
role: params.get('role') || 'viewer',
|
||||
requireConsent: params.get('consent') === '1',
|
||||
});
|
||||
// The ticket is single-use; drop it from the address bar and history.
|
||||
history.replaceState(null, '', location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params.get('client')) throw new Error('nothing to connect to');
|
||||
if (!token) throw new Error('you are not signed in');
|
||||
|
||||
connect(await mintTicket());
|
||||
} catch (err) {
|
||||
status('Cannot start', err.message, [
|
||||
{ label: 'Back to machines', primary: true, onClick: () => { location.href = '/'; } },
|
||||
]);
|
||||
}
|
||||
})();
|
||||
+138
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,81 @@
|
||||
'use strict';
|
||||
|
||||
// Secrets at rest: VNC passwords and agent keys are AES-256-GCM encrypted.
|
||||
// Tokens that we only ever need to *compare* (invite tokens, agent keys as
|
||||
// presented by a client) are stored as SHA-256 and checked in constant time.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const config = require('./config');
|
||||
|
||||
const KEY_FILE = path.join(path.dirname(config.dbPath), 'encryption.key');
|
||||
|
||||
let keyCache = null;
|
||||
|
||||
// A stable 32-byte key. Prefer ENCRYPTION_KEY from the environment; otherwise
|
||||
// generate one next to the database so a bare `npm start` still works and stays
|
||||
// decryptable across restarts.
|
||||
function key() {
|
||||
if (keyCache) return keyCache;
|
||||
|
||||
let material = config.encryptionKey;
|
||||
if (!material) {
|
||||
fs.mkdirSync(path.dirname(KEY_FILE), { recursive: true });
|
||||
if (fs.existsSync(KEY_FILE)) {
|
||||
material = fs.readFileSync(KEY_FILE, 'utf8').trim();
|
||||
} else {
|
||||
material = crypto.randomBytes(32).toString('hex');
|
||||
fs.writeFileSync(KEY_FILE, material, { mode: 0o600 });
|
||||
console.warn(`[crypto] ENCRYPTION_KEY not set — generated one at ${KEY_FILE}. Back it up.`);
|
||||
}
|
||||
}
|
||||
|
||||
keyCache = crypto.createHash('sha256').update(material, 'utf8').digest();
|
||||
return keyCache;
|
||||
}
|
||||
|
||||
function encrypt(plaintext) {
|
||||
if (plaintext === null || plaintext === undefined || plaintext === '') return null;
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
|
||||
const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return `v1.${iv.toString('base64')}.${tag.toString('base64')}.${ct.toString('base64')}`;
|
||||
}
|
||||
|
||||
function decrypt(blob) {
|
||||
if (!blob) return null;
|
||||
const parts = String(blob).split('.');
|
||||
if (parts.length !== 4 || parts[0] !== 'v1') return null;
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key(), Buffer.from(parts[1], 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(parts[2], 'base64'));
|
||||
return Buffer.concat([decipher.update(Buffer.from(parts[3], 'base64')), decipher.final()]).toString('utf8');
|
||||
} catch {
|
||||
// Wrong key or tampered ciphertext — treat as absent rather than crashing a session.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// URL-safe random token, 32 bytes of entropy.
|
||||
function randomToken(bytes = 32) {
|
||||
return crypto.randomBytes(bytes).toString('base64url');
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return crypto.createHash('sha256').update(String(value), 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function timingSafeEqualHex(a, b) {
|
||||
const ba = Buffer.from(String(a || ''), 'hex');
|
||||
const bb = Buffer.from(String(b || ''), 'hex');
|
||||
if (ba.length !== bb.length || ba.length === 0) return false;
|
||||
return crypto.timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
function uuid() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
module.exports = { encrypt, decrypt, randomToken, sha256, timingSafeEqualHex, uuid };
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
'use strict';
|
||||
|
||||
// Persistence. Uses Node's built-in SQLite so the app has zero native build
|
||||
// dependencies — important because this ships as a container to unraid.
|
||||
// Positional (?) parameters only: named-parameter binding differs between
|
||||
// node:sqlite releases.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const config = require('./config');
|
||||
|
||||
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
|
||||
|
||||
const db = new DatabaseSync(config.dbPath);
|
||||
|
||||
db.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA busy_timeout = 5000;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
mode TEXT NOT NULL DEFAULT 'direct', -- 'direct' | 'agent'
|
||||
host TEXT,
|
||||
port INTEGER NOT NULL DEFAULT 5900,
|
||||
vnc_password_enc TEXT,
|
||||
agent_key_hash TEXT,
|
||||
require_consent INTEGER NOT NULL DEFAULT 0,
|
||||
tags TEXT NOT NULL DEFAULT '',
|
||||
os TEXT,
|
||||
hostname TEXT,
|
||||
agent_version TEXT,
|
||||
last_seen_at INTEGER,
|
||||
last_ip TEXT,
|
||||
enrolled_at INTEGER,
|
||||
created_by TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_name ON clients(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
username TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'viewer', -- 'viewer' | 'operator'
|
||||
expires_at INTEGER,
|
||||
created_by TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE (client_id, username)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_grants_username ON grants(username);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invites (
|
||||
id TEXT PRIMARY KEY,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
kind TEXT NOT NULL, -- 'enroll' | 'session'
|
||||
client_id TEXT REFERENCES clients(id) ON DELETE CASCADE,
|
||||
label TEXT,
|
||||
role TEXT, -- session invites
|
||||
prefill TEXT, -- enroll invites, JSON
|
||||
max_uses INTEGER NOT NULL DEFAULT 1,
|
||||
uses INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at INTEGER,
|
||||
revoked_at INTEGER,
|
||||
last_used_at INTEGER,
|
||||
created_by TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_invites_client ON invites(client_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
client_id TEXT NOT NULL,
|
||||
client_name TEXT,
|
||||
username TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'web', -- 'web' | 'invite'
|
||||
invite_id TEXT,
|
||||
role TEXT NOT NULL,
|
||||
remote_ip TEXT,
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER,
|
||||
bytes_in INTEGER NOT NULL DEFAULT 0,
|
||||
bytes_out INTEGER NOT NULL DEFAULT 0,
|
||||
end_reason TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_client ON sessions(client_id, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
username TEXT,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT,
|
||||
detail TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(ts DESC);
|
||||
`);
|
||||
|
||||
const now = () => Date.now();
|
||||
|
||||
/* ---------------------------------------------------------------- clients */
|
||||
|
||||
const clients = {
|
||||
list() {
|
||||
return db.prepare('SELECT * FROM clients ORDER BY name COLLATE NOCASE').all();
|
||||
},
|
||||
|
||||
listForUser(username) {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT c.*, g.role AS granted_role, g.expires_at AS grant_expires_at
|
||||
FROM clients c
|
||||
JOIN grants g ON g.client_id = c.id
|
||||
WHERE g.username = ?
|
||||
AND (g.expires_at IS NULL OR g.expires_at > ?)
|
||||
ORDER BY c.name COLLATE NOCASE`
|
||||
)
|
||||
.all(username, now());
|
||||
},
|
||||
|
||||
get(id) {
|
||||
return db.prepare('SELECT * FROM clients WHERE id = ?').get(id);
|
||||
},
|
||||
|
||||
getByName(name) {
|
||||
return db.prepare('SELECT * FROM clients WHERE name = ? COLLATE NOCASE').get(name);
|
||||
},
|
||||
|
||||
create(c) {
|
||||
const ts = now();
|
||||
db.prepare(
|
||||
`INSERT INTO clients
|
||||
(id, name, description, mode, host, port, vnc_password_enc, agent_key_hash,
|
||||
require_consent, tags, os, hostname, agent_version, enrolled_at,
|
||||
created_by, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||
).run(
|
||||
c.id, c.name, c.description ?? null, c.mode, c.host ?? null, c.port ?? 5900,
|
||||
c.vnc_password_enc ?? null, c.agent_key_hash ?? null,
|
||||
c.require_consent ? 1 : 0, c.tags ?? '', c.os ?? null, c.hostname ?? null,
|
||||
c.agent_version ?? null, c.enrolled_at ?? null, c.created_by ?? null, ts, ts
|
||||
);
|
||||
return clients.get(c.id);
|
||||
},
|
||||
|
||||
update(id, fields) {
|
||||
const allowed = [
|
||||
'name', 'description', 'mode', 'host', 'port', 'vnc_password_enc', 'agent_key_hash',
|
||||
'require_consent', 'tags', 'os', 'hostname', 'agent_version', 'last_seen_at',
|
||||
'last_ip', 'enrolled_at',
|
||||
];
|
||||
const keys = Object.keys(fields).filter((k) => allowed.includes(k));
|
||||
if (!keys.length) return clients.get(id);
|
||||
const sql = `UPDATE clients SET ${keys.map((k) => `${k} = ?`).join(', ')}, updated_at = ? WHERE id = ?`;
|
||||
db.prepare(sql).run(...keys.map((k) => fields[k]), now(), id);
|
||||
return clients.get(id);
|
||||
},
|
||||
|
||||
touch(id, ip) {
|
||||
db.prepare('UPDATE clients SET last_seen_at = ?, last_ip = ? WHERE id = ?').run(now(), ip ?? null, id);
|
||||
},
|
||||
|
||||
remove(id) {
|
||||
db.prepare('DELETE FROM clients WHERE id = ?').run(id);
|
||||
},
|
||||
};
|
||||
|
||||
/* ----------------------------------------------------------------- grants */
|
||||
|
||||
const grants = {
|
||||
listForClient(clientId) {
|
||||
return db.prepare('SELECT * FROM grants WHERE client_id = ? ORDER BY username').all(clientId);
|
||||
},
|
||||
|
||||
listForUser(username) {
|
||||
return db.prepare('SELECT * FROM grants WHERE username = ?').all(username);
|
||||
},
|
||||
|
||||
find(clientId, username) {
|
||||
return db
|
||||
.prepare('SELECT * FROM grants WHERE client_id = ? AND username = ? COLLATE NOCASE')
|
||||
.get(clientId, username);
|
||||
},
|
||||
|
||||
upsert(g) {
|
||||
db.prepare(
|
||||
`INSERT INTO grants (id, client_id, username, role, expires_at, created_by, created_at)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
ON CONFLICT(client_id, username)
|
||||
DO UPDATE SET role = excluded.role, expires_at = excluded.expires_at`
|
||||
).run(g.id, g.client_id, g.username.toLowerCase(), g.role, g.expires_at ?? null, g.created_by ?? null, now());
|
||||
return grants.find(g.client_id, g.username);
|
||||
},
|
||||
|
||||
remove(clientId, username) {
|
||||
db.prepare('DELETE FROM grants WHERE client_id = ? AND username = ? COLLATE NOCASE')
|
||||
.run(clientId, username);
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- invites */
|
||||
|
||||
const invites = {
|
||||
list() {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT i.*, c.name AS client_name
|
||||
FROM invites i LEFT JOIN clients c ON c.id = i.client_id
|
||||
ORDER BY i.created_at DESC`
|
||||
)
|
||||
.all();
|
||||
},
|
||||
|
||||
get(id) {
|
||||
return db.prepare('SELECT * FROM invites WHERE id = ?').get(id);
|
||||
},
|
||||
|
||||
findByHash(hash) {
|
||||
return db.prepare('SELECT * FROM invites WHERE token_hash = ?').get(hash);
|
||||
},
|
||||
|
||||
create(i) {
|
||||
db.prepare(
|
||||
`INSERT INTO invites
|
||||
(id, token_hash, kind, client_id, label, role, prefill, max_uses, uses, expires_at, created_by, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,0,?,?,?)`
|
||||
).run(
|
||||
i.id, i.token_hash, i.kind, i.client_id ?? null, i.label ?? null, i.role ?? null,
|
||||
i.prefill ?? null, i.max_uses ?? 1, i.expires_at ?? null, i.created_by ?? null, now()
|
||||
);
|
||||
return invites.get(i.id);
|
||||
},
|
||||
|
||||
consume(id) {
|
||||
db.prepare('UPDATE invites SET uses = uses + 1, last_used_at = ? WHERE id = ?').run(now(), id);
|
||||
},
|
||||
|
||||
revoke(id) {
|
||||
db.prepare('UPDATE invites SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL').run(now(), id);
|
||||
},
|
||||
|
||||
remove(id) {
|
||||
db.prepare('DELETE FROM invites WHERE id = ?').run(id);
|
||||
},
|
||||
};
|
||||
|
||||
// An invite is usable when it is not revoked, not expired, and has uses left.
|
||||
function inviteUsable(inv) {
|
||||
if (!inv) return { ok: false, reason: 'not found' };
|
||||
if (inv.revoked_at) return { ok: false, reason: 'revoked' };
|
||||
if (inv.expires_at && inv.expires_at < now()) return { ok: false, reason: 'expired' };
|
||||
if (inv.max_uses > 0 && inv.uses >= inv.max_uses) return { ok: false, reason: 'already used' };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- sessions */
|
||||
|
||||
const sessions = {
|
||||
start(s) {
|
||||
db.prepare(
|
||||
`INSERT INTO sessions
|
||||
(id, client_id, client_name, username, source, invite_id, role, remote_ip, started_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)`
|
||||
).run(
|
||||
s.id, s.client_id, s.client_name ?? null, s.username, s.source ?? 'web',
|
||||
s.invite_id ?? null, s.role, s.remote_ip ?? null, now()
|
||||
);
|
||||
},
|
||||
|
||||
end(id, { bytesIn = 0, bytesOut = 0, reason = 'closed' } = {}) {
|
||||
db.prepare(
|
||||
`UPDATE sessions SET ended_at = ?, bytes_in = ?, bytes_out = ?, end_reason = ?
|
||||
WHERE id = ? AND ended_at IS NULL`
|
||||
).run(now(), bytesIn, bytesOut, reason, id);
|
||||
},
|
||||
|
||||
// Anything still marked open at boot was killed by a restart, not by a user.
|
||||
closeOrphans() {
|
||||
const r = db
|
||||
.prepare(`UPDATE sessions SET ended_at = ?, end_reason = 'server restart' WHERE ended_at IS NULL`)
|
||||
.run(now());
|
||||
return r.changes;
|
||||
},
|
||||
|
||||
recent(limit = 100, clientId = null) {
|
||||
return clientId
|
||||
? db.prepare('SELECT * FROM sessions WHERE client_id = ? ORDER BY started_at DESC LIMIT ?').all(clientId, limit)
|
||||
: db.prepare('SELECT * FROM sessions ORDER BY started_at DESC LIMIT ?').all(limit);
|
||||
},
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ audit */
|
||||
|
||||
function audit(username, action, target, detail) {
|
||||
db.prepare('INSERT INTO audit (ts, username, action, target, detail) VALUES (?,?,?,?,?)').run(
|
||||
now(), username ?? null, action, target ?? null,
|
||||
detail === undefined || detail === null ? null : typeof detail === 'string' ? detail : JSON.stringify(detail)
|
||||
);
|
||||
}
|
||||
|
||||
audit.recent = (limit = 200) =>
|
||||
db.prepare('SELECT * FROM audit ORDER BY ts DESC LIMIT ?').all(limit);
|
||||
|
||||
module.exports = { db, clients, grants, invites, inviteUsable, sessions, audit };
|
||||
+195
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
@@ -0,0 +1,274 @@
|
||||
'use strict';
|
||||
|
||||
// RFB protocol handling for the hub.
|
||||
//
|
||||
// The hub is a man-in-the-middle by design: it completes the RFB handshake with
|
||||
// the real VNC server itself (including VNC Authentication using a password the
|
||||
// browser never receives), and separately presents a "no authentication needed"
|
||||
// handshake to the browser. Once both sides are past ServerInit the two streams
|
||||
// are spliced together.
|
||||
//
|
||||
// That MITM position is also what makes view-only enforceable: the client->server
|
||||
// direction is parsed and input-bearing messages are dropped, so a viewer cannot
|
||||
// send keystrokes no matter what its browser does.
|
||||
|
||||
const { vncAuthResponse } = require('./des');
|
||||
|
||||
const SEC_NONE = 1;
|
||||
const SEC_VNC_AUTH = 2;
|
||||
|
||||
/**
|
||||
* Reads exact byte counts off a Readable without putting it into flowing mode,
|
||||
* so anything we do not consume stays in the stream's internal buffer and is
|
||||
* picked up by the later pipe().
|
||||
*/
|
||||
class ByteReader {
|
||||
constructor(stream, timeoutMs = 20_000) {
|
||||
this.stream = stream;
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
read(n) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const attempt = () => {
|
||||
const buf = this.stream.read(n);
|
||||
if (buf) {
|
||||
cleanup();
|
||||
resolve(buf);
|
||||
}
|
||||
};
|
||||
const onEnd = () => {
|
||||
cleanup();
|
||||
reject(new Error('connection closed during RFB handshake'));
|
||||
};
|
||||
const onError = (err) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('timed out during RFB handshake'));
|
||||
}, this.timeoutMs);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
this.stream.off('readable', attempt);
|
||||
this.stream.off('end', onEnd);
|
||||
this.stream.off('close', onEnd);
|
||||
this.stream.off('error', onError);
|
||||
};
|
||||
|
||||
this.stream.on('readable', attempt);
|
||||
this.stream.on('end', onEnd);
|
||||
this.stream.on('close', onEnd);
|
||||
this.stream.on('error', onError);
|
||||
attempt();
|
||||
});
|
||||
}
|
||||
|
||||
async readU8() {
|
||||
return (await this.read(1))[0];
|
||||
}
|
||||
|
||||
async readU32() {
|
||||
return (await this.read(4)).readUInt32BE(0);
|
||||
}
|
||||
|
||||
// RFB failure reasons are a u32 length followed by that many bytes of text.
|
||||
async readReason() {
|
||||
const len = await this.readU32();
|
||||
if (!len) return '';
|
||||
return (await this.read(Math.min(len, 4096))).toString('utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function parseVersion(buf) {
|
||||
const text = buf.toString('ascii');
|
||||
const m = /^RFB (\d{3})\.(\d{3})\n$/.exec(text);
|
||||
if (!m) throw new Error(`not a VNC server (got ${JSON.stringify(text)})`);
|
||||
return { major: Number(m[1]), minor: Number(m[2]) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Act as a VNC *client* toward the real server: version negotiation, security
|
||||
* negotiation, VNC Authentication if required. Returns once the server is ready
|
||||
* for ClientInit, which the browser will supply.
|
||||
*/
|
||||
async function handshakeWithServer(stream, password, timeoutMs = 20_000) {
|
||||
const r = new ByteReader(stream, timeoutMs);
|
||||
|
||||
const { major, minor: rawMinor } = parseVersion(await r.read(12));
|
||||
// Apple advertises 003.889; anything above 3.8 is negotiated down to 3.8.
|
||||
const minor = rawMinor > 8 ? 8 : rawMinor;
|
||||
const negotiated = minor >= 8 ? 8 : minor >= 7 ? 7 : 3;
|
||||
stream.write(Buffer.from(`RFB 003.00${negotiated}\n`, 'ascii'));
|
||||
|
||||
let secType;
|
||||
if (negotiated >= 7) {
|
||||
const count = await r.readU8();
|
||||
if (count === 0) throw new Error(`server refused connection: ${await r.readReason()}`);
|
||||
const types = Array.from(await r.read(count));
|
||||
|
||||
if (password && types.includes(SEC_VNC_AUTH)) secType = SEC_VNC_AUTH;
|
||||
else if (types.includes(SEC_NONE)) secType = SEC_NONE;
|
||||
else if (types.includes(SEC_VNC_AUTH)) {
|
||||
throw new Error('VNC server requires a password but none is stored for this client');
|
||||
} else {
|
||||
throw new Error(`no supported VNC security type (server offered ${types.join(', ')})`);
|
||||
}
|
||||
stream.write(Buffer.from([secType]));
|
||||
} else {
|
||||
secType = await r.readU32();
|
||||
if (secType === 0) throw new Error(`server refused connection: ${await r.readReason()}`);
|
||||
if (secType === SEC_VNC_AUTH && !password) {
|
||||
throw new Error('VNC server requires a password but none is stored for this client');
|
||||
}
|
||||
}
|
||||
|
||||
if (secType === SEC_VNC_AUTH) {
|
||||
const challenge = await r.read(16);
|
||||
stream.write(vncAuthResponse(challenge, password));
|
||||
} else if (secType !== SEC_NONE) {
|
||||
throw new Error(`unsupported VNC security type ${secType}`);
|
||||
}
|
||||
|
||||
// 3.8 always sends SecurityResult; earlier versions only send it for real auth.
|
||||
if (negotiated >= 8 || secType !== SEC_NONE) {
|
||||
const result = await r.readU32();
|
||||
if (result !== 0) {
|
||||
const reason = negotiated >= 8 ? await r.readReason().catch(() => '') : '';
|
||||
throw new Error(reason || 'VNC authentication failed (wrong password?)');
|
||||
}
|
||||
}
|
||||
|
||||
return { version: `${major}.${rawMinor}`, securityType: secType };
|
||||
}
|
||||
|
||||
/**
|
||||
* Act as a VNC *server* toward the browser, offering "None" security. By the
|
||||
* time this runs the hub has already authenticated upstream, so the browser is
|
||||
* handed an already-authorised stream and never sees the real password.
|
||||
*/
|
||||
async function handshakeWithBrowser(stream, timeoutMs = 20_000) {
|
||||
const r = new ByteReader(stream, timeoutMs);
|
||||
|
||||
stream.write(Buffer.from('RFB 003.008\n', 'ascii'));
|
||||
const { minor } = parseVersion(await r.read(12));
|
||||
|
||||
if (minor >= 7) {
|
||||
stream.write(Buffer.from([1, SEC_NONE]));
|
||||
const chosen = await r.readU8();
|
||||
if (chosen !== SEC_NONE) {
|
||||
const reason = Buffer.from('unsupported security type', 'utf8');
|
||||
const buf = Buffer.alloc(8 + reason.length);
|
||||
buf.writeUInt32BE(1, 0);
|
||||
buf.writeUInt32BE(reason.length, 4);
|
||||
reason.copy(buf, 8);
|
||||
stream.write(buf);
|
||||
throw new Error('browser chose an unsupported security type');
|
||||
}
|
||||
// SecurityResult: OK
|
||||
const ok = Buffer.alloc(4);
|
||||
ok.writeUInt32BE(0, 0);
|
||||
stream.write(ok);
|
||||
} else {
|
||||
// RFB 3.3: the server dictates the security type and sends no SecurityResult.
|
||||
const buf = Buffer.alloc(4);
|
||||
buf.writeUInt32BE(SEC_NONE, 0);
|
||||
stream.write(buf);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* View-only enforcement */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
// Client-to-server messages that cannot change anything on the remote machine.
|
||||
// Everything else is dropped for viewers — notably KeyEvent, PointerEvent,
|
||||
// ClientCutText (paste), SetDesktopSize and xvp (which can power off a host).
|
||||
const PASSIVE_MESSAGES = new Set([
|
||||
0, // SetPixelFormat
|
||||
2, // SetEncodings
|
||||
3, // FramebufferUpdateRequest
|
||||
150, // EnableContinuousUpdates
|
||||
248, // ClientFence
|
||||
]);
|
||||
|
||||
/**
|
||||
* Length of the client->server message starting at offset 0 of `buf`.
|
||||
* Returns 0 when more bytes are needed, -1 when the type is unknown (which means
|
||||
* we can no longer track message boundaries and must drop the connection).
|
||||
*/
|
||||
function clientMessageLength(buf) {
|
||||
const type = buf[0];
|
||||
switch (type) {
|
||||
case 0: return 20; // SetPixelFormat
|
||||
case 2: // SetEncodings
|
||||
if (buf.length < 4) return 0;
|
||||
return 4 + 4 * buf.readUInt16BE(2);
|
||||
case 3: return 10; // FramebufferUpdateRequest
|
||||
case 4: return 8; // KeyEvent
|
||||
case 5: return 6; // PointerEvent
|
||||
case 6: // ClientCutText
|
||||
if (buf.length < 8) return 0;
|
||||
// A negative length marks the extended clipboard extension.
|
||||
return 8 + Math.abs(buf.readInt32BE(4));
|
||||
case 150: return 10; // EnableContinuousUpdates
|
||||
case 248: // ClientFence
|
||||
if (buf.length < 9) return 0;
|
||||
return 9 + buf[8];
|
||||
case 250: return 4; // xvp (shutdown/reboot/reset)
|
||||
case 251: // SetDesktopSize
|
||||
if (buf.length < 8) return 0;
|
||||
return 8 + 16 * buf[6];
|
||||
case 255: // QEMU client message
|
||||
if (buf.length < 2) return 0;
|
||||
if (buf[1] === 0) return 12; // QEMU Extended Key Event
|
||||
return -1;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental filter for the browser->server direction of a view-only session.
|
||||
* Feed it chunks; it returns only the bytes that are safe to forward.
|
||||
*/
|
||||
class ViewOnlyFilter {
|
||||
constructor() {
|
||||
this.pending = Buffer.alloc(0);
|
||||
this.blocked = 0;
|
||||
}
|
||||
|
||||
push(chunk) {
|
||||
this.pending = this.pending.length ? Buffer.concat([this.pending, chunk]) : chunk;
|
||||
const keep = [];
|
||||
|
||||
while (this.pending.length > 0) {
|
||||
const len = clientMessageLength(this.pending);
|
||||
if (len === -1) {
|
||||
throw new Error(`unparseable client message type ${this.pending[0]} in view-only session`);
|
||||
}
|
||||
if (len === 0 || this.pending.length < len) break;
|
||||
|
||||
const msg = this.pending.subarray(0, len);
|
||||
if (PASSIVE_MESSAGES.has(msg[0])) keep.push(Buffer.from(msg));
|
||||
else this.blocked++;
|
||||
|
||||
this.pending = this.pending.subarray(len);
|
||||
}
|
||||
|
||||
if (!keep.length) return null;
|
||||
return keep.length === 1 ? keep[0] : Buffer.concat(keep);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ByteReader,
|
||||
handshakeWithServer,
|
||||
handshakeWithBrowser,
|
||||
ViewOnlyFilter,
|
||||
clientMessageLength,
|
||||
SEC_NONE,
|
||||
SEC_VNC_AUTH,
|
||||
};
|
||||
+601
@@ -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();
|
||||
Reference in New Issue
Block a user