Registering a machine meant reading a multi-step page, installing Node, and running three commands in the right order. Now it is one line per platform. The hub serves scripts/install.sh and scripts/install.ps1 with its own address and the enrolment token substituted in, so the published command carries everything and there is nothing to fill in: curl -fsSL https://support.freakma.com/install.sh?token=TOKEN | sh irm https://support.freakma.com/install.ps1?token=TOKEN | iex That output is piped straight into a shell, so the token — the only untrusted value in either file — is refused unless it matches the base64url shape that randomToken produces. Each script checks for a usable runtime and stops with instructions rather than guessing, warns when nothing is serving RFB on the loopback, installs per-user with no root or administrator, and registers a login-scoped service: launchd on macOS, a lingering systemd user service on Linux, a logon task on Windows. The Windows script uses Node 22 when it is present and falls back to the bundled executable otherwise, which is what lets one command cover both Windows 11 and Server 2008 R2. It registers a logon task rather than a service on purpose: services run in session 0 and cannot draw on the interactive desktop, so the "ask first" consent prompt would never appear. Also adds /docs — a per-OS setup guide with service management and a troubleshooting table — and reworks the enrolment page into OS tabs that open on whichever platform the reader is sitting at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
133 lines
4.2 KiB
JavaScript
133 lines
4.2 KiB
JavaScript
'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();
|
|
|
|
const { baseUrl } = config;
|
|
|
|
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 };
|