diff --git a/.env.example b/.env.example index 30f9423c..3633e0aa 100644 --- a/.env.example +++ b/.env.example @@ -19,5 +19,8 @@ # DB_PASSWORD= # DB_DATABASE=rustdesk_console +# Optional server management nodes (tokens are backend-only; minimum 32 characters). +# RUSTDESK_NODES=[{"id":"local","name":"Local server","url":"http://rustdesk-agent:3001","token":"replace-with-a-random-token-at-least-32-characters"}] + # Docker only: set to 0 when a separate single migration job runs before all replicas. # DB_MIGRATE_ON_START=1 diff --git a/.env.integrated.example b/.env.integrated.example new file mode 100644 index 00000000..48559751 --- /dev/null +++ b/.env.integrated.example @@ -0,0 +1,4 @@ +JWT_SECRET=replace-with-an-independent-random-secret +NODE_AGENT_TOKEN=replace-with-an-independent-random-token-at-least-32-characters +SERVER_MANAGEMENT_TOKEN=replace-with-an-independent-random-token-at-least-32-characters +RUSTDESK_RELAY_ADDRESS=rustdesk.example.com:21117 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 296b13c0..ae20b3c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,30 @@ concurrency: cancel-in-progress: true jobs: + test: + name: Backend and node agent tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version-file: '.nvmrc' + cache: npm + - run: npm ci + - run: npm test -- --runInBand + - run: node --test agent/*.test.mjs + + agent-build: + name: Node agent image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: docker/setup-buildx-action@v4 + - uses: docker/build-push-action@v7 + with: + context: ./agent + push: false + lint: name: Lint runs-on: ubuntu-latest diff --git a/README.md b/README.md index 3725b98c..bcb57dd4 100644 --- a/README.md +++ b/README.md @@ -154,3 +154,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding conventions

Built with ❤️ using NestJS | Data Block

+ +### Integrated hbbs / hbbr management + +See [Server management](docs/server-management.md) for the authenticated server APIs, Docker node agent, server permissions, integrated Compose deployment and CI validation. diff --git a/agent/Dockerfile b/agent/Dockerfile new file mode 100644 index 00000000..76e3bcdb --- /dev/null +++ b/agent/Dockerfile @@ -0,0 +1,6 @@ +FROM node:24-alpine +WORKDIR /app +COPY *.mjs schema.json ./ +USER root +EXPOSE 3001 +CMD ["node", "index.mjs"] diff --git a/agent/agent.test.mjs b/agent/agent.test.mjs new file mode 100644 index 00000000..241e995d --- /dev/null +++ b/agent/agent.test.mjs @@ -0,0 +1,493 @@ +// Executed only by CI: node --test agent/*.test.mjs. +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { once } from 'node:events'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createAgent, REDACTED, validateBans, validateConfig } from './app.mjs'; +import { DockerDriver } from './docker.mjs'; + +const token = 'a'.repeat(40); +const serverToken = 'b'.repeat(40); +const container = (service = 'hbbs') => ({ + Id: 'old', + State: { Running: true, Status: 'running' }, + Config: { + Image: 'managed:test', + Cmd: [service], + Labels: { + 'io.rustdesk.console.node': 'local', + 'io.rustdesk.console.service': service, + 'io.rustdesk.console.port': '21116', + }, + ExposedPorts: { + '21115/tcp': {}, + '21116/tcp': {}, + '21116/udp': {}, + '21118/tcp': {}, + '21120/tcp': {}, + }, + }, + HostConfig: { + Binds: ['/host/data:/data'], + PortBindings: { + '21115/tcp': [{ HostPort: '21115' }], + '21116/tcp': [{ HostPort: '21116' }], + '21116/udp': [{ HostPort: '21116' }], + '21118/tcp': [{ HostPort: '21118' }], + }, + }, + NetworkSettings: { Networks: { net: { Aliases: ['hbbs'] } } }, +}); + +async function fixture(t, overrides = {}) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'rustdesk-agent-')); + const calls = []; + const docker = { + inspect: async (service) => container(service), + apply: async (_service, port, ready, _rollback, commit) => { + await ready(port); + await commit(); + }, + logs: async () => `Key: private-value\ntoken=${serverToken}\nnormal log`, + action: async () => {}, + }; + const fetcher = async (url, options) => { + calls.push({ url, options }); + assert.equal(options.headers.authorization, `Bearer ${serverToken}`); + const service = new URL(url).hostname; + if (url.endsWith('/status')) + return Response.json({ service, api_version: 1, uptime_seconds: 2 }); + if (url.endsWith('/config')) + return Response.json({ + values: { port: service === 'hbbs' ? '21116' : '21117', key: REDACTED }, + }); + return Response.json({ state: 'applied', data: [] }); + }; + const agent = createAgent({ + token, + serverToken, + directory, + docker, + fetcher, + readinessAttempts: 1, + ...overrides, + }); + const server = agent.server(); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(async () => { + await new Promise((resolve) => server.close(resolve)); + await fs.rm(directory, { recursive: true, force: true }); + }); + const request = async (route, method = 'GET', body, authorized = true) => + fetch(`http://127.0.0.1:${server.address().port}${route}`, { + method, + headers: { + ...(authorized ? { authorization: `Bearer ${token}` } : {}), + 'content-type': 'application/json', + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + return { request, directory, calls, docker, agent }; +} + +test('authorization is required even for status; arbitrary Docker commands are unavailable', async (t) => { + const { request } = await fixture(t); + assert.equal( + (await request('/v1/status', 'GET', undefined, false)).status, + 401, + ); + assert.equal((await request('/v1/services/other/stop', 'POST')).status, 404); + assert.equal( + (await request('/v1/services/hbbs/exec', 'POST', { command: 'anything' })) + .status, + 404, + ); +}); + +test('saving configuration preserves redacted keys and does not restart a service', async (t) => { + const { request, directory, docker } = await fixture(t); + let applied = false; + docker.apply = async () => { + applied = true; + }; + await fs.writeFile( + path.join(directory, 'hbbs-config.json'), + JSON.stringify({ values: { key: 'private-value' } }), + ); + const response = await request('/v1/services/hbbs/config', 'PUT', { + values: { key: REDACTED, port: '22016' }, + }); + assert.equal(response.status, 200); + const result = await response.json(); + assert.equal(result.values.key, REDACTED); + assert.equal(result.pending_restart, true); + assert.equal(applied, false); + const persisted = JSON.parse( + await fs.readFile(path.join(directory, 'hbbs-config.json'), 'utf8'), + ); + assert.equal(persisted.values.key, 'private-value'); +}); + +test('an apply failure restores the last applied document', async (t) => { + const { request, directory, docker } = await fixture(t); + const previous = { values: { port: '21116', key: 'previous-key' } }; + await fs.writeFile( + path.join(directory, 'hbbs-applied.json'), + JSON.stringify(previous), + ); + await fs.writeFile( + path.join(directory, 'hbbs-config.json'), + JSON.stringify({ values: { port: '22016' } }), + ); + docker.apply = async (_service, _port, _ready, rollback) => { + await rollback(); + throw new Error('Failed readiness'); + }; + assert.equal((await request('/v1/services/hbbs/apply', 'POST')).status, 500); + assert.deepEqual( + JSON.parse( + await fs.readFile(path.join(directory, 'hbbs-config.json'), 'utf8'), + ), + previous, + ); +}); + +test('offline services leave persisted policy pending and receive it after recovery', async (t) => { + let offline = true; + const applied = []; + const { request, agent } = await fixture(t, { + fetcher: async (url, options) => { + if (offline && url.includes('hbbr')) throw new Error('Offline'); + applied.push(JSON.parse(options.body)); + return Response.json({ state: 'applied' }); + }, + }); + const rules = { device_ids: ['123456'], ips: ['192.0.2.1'] }; + const first = await (await request('/v1/bans', 'PUT', rules)).json(); + assert.equal(first.synchronization.hbbr.state, 'pending'); + offline = false; + const next = await agent.syncPolicy(); + assert.equal(next.synchronization.hbbr.state, 'applied'); + assert.deepEqual(applied.at(-1), rules); +}); + +test('invalid settings, nonfinite limits and invalid ban entries fail validation', () => { + assert.throws(() => validateConfig('hbbs', { values: { port: '65535' } })); + assert.throws(() => + validateConfig('hbbr', { values: { 'total-bandwidth': 'Infinity' } }), + ); + assert.throws(() => + validateConfig('hbbs', { values: { RD_MANAGEMENT_TOKEN: 'secret' } }), + ); + assert.throws(() => validateBans({ device_ids: [''], ips: [] })); + assert.throws(() => validateBans({ device_ids: [], ips: ['not-an-ip'] })); + assert.deepEqual( + validateBans({ + device_ids: ['123456', '123456'], + ips: ['::ffff:192.0.2.1'], + }), + { device_ids: ['123456'], ips: ['192.0.2.1'] }, + ); +}); + +test('integer settings are serialized in the decimal format parsed by the server', () => { + assert.deepEqual( + validateConfig('hbbs', { values: { port: '21116.0', serial: '1e2' } }), + { values: { port: '21116', serial: '100' } }, + ); +}); + +test('log responses remove management credentials and key values', async (t) => { + const { request } = await fixture(t); + const result = await (await request('/v1/services/hbbs/logs')).json(); + assert.ok(!result.text.includes(serverToken)); + assert.ok(!result.text.includes('private-value')); + assert.ok(result.text.includes('normal log')); +}); + +test('Docker enrollment fails closed before any lifecycle operation', async () => { + const calls = []; + const docker = new DockerDriver({ + request: async (method, path) => { + calls.push({ method, path }); + const info = container(); + info.Config.Labels['io.rustdesk.console.node'] = 'other'; + return info; + }, + }); + await assert.rejects(docker.action('hbbs', 'stop'), /not enrolled/); + assert.equal(calls.length, 1); +}); + +test('port recreation preserves host bindings and volumes even for overlapping ranges', async () => { + const calls = []; + const docker = new DockerDriver({ + request: async (method, path, body) => { + calls.push({ method, path, body }); + if (path.endsWith('/json')) return container(); + if (path.startsWith('/containers/create')) return { Id: 'new' }; + }, + }); + await docker.apply( + 'hbbs', + 21117, + async () => {}, + async () => {}, + ); + const creation = calls.find((call) => + call.path.startsWith('/containers/create'), + ).body; + assert.deepEqual(creation.HostConfig.Binds, ['/host/data:/data']); + assert.deepEqual(creation.HostConfig.PortBindings['21117/tcp'], [ + { HostPort: '21116' }, + ]); + assert.deepEqual(creation.NetworkingConfig.EndpointsConfig.net.Aliases, [ + 'hbbs', + ]); + assert.equal(creation.Labels['io.rustdesk.console.port'], '21117'); + assert.equal(creation.Labels['io.rustdesk.console.deployment-port'], '21116'); + assert.ok( + calls.some((call) => call.method === 'DELETE' && call.path.includes('v=0')), + ); +}); + +test('failed replacement restores configuration before restoring the original container', async () => { + const calls = []; + const docker = new DockerDriver({ + request: async (method, path, body) => { + calls.push({ method, path, body }); + if (path.endsWith('/json')) return container(); + if (path.startsWith('/containers/create')) return { Id: 'new' }; + }, + }); + let healthChecks = 0; + await assert.rejects( + docker.apply( + 'hbbs', + 22016, + async () => { + if (++healthChecks === 1) throw new Error('Unhealthy replacement'); + }, + async () => { + calls.push({ path: 'restore-config' }); + }, + ), + /Unhealthy replacement/, + ); + const restore = calls.findIndex((call) => call.path === 'restore-config'); + const startOld = calls.findIndex( + (call) => call.path === '/containers/old/start', + ); + assert.ok(restore >= 0 && restore < startOld); + assert.ok( + !calls.some( + (call) => call.method === 'DELETE' && call.path.includes('/old?'), + ), + ); +}); + +test('oversized authenticated bodies return 413 without applying configuration', async (t) => { + const { request, directory } = await fixture(t); + const response = await request('/v1/services/hbbs/config', 'PUT', { + values: { key: 'x'.repeat(129 * 1024) }, + }); + assert.equal(response.status, 413); + await assert.rejects(fs.access(path.join(directory, 'hbbs-config.json'))); +}); + +test('invalid server JSON is reported as an upstream failure', async (t) => { + const { request } = await fixture(t, { + fetcher: async () => new Response('not JSON'), + }); + assert.equal((await request('/v1/peers')).status, 502); +}); + +test('removing a port override restores the immutable deployment port', async (t) => { + const { request, directory, docker } = await fixture(t); + const info = container('hbbr'); + info.Config.Labels['io.rustdesk.console.port'] = '22117'; + info.Config.Labels['io.rustdesk.console.deployment-port'] = '21117'; + docker.inspect = async () => info; + await fs.writeFile( + path.join(directory, 'hbbr-applied.json'), + JSON.stringify({ values: { port: '22117' } }), + ); + await fs.writeFile( + path.join(directory, 'hbbr-config.json'), + JSON.stringify({ values: {} }), + ); + let appliedPort; + docker.apply = async (_service, port, ready, _rollback, commit) => { + appliedPort = port; + await ready(port); + await commit(); + }; + assert.equal((await request('/v1/services/hbbr/apply', 'POST')).status, 200); + assert.equal(appliedPort, 21117); + assert.deepEqual( + JSON.parse( + await fs.readFile(path.join(directory, 'hbbr-applied.json'), 'utf8'), + ), + { values: {} }, + ); +}); + +test('management readiness rejects a listener that does not match container bindings', async (t) => { + const { request, directory } = await fixture(t); + await request('/v1/services/hbbs/config', 'PUT', { + values: { port: '22016' }, + }); + const response = await request('/v1/services/hbbs/apply', 'POST'); + assert.equal(response.status, 503); + assert.deepEqual( + JSON.parse( + await fs.readFile(path.join(directory, 'hbbs-config.json'), 'utf8'), + ), + { values: {} }, + ); + assert.deepEqual( + JSON.parse( + await fs.readFile(path.join(directory, 'hbbs-applied.json'), 'utf8'), + ), + { values: {} }, + ); +}); + +test('rollback attempts every recovery step and preserves the original failure', async () => { + const original = new Error('Unhealthy replacement'); + const calls = []; + const ports = []; + const info = container(); + info.NetworkSettings.Networks.second = { Aliases: ['hbbs'] }; + const docker = new DockerDriver({ + request: async (method, route, body) => { + calls.push({ method, route, body }); + if (route.endsWith('/json')) return info; + if (route.startsWith('/containers/create')) return { Id: 'new' }; + if ( + route === '/containers/new?force=1&v=0' || + route === '/containers/old/rename?name=hbbs' || + route === '/networks/net/connect' || + route === '/containers/old/start' + ) + throw new Error('Recovery step failed'); + }, + }); + await assert.rejects( + docker.apply( + 'hbbs', + 22016, + async (port) => { + ports.push(port); + if (ports.length === 1) throw original; + }, + async () => { + throw new Error('Configuration restore failed'); + }, + ), + (error) => { + assert.equal(error, original); + assert.deepEqual(error.rollbackFailures, [ + 'configuration', + 'remove-replacement', + 'restore-name', + 'reconnect-net', + 'start', + ]); + return true; + }, + ); + assert.deepEqual(ports, [22016, 21116]); + assert.ok(calls.some((call) => call.route === '/networks/second/connect')); + assert.ok(calls.some((call) => call.route === '/containers/old/start')); +}); + +test('same-port rollback still restarts and checks the old service if restoring configuration fails', async () => { + const calls = []; + const original = new Error('Unhealthy service'); + let checks = 0; + const docker = new DockerDriver({ + request: async (method, route) => { + calls.push({ method, route }); + if (route.endsWith('/json')) return container(); + }, + }); + await assert.rejects( + docker.apply( + 'hbbs', + 21116, + async (port) => { + assert.equal(port, 21116); + if (++checks === 1) throw original; + }, + async () => { + throw new Error('Configuration restore failed'); + }, + ), + (error) => error === original, + ); + assert.equal(checks, 2); + assert.equal( + calls.filter((call) => call.route === '/containers/old/restart?t=10') + .length, + 2, + ); +}); + +test('every returned configuration representation masks schema-marked secrets', async (t) => { + const { request } = await fixture(t, { + fetcher: async () => + Response.json({ + values: { key: 'unmasked-server-secret', port: '21116' }, + }), + }); + const response = await request('/v1/services/hbbs/config'); + assert.equal(response.status, 200); + const result = await response.json(); + assert.equal(result.values.key, REDACTED); + assert.equal(result.effective_values.key, REDACTED); + assert.ok(!JSON.stringify(result).includes('unmasked-server-secret')); +}); + +for (const port of [21116, 22016]) { + test(`failed applied-state checkpoint rolls back the runtime before deleting backup (port ${port})`, async () => { + const docker = new DockerDriver({ nodeId: 'local' }); + docker.inspect = async () => container(); + const calls = []; + docker.request = async (method, route) => { + calls.push({ method, route }); + if (route.startsWith('/containers/create')) return { Id: 'replacement' }; + return {}; + }; + const checked = []; + let restored = false; + const failure = new Error('Checkpoint write failed'); + await assert.rejects( + docker.apply( + 'hbbs', + port, + async (value) => checked.push(value), + async () => { + restored = true; + }, + async () => { + throw failure; + }, + ), + (error) => error === failure, + ); + assert.equal(restored, true); + assert.deepEqual(checked, [port, 21116]); + assert.ok( + !calls.some( + ({ method, route }) => + method === 'DELETE' && route.startsWith('/containers/old?'), + ), + ); + if (port !== 21116) + assert.ok(calls.some(({ route }) => route === '/containers/old/start')); + }); +} diff --git a/agent/app.mjs b/agent/app.mjs new file mode 100644 index 00000000..1f891450 --- /dev/null +++ b/agent/app.mjs @@ -0,0 +1,467 @@ +import http from 'node:http'; +import { timingSafeEqual, randomUUID } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { isIP } from 'node:net'; +import { setTimeout as delay } from 'node:timers/promises'; +import { AgentError, DockerDriver } from './docker.mjs'; + +export const REDACTED = '__REDACTED__'; +export const schema = JSON.parse( + await fs.readFile(new URL('./schema.json', import.meta.url), 'utf8'), +); +const services = ['hbbs', 'hbbr']; + +export function validateConfig(service, input, previous = {}) { + if ( + !input || + typeof input !== 'object' || + Array.isArray(input) || + Object.keys(input).some((key) => key !== 'values') || + !input.values || + typeof input.values !== 'object' || + Array.isArray(input.values) + ) + throw new AgentError(400, 'Expected a values object'); + const values = {}; + for (const [name, value] of Object.entries(input.values)) { + const definition = schema[service].find((item) => item.name === name); + if ( + !definition || + typeof value !== 'string' || + value.length > 4096 || + value.includes('\0') + ) + throw new AgentError(400, `Invalid setting: ${name}`); + if (value === REDACTED) { + if (!definition.secret) + throw new AgentError(400, `Invalid placeholder: ${name}`); + if (Object.hasOwn(previous, name)) values[name] = previous[name]; + continue; + } + if (definition.kind === 'integer' || definition.kind === 'number') { + const number = Number(value); + if ( + !value.trim() || + !Number.isFinite(number) || + number < definition.minimum || + number > definition.maximum || + (definition.kind === 'integer' && !Number.isInteger(number)) + ) + throw new AgentError(400, `Out of range: ${name}`); + values[name] = String(number); + continue; + } + if (name === 'bind' && value && !isIP(value)) + throw new AgentError(400, 'Invalid bind address'); + if (name === 'always-use-relay' && !['Y', 'N'].includes(value)) + throw new AgentError(400, 'Expected Y or N'); + values[name] = value; + } + return { values }; +} + +export function validateBans(input) { + if ( + !input || + typeof input !== 'object' || + Object.keys(input).some((key) => !['device_ids', 'ips'].includes(key)) || + !Array.isArray(input.device_ids) || + !Array.isArray(input.ips) || + input.device_ids.length > 10000 || + input.ips.length > 10000 || + input.device_ids.some( + (id) => typeof id !== 'string' || !id || id.length > 100 || /\s/.test(id), + ) || + input.ips.some((ip) => typeof ip !== 'string' || !isIP(ip)) + ) + throw new AgentError(400, 'Invalid ban rules'); + return { + device_ids: [...new Set(input.device_ids)].sort(), + ips: [ + ...new Set( + input.ips.map((ip) => + ip.toLowerCase().startsWith('::ffff:') && isIP(ip.slice(7)) === 4 + ? ip.slice(7) + : ip, + ), + ), + ].sort(), + }; +} + +function redacted(config, service) { + return { + ...config, + values: Object.fromEntries( + Object.entries(config.values).map(([name, value]) => [ + name, + schema[service].find((item) => item.name === name)?.secret + ? REDACTED + : value, + ]), + ), + }; +} + +export function createAgent({ + token, + serverToken, + directory = '/data/management', + nodeId = 'local', + docker = new DockerDriver({ nodeId }), + urls = { hbbs: 'http://hbbs:21120', hbbr: 'http://hbbr:21121' }, + fetcher = fetch, + readinessAttempts = 10, +} = {}) { + if ( + typeof token !== 'string' || + token.length < 32 || + typeof serverToken !== 'string' || + serverToken.length < 32 + ) + throw new Error( + 'Both management tokens must contain at least 32 characters', + ); + let queue = Promise.resolve(); + const locked = (work) => { + const result = queue.then(work); + queue = result.catch(() => {}); + return result; + }; + const file = (name) => path.join(directory, `${name}.json`); + const read = async (name, fallback) => { + try { + return JSON.parse(await fs.readFile(file(name), 'utf8')); + } catch (error) { + if (error.code === 'ENOENT') return fallback; + throw new AgentError(500, 'Invalid persisted management data'); + } + }; + const save = async (name, value) => { + await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + const temporary = `${file(name)}.${randomUUID()}.tmp`; + try { + await fs.writeFile(temporary, JSON.stringify(value), { + mode: 0o600, + flag: 'wx', + }); + await fs.rename(temporary, file(name)); + } finally { + await fs.rm(temporary, { force: true }); + } + }; + const serverRequest = async (service, route, method = 'GET', body) => { + let response; + try { + response = await fetcher(`${urls[service]}${route}`, { + method, + redirect: 'error', + headers: { + authorization: `Bearer ${serverToken}`, + 'content-type': 'application/json', + }, + signal: AbortSignal.timeout(4000), + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + } catch { + throw new AgentError(503, `${service} management API unavailable`); + } + if (!response.ok) + throw new AgentError( + response.status === 404 ? 404 : 502, + `${service} management API rejected the request`, + ); + const reader = response.body?.getReader(); + if (!reader) + throw new AgentError( + 502, + `${service} returned an empty management response`, + ); + const chunks = []; + let size = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > 8 * 1024 * 1024) { + await reader.cancel(); + throw new Error('Response too large'); + } + chunks.push(Buffer.from(value)); + } + const result = JSON.parse(Buffer.concat(chunks).toString('utf8')); + if (!result || typeof result !== 'object' || Array.isArray(result)) + throw new Error('Invalid response'); + return result; + } catch { + throw new AgentError( + 502, + `${service} returned an invalid management response`, + ); + } finally { + reader.releaseLock(); + } + }; + const syncPolicy = async () => { + const bans = validateBans(await read('bans', { device_ids: [], ips: [] })); + const results = await Promise.all( + services.map(async (service) => { + try { + await serverRequest(service, '/v1/bans', 'PUT', bans); + return [service, { state: 'applied' }]; + } catch { + return [service, { state: 'pending' }]; + } + }), + ); + return { ...bans, synchronization: Object.fromEntries(results) }; + }; + const ready = async (service, expectedPort) => { + for (let attempt = 0; attempt < readinessAttempts; attempt++) { + try { + const status = await serverRequest(service, '/v1/status'); + if ( + status.service !== service || + status.api_version !== 1 || + !Number.isFinite(status.uptime_seconds) || + status.uptime_seconds < 1 + ) + throw new AgentError(502, 'Incompatible server management API'); + const effective = await serverRequest(service, '/v1/config'); + if (Number(effective.values?.port) !== expectedPort) + throw new AgentError( + 502, + 'Server listener port does not match container bindings', + ); + await serverRequest( + service, + '/v1/bans', + 'PUT', + validateBans(await read('bans', { device_ids: [], ips: [] })), + ); + return; + } catch { + if (attempt + 1 < readinessAttempts) await delay(1000); + } + } + throw new AgentError(503, `${service} failed its management health check`); + }; + const apply = async (service) => { + const desired = validateConfig( + service, + await read(`${service}-config`, { values: {} }), + ); + const previous = await read(`${service}-applied`, { values: {} }); + const container = await docker.inspect(service); + const port = Number( + desired.values.port || + container.Config.Labels['io.rustdesk.console.deployment-port'] || + (service === 'hbbs' ? 21116 : 21117), + ); + try { + await docker.apply( + service, + port, + (expectedPort) => ready(service, expectedPort), + async () => { + await save(`${service}-config`, previous); + await save(`${service}-applied`, previous); + }, + () => save(`${service}-applied`, desired), + ); + } catch (error) { + // Failed application restores the last applied override, including secrets. + try { + await save(`${service}-config`, previous); + } catch { + console.error(`Unable to restore saved configuration: ${service}`); + } + throw error; + } + return { state: 'applied' }; + }; + const config = async (service) => { + const desired = await read(`${service}-config`, { values: {} }); + let effective; + try { + effective = await serverRequest(service, '/v1/config'); + } catch { + effective = null; + } + const applied = await read(`${service}-applied`, null); + return { + schema: schema[service], + ...redacted( + { values: { ...(effective?.values || {}), ...desired.values } }, + service, + ), + effective_values: effective + ? redacted({ values: effective.values || {} }, service).values + : null, + pending_restart: + applied === null + ? Object.keys(desired.values).length > 0 + : JSON.stringify(desired) !== JSON.stringify(applied), + available: effective !== null, + }; + }; + const dispatch = async (method, pathname, input, query) => { + if (method === 'GET' && pathname === '/v1/status') { + const status = await Promise.all( + services.map(async (service) => { + try { + const info = await docker.inspect(service); + let runtime; + try { + runtime = await serverRequest(service, '/v1/status'); + } catch { + runtime = null; + } + return { + service, + state: info.State.Status, + running: info.State.Running, + image: info.Config.Image, + started_at: info.State.StartedAt, + restart_count: info.RestartCount, + runtime, + available: + runtime?.api_version === 1 && runtime.service === service, + }; + } catch (error) { + return { + service, + state: 'unavailable', + available: false, + error: error.message, + }; + } + }), + ); + return { api_version: 1, node_id: nodeId, services: status }; + } + if (pathname === '/v1/bans') { + if (method === 'GET') return locked(syncPolicy); + if (method === 'PUT') + return locked(async () => { + await save('bans', validateBans(input)); + return syncPolicy(); + }); + } + if (method === 'GET' && pathname === '/v1/peers') + return serverRequest('hbbs', '/v1/peers'); + if (method === 'GET' && pathname === '/v1/sessions') + return serverRequest('hbbr', '/v1/sessions'); + const session = /^\/v1\/sessions\/([^/]+)$/.exec(pathname); + if (session && method === 'DELETE') + return locked(() => + serverRequest( + 'hbbr', + `/v1/sessions/${encodeURIComponent(decodeURIComponent(session[1]))}`, + 'DELETE', + ), + ); + const match = + /^\/v1\/services\/(hbbs|hbbr)\/(logs|config|start|stop|restart|apply)$/.exec( + pathname, + ); + if (match) { + const [, service, action] = match; + if (action === 'logs' && method === 'GET') { + const tail = Number(query.get('tail') || 200); + if (!Number.isInteger(tail) || tail < 1 || tail > 2000) + throw new AgentError(400, 'Invalid log limit'); + const text = await docker.logs(service, tail); + const sanitized = text + .replaceAll(serverToken, '[redacted]') + .replace( + /(token|secret|(?:private[_ -]?)?key|password)\s*[:=]\s*\S+/gi, + '$1=[redacted]', + ); + return { text: sanitized }; + } + if (action === 'config' && method === 'GET') + return locked(() => config(service)); + if (action === 'config' && method === 'PUT') + return locked(async () => { + const old = await read(`${service}-config`, { values: {} }); + const next = validateConfig(service, input, old.values); + if ((await read(`${service}-applied`, null)) === null) + await save(`${service}-applied`, old); + await save(`${service}-config`, next); + return config(service); + }); + if ( + method === 'POST' && + ['start', 'stop', 'restart', 'apply'].includes(action) + ) + return locked(async () => { + if (action === 'stop') { + await docker.action(service, action); + return { state: 'stopped' }; + } + return apply(service); + }); + } + throw new AgentError(404, 'Unknown management endpoint'); + }; + const handler = async (req, res) => { + const expected = Buffer.from(`Bearer ${token}`); + const actual = Buffer.from(req.headers.authorization || ''); + res.setHeader('content-type', 'application/json'); + res.setHeader('cache-control', 'no-store'); + if ( + actual.length !== expected.length || + !timingSafeEqual(actual, expected) + ) { + res.writeHead(401); + res.end('{"error":"Unauthorized"}'); + return; + } + try { + const url = new URL(req.url, 'http://agent'); + const chunks = []; + let size = 0; + for await (const chunk of req.iterator({ destroyOnReturn: false })) { + size += chunk.length; + if (size > 128 * 1024) { + req.resume(); + throw new AgentError(413, 'Request too large'); + } + chunks.push(chunk); + } + const body = Buffer.concat(chunks).toString('utf8'); + let input; + try { + input = body ? JSON.parse(body) : undefined; + } catch { + throw new AgentError(400, 'Invalid JSON'); + } + const result = await dispatch( + req.method, + url.pathname, + input, + url.searchParams, + ); + res.writeHead(200); + res.end(JSON.stringify(result)); + } catch (error) { + res.writeHead(error instanceof AgentError ? error.status : 500); + res.end( + JSON.stringify({ + error: + error instanceof AgentError + ? error.message + : 'Management operation failed', + }), + ); + } + }; + return { + handler, + syncPolicy: () => locked(syncPolicy), + server: () => http.createServer(handler), + }; +} diff --git a/agent/docker.mjs b/agent/docker.mjs new file mode 100644 index 00000000..c9159630 --- /dev/null +++ b/agent/docker.mjs @@ -0,0 +1,293 @@ +import http from 'node:http'; +import { randomUUID } from 'node:crypto'; + +export class AgentError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +// This driver accepts service names, never arbitrary container IDs or commands. +export class DockerDriver { + constructor({ + socket = '/var/run/docker.sock', + nodeId = 'local', + containers = { hbbs: 'hbbs', hbbr: 'hbbr' }, + request, + } = {}) { + this.socket = socket; + this.nodeId = nodeId; + this.containers = containers; + if (request) this.request = request; + } + + request(method, path, data, binary = false) { + return new Promise((resolve, reject) => { + const req = http.request( + { + socketPath: this.socket, + path: `/v1.45${path}`, + method, + headers: + data === undefined ? {} : { 'content-type': 'application/json' }, + }, + (res) => { + const chunks = []; + let size = 0; + res.on('data', (chunk) => { + size += chunk.length; + if (size > 8 * 1024 * 1024) { + req.destroy(new AgentError(502, 'Docker response too large')); + return; + } + chunks.push(chunk); + }); + res.on('error', reject); + res.on('end', () => { + const buffer = Buffer.concat(chunks); + if (res.statusCode < 200 || res.statusCode >= 300) { + reject( + new AgentError( + res.statusCode === 404 ? 404 : 502, + 'Docker operation failed', + ), + ); + return; + } + try { + resolve( + binary + ? buffer + : buffer.length + ? JSON.parse(buffer.toString()) + : undefined, + ); + } catch { + reject(new AgentError(502, 'Invalid Docker response')); + } + }); + }, + ); + req.setTimeout(30000, () => + req.destroy(new AgentError(504, 'Docker request timed out')), + ); + req.on('error', reject); + if (data !== undefined) req.write(JSON.stringify(data)); + req.end(); + }); + } + + async inspect(service) { + if (!Object.hasOwn(this.containers, service)) + throw new AgentError(400, 'Unknown service'); + const info = await this.request( + 'GET', + `/containers/${encodeURIComponent(this.containers[service])}/json`, + ); + if ( + info.Config.Labels?.['io.rustdesk.console.service'] !== service || + info.Config.Labels?.['io.rustdesk.console.node'] !== this.nodeId + ) { + throw new AgentError(403, 'Container is not enrolled in this node'); + } + return info; + } + + async action(service, action) { + if (!['start', 'stop', 'restart'].includes(action)) + throw new AgentError(400, 'Unknown action'); + const info = await this.inspect(service); + if ( + (action === 'start' && info.State.Running) || + (action === 'stop' && !info.State.Running) + ) + return; + await this.request('POST', `/containers/${info.Id}/${action}?t=10`); + } + + async logs(service, tail = 200) { + const info = await this.inspect(service); + const buffer = await this.request( + 'GET', + `/containers/${info.Id}/logs?stdout=1&stderr=1&tail=${tail}`, + undefined, + true, + ); + if (info.Config.Tty) return buffer.toString(); + let offset = 0; + const chunks = []; + while (offset + 8 <= buffer.length) { + const size = buffer.readUInt32BE(offset + 4); + if (offset + 8 + size > buffer.length) + throw new AgentError(502, 'Invalid Docker log frame'); + chunks.push(buffer.subarray(offset + 8, offset + 8 + size)); + offset += 8 + size; + } + return Buffer.concat(chunks).toString(); + } + + // Keep the old container until the replacement passes its authenticated API + // health check. Preserve mounts, image, resources, network and host ports. + async apply(service, port, ready, beforeRollback, commit = async () => {}) { + const info = await this.inspect(service); + const oldPort = Number( + info.Config.Labels['io.rustdesk.console.port'] || + (service === 'hbbs' ? 21116 : 21117), + ); + const rollbackFailures = []; + const recover = async (step, operation) => { + try { + await operation(); + } catch { + rollbackFailures.push(step); + console.error(`Service rollback step failed: ${service}/${step}`); + } + }; + const reportRollback = (error) => { + if (rollbackFailures.length && error instanceof Error) + error.rollbackFailures = rollbackFailures; + }; + if (port === oldPort) { + try { + await this.action(service, info.State.Running ? 'restart' : 'start'); + await ready(port); + await commit(); + } catch (error) { + await recover('configuration', beforeRollback); + if (info.State.Running) { + await recover('restart', () => this.action(service, 'restart')); + await recover('readiness', () => ready(oldPort)); + } else { + await recover('stop', () => this.action(service, 'stop')); + } + reportRollback(error); + throw error; + } + return; + } + const name = this.containers[service]; + const backup = `${name}-rollback-${randomUUID()}`; + const config = structuredClone(info.Config); + const hostConfig = structuredClone(info.HostConfig); + const protocols = + service === 'hbbs' + ? [ + [-1, 'tcp'], + [0, 'tcp'], + [0, 'udp'], + [2, 'tcp'], + ] + : [ + [0, 'tcp'], + [2, 'tcp'], + ]; + const bindings = structuredClone(hostConfig.PortBindings || {}); + const exposed = structuredClone(config.ExposedPorts || {}); + for (const [offset, protocol] of protocols) { + delete hostConfig.PortBindings?.[`${oldPort + offset}/${protocol}`]; + delete config.ExposedPorts?.[`${oldPort + offset}/${protocol}`]; + } + for (const [offset, protocol] of protocols) { + const oldKey = `${oldPort + offset}/${protocol}`; + const newKey = `${port + offset}/${protocol}`; + if (hostConfig.PortBindings?.[newKey] || config.ExposedPorts?.[newKey]) + throw new AgentError(409, 'Port conflicts with an existing binding'); + if (bindings[oldKey]) { + hostConfig.PortBindings ||= {}; + hostConfig.PortBindings[newKey] = bindings[oldKey]; + } + if (exposed[oldKey]) { + config.ExposedPorts ||= {}; + config.ExposedPorts[newKey] = exposed[oldKey]; + } + } + config.Labels['io.rustdesk.console.deployment-port'] ||= String(oldPort); + config.Labels['io.rustdesk.console.port'] = String(port); + const endpoints = Object.fromEntries( + Object.entries(info.NetworkSettings.Networks).map( + ([network, endpoint]) => [ + network, + { + Aliases: (endpoint.Aliases || []).filter( + (alias) => !/^[a-f0-9]{64}$/.test(alias), + ), + IPAMConfig: endpoint.IPAMConfig, + DriverOpts: endpoint.DriverOpts, + }, + ], + ), + ); + let renamed = false; + let replacement; + const disconnected = []; + try { + if (info.State.Running) await this.action(service, 'stop'); + await this.request( + 'POST', + `/containers/${info.Id}/rename?name=${encodeURIComponent(backup)}`, + ); + renamed = true; + // Disconnect the backup from service aliases while it is retained. + for (const network of Object.keys(endpoints)) { + await this.request( + 'POST', + `/networks/${encodeURIComponent(network)}/disconnect`, + { Container: info.Id, Force: true }, + ); + disconnected.push(network); + } + replacement = await this.request( + 'POST', + `/containers/create?name=${encodeURIComponent(name)}`, + { + ...config, + HostConfig: hostConfig, + NetworkingConfig: { EndpointsConfig: endpoints }, + }, + ); + await this.request('POST', `/containers/${replacement.Id}/start`); + await ready(port); + await commit(); + } catch (error) { + // Recovery steps are independent: cleanup or rename failure must not + // prevent reconnecting networks or attempting to start the old service. + await recover('configuration', beforeRollback); + if (replacement) + await recover('remove-replacement', () => + this.request('DELETE', `/containers/${replacement.Id}?force=1&v=0`), + ); + if (renamed) { + await recover('restore-name', () => + this.request( + 'POST', + `/containers/${info.Id}/rename?name=${encodeURIComponent(name)}`, + ), + ); + for (const network of disconnected) + await recover(`reconnect-${network}`, () => + this.request( + 'POST', + `/networks/${encodeURIComponent(network)}/connect`, + { Container: info.Id, EndpointConfig: endpoints[network] }, + ), + ); + } + if (info.State.Running) { + await recover('start', () => + this.request('POST', `/containers/${info.Id}/start`), + ); + await recover('readiness', () => ready(oldPort)); + } + reportRollback(error); + throw error; + } + try { + await this.request('DELETE', `/containers/${info.Id}?force=1&v=0`); + } catch { + console.warn( + 'Applied configuration; old rollback container requires cleanup', + ); + } + } +} diff --git a/agent/index.mjs b/agent/index.mjs new file mode 100644 index 00000000..47187477 --- /dev/null +++ b/agent/index.mjs @@ -0,0 +1,38 @@ +import { createAgent } from './app.mjs'; +import { DockerDriver } from './docker.mjs'; + +const nodeId = process.env.NODE_ID || 'local'; +const agent = createAgent({ + nodeId, + token: process.env.NODE_AGENT_TOKEN, + serverToken: process.env.SERVER_MANAGEMENT_TOKEN, + directory: process.env.MANAGEMENT_DIR || '/data/management', + docker: new DockerDriver({ + nodeId, + socket: process.env.DOCKER_SOCKET || '/var/run/docker.sock', + containers: { + hbbs: process.env.HBBS_CONTAINER || 'hbbs', + hbbr: process.env.HBBR_CONTAINER || 'hbbr', + }, + }), + urls: { + hbbs: process.env.HBBS_MANAGEMENT_URL || 'http://hbbs:21120', + hbbr: process.env.HBBR_MANAGEMENT_URL || 'http://hbbr:21121', + }, +}); +const server = agent.server(); +server.requestTimeout = 15000; +server.headersTimeout = 10000; +server.listen(Number(process.env.PORT || 3001), '0.0.0.0'); +// Persisted desired bans are reconciled after a service or agent restart. +const timer = setInterval(() => { + void agent + .syncPolicy() + .catch(() => console.error('Policy synchronization failed')); +}, 10000); +timer.unref(); +for (const signal of ['SIGTERM', 'SIGINT']) + process.on(signal, () => { + clearInterval(timer); + server.close(); + }); diff --git a/agent/schema.json b/agent/schema.json new file mode 100644 index 00000000..4525c6a0 --- /dev/null +++ b/agent/schema.json @@ -0,0 +1,195 @@ +{ + "version": 1, + "hbbs": [ + { + "name": "port", + "default": "21116", + "kind": "integer", + "secret": false, + "restart_required": true, + "minimum": 3, + "maximum": 65533 + }, + { + "name": "bind", + "default": "", + "kind": "string", + "secret": false, + "restart_required": true + }, + { + "name": "relay-servers", + "default": "", + "kind": "string", + "secret": false, + "restart_required": true + }, + { + "name": "key", + "default": "-", + "kind": "string", + "secret": true, + "restart_required": true + }, + { + "name": "rmem", + "default": "0", + "kind": "integer", + "secret": false, + "restart_required": true, + "minimum": 0, + "maximum": 2147483647 + }, + { + "name": "serial", + "default": "0", + "kind": "integer", + "secret": false, + "restart_required": true, + "minimum": 0, + "maximum": 2147483647 + }, + { + "name": "rendezvous-servers", + "default": "", + "kind": "string", + "secret": false, + "restart_required": true + }, + { + "name": "software-url", + "default": "", + "kind": "string", + "secret": false, + "restart_required": true + }, + { + "name": "mask", + "default": "", + "kind": "string", + "secret": false, + "restart_required": true + }, + { + "name": "local-ip", + "default": "", + "kind": "string", + "secret": false, + "restart_required": true + }, + { + "name": "db-url", + "default": "./db_v2.sqlite3", + "kind": "string", + "secret": false, + "restart_required": true + }, + { + "name": "max-database-connections", + "default": "1", + "kind": "integer", + "secret": false, + "restart_required": true, + "minimum": 1, + "maximum": 1024 + }, + { + "name": "test-hbbs", + "default": "", + "kind": "string", + "secret": false, + "restart_required": true + }, + { + "name": "always-use-relay", + "default": "N", + "kind": "relay-mode", + "secret": false, + "restart_required": true + }, + { + "name": "log", + "default": "info", + "kind": "string", + "secret": false, + "restart_required": true + } + ], + "hbbr": [ + { + "name": "port", + "default": "21117", + "kind": "integer", + "secret": false, + "restart_required": true, + "minimum": 1, + "maximum": 65533 + }, + { + "name": "bind", + "default": "", + "kind": "string", + "secret": false, + "restart_required": true + }, + { + "name": "key", + "default": "", + "kind": "string", + "secret": true, + "restart_required": true + }, + { + "name": "downgrade-threshold", + "default": "0.66", + "kind": "number", + "secret": false, + "restart_required": true, + "minimum": 0.01, + "maximum": 1 + }, + { + "name": "downgrade-start-check", + "default": "1800", + "kind": "integer", + "secret": false, + "restart_required": true, + "minimum": 1, + "maximum": 4294967 + }, + { + "name": "limit-speed", + "default": "32", + "kind": "number", + "secret": false, + "restart_required": true, + "minimum": 0.01, + "maximum": 1000000 + }, + { + "name": "total-bandwidth", + "default": "1024", + "kind": "number", + "secret": false, + "restart_required": true, + "minimum": 0.01, + "maximum": 1000000 + }, + { + "name": "single-bandwidth", + "default": "128", + "kind": "number", + "secret": false, + "restart_required": true, + "minimum": 0.01, + "maximum": 1000000 + }, + { + "name": "log", + "default": "info", + "kind": "string", + "secret": false, + "restart_required": true + } + ] +} diff --git a/docker-compose.integrated.yml b/docker-compose.integrated.yml new file mode 100644 index 00000000..137fbfc9 --- /dev/null +++ b/docker-compose.integrated.yml @@ -0,0 +1,83 @@ +# Run from rustdesk-console; the server and web repositories are siblings. +services: + rustdesk-console: + build: . + restart: unless-stopped + environment: + JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET} + RUSTDESK_NODES: '[{"id":"local","name":"Local RustDesk","url":"http://rustdesk-agent:3001","token":"${NODE_AGENT_TOKEN:?Set NODE_AGENT_TOKEN}"}]' + volumes: + - ./data/console:/data + networks: [rustdesk] + + rustdesk-console-web: + build: ../rustdesk-console-web + restart: unless-stopped + ports: ['127.0.0.1:21114:80'] + environment: + BACKEND_URL: http://rustdesk-console:3000 + depends_on: [rustdesk-console] + networks: [rustdesk] + + rustdesk-agent: + build: ./agent + restart: unless-stopped + environment: + NODE_ID: local + NODE_AGENT_TOKEN: ${NODE_AGENT_TOKEN:?Set NODE_AGENT_TOKEN} + SERVER_MANAGEMENT_TOKEN: ${SERVER_MANAGEMENT_TOKEN:?Set SERVER_MANAGEMENT_TOKEN} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./data/management:/data/management + depends_on: [hbbs, hbbr] + networks: [rustdesk] + + hbbs: + build: + context: ../rustdesk-server + dockerfile: docker-managed/Dockerfile + image: rustdesk-server-managed:local + container_name: hbbs + command: [hbbs, -r, '${RUSTDESK_RELAY_ADDRESS:?Set RUSTDESK_RELAY_ADDRESS}'] + restart: unless-stopped + labels: + io.rustdesk.console.node: local + io.rustdesk.console.service: hbbs + io.rustdesk.console.deployment-port: '21116' + io.rustdesk.console.port: '21116' + environment: + RD_MANAGEMENT_BIND: 0.0.0.0:21120 + RD_MANAGEMENT_TOKEN: ${SERVER_MANAGEMENT_TOKEN:?Set SERVER_MANAGEMENT_TOKEN} + RD_MANAGEMENT_DIR: /data/management + ports: ['21115:21115', '21116:21116', '21116:21116/udp', '21118:21118'] + volumes: + - ./data/server:/data + - ./data/management:/data/management + depends_on: [hbbr] + networks: [rustdesk] + + hbbr: + build: + context: ../rustdesk-server + dockerfile: docker-managed/Dockerfile + image: rustdesk-server-managed:local + container_name: hbbr + command: [hbbr] + restart: unless-stopped + labels: + io.rustdesk.console.node: local + io.rustdesk.console.service: hbbr + io.rustdesk.console.deployment-port: '21117' + io.rustdesk.console.port: '21117' + environment: + RD_MANAGEMENT_BIND: 0.0.0.0:21121 + RD_MANAGEMENT_TOKEN: ${SERVER_MANAGEMENT_TOKEN:?Set SERVER_MANAGEMENT_TOKEN} + RD_MANAGEMENT_DIR: /data/management + ports: ['21117:21117', '21119:21119'] + volumes: + - ./data/server:/data + - ./data/management:/data/management + networks: [rustdesk] + +networks: + rustdesk: diff --git a/docs/server-management.md b/docs/server-management.md new file mode 100644 index 00000000..5c1b59de --- /dev/null +++ b/docs/server-management.md @@ -0,0 +1,79 @@ +# RustDesk server management + +The Console web application calls `/api/servers`; the NestJS backend authenticates the user, checks global server permissions, and calls a configured node agent. The agent controls only enrolled hbbs/hbbr containers and calls their private `/v1` management APIs. Client protocol messages remain compatible. No browser receives node or server management credentials. + +## Integrated deployment + +Place `rustdesk-console`, `rustdesk-console-web`, and `rustdesk-server` beside each other. Initialize the server's submodules. Copy `.env.integrated.example` to a deployment environment file and replace each secret with a different random value (tokens require at least 32 characters). Set the public relay address. The deployment command is: + +```sh +docker compose --env-file .env.integrated -f docker-compose.integrated.yml up -d --build +``` + +Repository CI builds these images before deployment. Console HTTP is bound to `127.0.0.1:21114` for local access and a host-local TLS reverse proxy. Before enabling remote access, sign in locally and replace the default `databk/databk` administrator password. Keep the loopback binding; publish only the proxy HTTPS endpoint with a valid certificate. Proxy all paths, including `/api/`, to `http://127.0.0.1:21114` and preserve the 300-second management request timeout. Do not publish the HTTP login directly. TLS protects login credentials and bearer tokens in transit; password rotation is required separately. Protocol ports are 21115–21119. The agent and server management ports have no host publication. The agent alone has access to the Docker socket; it requires matching `io.rustdesk.console.node` and `io.rustdesk.console.service` labels and configured container names before any Docker operation. Keep this agent on a trusted network. A Docker socket grants host-level control to the agent process. The node token authorizes only the fixed management endpoints; it does not expose arbitrary Docker requests. An agent process compromise is nevertheless a host compromise. A generic socket proxy that allows unrestricted container creation cannot remove that privilege, because creation payloads can request privileged containers and host mounts. Stronger isolation requires a policy-aware broker that validates container ownership and creation payloads, or a dedicated/rootless Docker host. + +Existing two-service Console deployments still work without server management. `RUSTDESK_NODES` defaults to an empty list. To enroll a node, configure the backend with a JSON array: + +```json +[{"id":"local","name":"Local RustDesk","url":"http://rustdesk-agent:3001","token":"a-random-token-at-least-32-characters"}] +``` + +IDs must be unique and match the agent's `NODE_ID` and container labels. URLs must be HTTP(S) origins without paths or credentials. Additional nodes use the same interface; deploy one agent per Docker host and use HTTPS when crossing hosts. Backend environment changes require a backend restart. This first version provides environment-based enrollment, not a browser credential editor. + +## Permissions and endpoints + +All operations require the existing JWT authentication. Super administrators have access; delegated roles may receive these **global**, backend-owned permissions: + +| Permission | Capability | +| --- | --- | +| `servers.view` | Nodes, service status, hbbs registrations, hbbr sessions, logs | +| `servers.control` | Start, stop, restart, apply configuration | +| `servers.config` | Read and save configuration | +| `servers.disconnect` | Close an hbbr session | +| `servers.ban` | Read, save and synchronize ban rules | + +Every additional permission requires `servers.view`. Existing `devices.*` permissions do not grant node-wide operations. Mutations are recorded in Console audit with actor, node and operation; configuration values and credentials are excluded. + +| Console endpoint | Methods | +| --- | --- | +| `/api/servers` | GET | +| `/api/servers/:node/peers` | GET | +| `/api/servers/:node/sessions` | GET | +| `/api/servers/:node/sessions/:uuid` | DELETE | +| `/api/servers/:node/services/:service/logs?tail=200` | GET | +| `/api/servers/:node/services/:service/config` | GET, PUT | +| `/api/servers/:node/services/:service/:action` | POST (`start`, `stop`, `restart`, `apply`) | +| `/api/servers/:node/bans` | GET, PUT | + +Node and server protocols carry `api_version: 1`. hbbs exposes status/config/peers/bans. hbbr exposes status/config/sessions/bans and session deletion. APIs require independent Bearer credentials. Server APIs are opt-in through `RD_MANAGEMENT_BIND`, `RD_MANAGEMENT_TOKEN`, and `RD_MANAGEMENT_DIR`; existing deployments have no new listener by default. + +## Configuration lifecycle + +The schema lists effective service settings, including deprecated hbbs settings, database options and hbbr bandwidth controls. It is versioned in `agent/schema.json` and `rustdesk-server/management-schema.json`. Keep the two copies identical when changing the contract. Configuration source paths (`--config` and `.env`) are deployment inputs; their effective setting values can be overridden directly. Infrastructure credentials and API listener addresses remain deployment-only. + +`PUT config` accepts `{ "values": { "port": "21116", "relay-servers": "host:21117" } }`. Values are strings; omitted fields fall back to deployment arguments/environment. Saving persists a complete override document; it does **not** change the running process. Managed overrides take priority over CLI, config files and environment at the next start. A key shown as `__REDACTED__` preserves the stored key override; without an override it preserves the deployment key. Clearing a key explicitly is a real configuration change. Private-key generation/rotation is not a new Console operation. + +Start, restart, and apply load the saved overrides. Apply checks the authenticated management API, verifies the effective listener port against the container bindings, and synchronizes bans before reporting success. Failure restores the last applied override and attempts to restore the old running/stopped state. Listener port changes recreate the container, remap its container-side protocol ports while retaining the published **host** ports, image, mounts, resources and network aliases, and retain the old container until readiness succeeds. A cleanup failure may leave a stopped rollback container for operator cleanup. Rollback attempts every independent recovery step even if an earlier step fails, records failed steps in agent logs, and retains the original operation failure. The applied-state checkpoint is persisted before deleting the rollback container; checkpoint failure triggers the same runtime rollback as readiness failure. Data volumes are never deleted. Applying or stopping a service interrupts its sessions. + +The immutable `io.rustdesk.console.deployment-port` label records the original startup listener port; removing a saved port override restores that port. Set this label to the CLI/environment startup port when enrolling an existing custom-port deployment. Without this label the agent uses the standard service port, and readiness rejects any mismatch. + +Console becomes the runtime authority for managed listener ports. A later `docker compose up` can recreate a container from the original Compose definition; align the Compose port bindings and port label with Console's applied configuration before doing that. For changing externally published host ports or images, update the deployment manifest rather than the runtime settings form. + +## Registrations, sessions and bans + +hbbs snapshots use its 30-second registration timeout and retain native IDs and base64 device UUID bytes. Console device online state keeps its separate client API heartbeat semantics. Registrations are displayed separately and do not automatically create users/groups or overwrite Console device records. + +hbbr reports paired active sessions with UUID, reported target IDs, both addresses, transport, start time and combined traffic in bytes. UUID is an opaque session identifier; it is not the Console device UUID or numeric heartbeat connection ID. Session deletion returns `closing`, and the row disappears after both streams are dropped. The cancellation wraps the whole relay task, including waits in bandwidth limiters. + +Ban updates accept `{ "device_ids": ["123456"], "ips": ["192.0.2.1"] }`. Device-ID rules reject registration and requests targeting that ID. The ID in a relay request is client-reported; it is not an authenticated identity of both endpoints. IP rules compare both relay endpoints, normalize mapped IPv4 addresses and also block hbbs requests. Already-established relay sessions matching a rule are cancelled. Existing direct connections are outside hbbr and continue to use Console's client heartbeat disconnect mechanism. + +The agent persists desired rules and reports per-service `applied` or `pending` synchronization. It retries every 10 seconds and before marking a restarted service healthy. Servers persist their last applied rules for startup, so an agent outage does not erase policy. Server peers/register/relay routes enforce these rules independently of Console's database device status. The existing hbbr `blacklist` remains a bandwidth limiter; it is not the new persistent ban policy. + +## CI validation + +The existing repository CI provides permanent build, type, lint and regression checks for this feature. Console CI includes backend and Node agent tests; web CI builds and runs focused server API/access tests; server CI checks all targets, tests management handlers and builds the managed image. Per-repository CI does not establish full-stack interoperability. Coordinated release validation should exercise the deployed Console, agent and native servers together. + + +### Interrupted operations + +Rollback covers errors returned while the agent process remains alive. Container replacement is not a durable transaction across Docker and filesystem state. If the agent or host stops during stop/rename/disconnect/create, inspect the enrolled container and any `-rollback-` container before repeating an apply. Preserve both containers and management files until the active listener, container labels, host port mappings and saved/applied configuration agree. Restore the original container name and network aliases, restore `-config.json` from the last verified `-applied.json`, and start that container if it was previously running. Do not delete the backup or rewrite the applied checkpoint without verifying the service. Automatic recovery of interrupted replacements requires a durable transition journal and is outside this first deployment's guarantees. diff --git a/src/app.module.ts b/src/app.module.ts index 21b9fcaf..b7f1c103 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { ServerManagementModule } from './modules/server-management/server-management.module'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ScheduleModule } from '@nestjs/schedule'; import { ConfigModule } from '@nestjs/config'; @@ -97,6 +98,7 @@ import { ConsoleAuditInterceptor } from './modules/rbac/interceptors/console-aud NexusModule, UserGroupModule, RbacModule, + ServerManagementModule, ], providers: [ { diff --git a/src/modules/rbac/constants/permission-catalog.ts b/src/modules/rbac/constants/permission-catalog.ts index 1c56da04..bdcbc094 100644 --- a/src/modules/rbac/constants/permission-catalog.ts +++ b/src/modules/rbac/constants/permission-catalog.ts @@ -30,7 +30,12 @@ export type PermissionCode = | 'strategies.assign' | 'audit.view' | 'roles.view' - | 'roles.assign'; + | 'roles.assign' + | 'servers.view' + | 'servers.control' + | 'servers.config' + | 'servers.disconnect' + | 'servers.ban'; export type SystemPermissionCode = 'roles.create' | 'roles.edit' | 'roles.delete'; @@ -85,6 +90,34 @@ const systemDefinition = ( }); export const PERMISSION_CATALOG: readonly PermissionDefinition[] = [ + definition('servers.view', 'servers', 'view', 'View servers'), + definition( + 'servers.control', + 'servers', + 'control', + 'Control server services', + 'global', + ['servers.view'], + ), + definition( + 'servers.config', + 'servers', + 'config', + 'Configure servers', + 'global', + ['servers.view'], + ), + definition( + 'servers.disconnect', + 'servers', + 'disconnect', + 'Disconnect relay sessions', + 'global', + ['servers.view'], + ), + definition('servers.ban', 'servers', 'ban', 'Manage server bans', 'global', [ + 'servers.view', + ]), definition('users.view', 'users', 'view', 'View users'), definition('users.create', 'users', 'create', 'Create users', 'global', [ 'users.view', diff --git a/src/modules/server-management/server-management.controller.ts b/src/modules/server-management/server-management.controller.ts new file mode 100644 index 00000000..e9fb714a --- /dev/null +++ b/src/modules/server-management/server-management.controller.ts @@ -0,0 +1,141 @@ +import { + Body, + Controller, + DefaultValuePipe, + Delete, + Get, + Param, + ParseEnumPipe, + ParseIntPipe, + Post, + Put, + Query, +} from '@nestjs/common'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequirePermission } from '../rbac/decorators/require-permission.decorator'; +import { SkipConsoleAudit } from '../rbac/decorators/skip-console-audit.decorator'; +import { + RustdeskService, + ServerBansDto, + ServerConfigDto, + ServiceAction, +} from './server-management.dto'; +import { ServerManagementService } from './server-management.service'; + +@Controller('servers') +@SkipConsoleAudit() +export class ServerManagementController { + constructor(private readonly service: ServerManagementService) {} + + @Get() + @RequirePermission('servers.view') + list() { + return this.service.list(); + } + + @Get(':node/peers') + @RequirePermission('servers.view') + peers(@Param('node') node: string) { + return this.service.request(node, '/v1/peers'); + } + + @Get(':node/sessions') + @RequirePermission('servers.view') + sessions(@Param('node') node: string) { + return this.service.request(node, '/v1/sessions'); + } + + @Delete(':node/sessions/:uuid') + @RequirePermission('servers.disconnect') + disconnect( + @Param('node') node: string, + @Param('uuid') uuid: string, + @CurrentUser('id') actor: string, + ) { + return this.service.mutate( + actor, + node, + `/v1/sessions/${encodeURIComponent(uuid)}`, + 'DELETE', + ); + } + + @Get(':node/services/:service/logs') + @RequirePermission('servers.view') + logs( + @Param('node') node: string, + @Param('service', new ParseEnumPipe(RustdeskService)) + service: RustdeskService, + @Query('tail', new DefaultValuePipe(200), ParseIntPipe) tail: number, + ) { + return this.service.request( + node, + `/v1/services/${service}/logs?tail=${tail}`, + ); + } + + @Get(':node/services/:service/config') + @RequirePermission('servers.config') + config( + @Param('node') node: string, + @Param('service', new ParseEnumPipe(RustdeskService)) + service: RustdeskService, + ) { + return this.service.request(node, `/v1/services/${service}/config`); + } + + @Put(':node/services/:service/config') + @RequirePermission('servers.config') + saveConfig( + @Param('node') node: string, + @Param('service', new ParseEnumPipe(RustdeskService)) + service: RustdeskService, + @Body() dto: ServerConfigDto, + @CurrentUser('id') actor: string, + ) { + return this.service.mutate( + actor, + node, + `/v1/services/${service}/config`, + 'PUT', + dto, + { settings: Object.keys(dto.values) }, + ); + } + + @Post(':node/services/:service/:action') + @RequirePermission('servers.control') + action( + @Param('node') node: string, + @Param('service', new ParseEnumPipe(RustdeskService)) + service: RustdeskService, + @Param('action', new ParseEnumPipe(ServiceAction)) action: ServiceAction, + @CurrentUser('id') actor: string, + ) { + return this.service.mutate( + actor, + node, + `/v1/services/${service}/${action}`, + 'POST', + ); + } + + @Get(':node/bans') + @RequirePermission('servers.ban') + bans(@Param('node') node: string) { + return this.service.request(node, '/v1/bans'); + } + + @Put(':node/bans') + @RequirePermission('servers.ban') + saveBans( + @Param('node') node: string, + @Body() dto: ServerBansDto, + @CurrentUser('id') actor: string, + ) { + return this.service.mutate(actor, node, '/v1/bans', 'PUT', dto, { + device_ids: dto.device_ids, + ips: dto.ips, + }); + } +} diff --git a/src/modules/server-management/server-management.dto.ts b/src/modules/server-management/server-management.dto.ts new file mode 100644 index 00000000..8d5788ab --- /dev/null +++ b/src/modules/server-management/server-management.dto.ts @@ -0,0 +1,42 @@ +import { + ArrayMaxSize, + IsArray, + IsIP, + IsNotEmpty, + IsObject, + IsString, + Matches, + MaxLength, +} from 'class-validator'; + +export enum RustdeskService { + HBBS = 'hbbs', + HBBR = 'hbbr', +} + +export enum ServiceAction { + START = 'start', + STOP = 'stop', + RESTART = 'restart', + APPLY = 'apply', +} + +export class ServerConfigDto { + @IsObject() + values: Record; +} + +export class ServerBansDto { + @IsArray() + @ArrayMaxSize(10000) + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + @MaxLength(100, { each: true }) + @Matches(/^\S+$/, { each: true }) + device_ids: string[]; + + @IsArray() + @ArrayMaxSize(10000) + @IsIP(undefined, { each: true }) + ips: string[]; +} diff --git a/src/modules/server-management/server-management.module.ts b/src/modules/server-management/server-management.module.ts new file mode 100644 index 00000000..cf16d918 --- /dev/null +++ b/src/modules/server-management/server-management.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { RbacModule } from '../rbac/rbac.module'; +import { ServerManagementController } from './server-management.controller'; +import { ServerManagementService } from './server-management.service'; + +@Module({ + imports: [RbacModule], + controllers: [ServerManagementController], + providers: [ServerManagementService], +}) +export class ServerManagementModule {} diff --git a/src/modules/server-management/server-management.service.spec.ts b/src/modules/server-management/server-management.service.spec.ts new file mode 100644 index 00000000..41dc52e5 --- /dev/null +++ b/src/modules/server-management/server-management.service.spec.ts @@ -0,0 +1,123 @@ +import { ConfigService } from '@nestjs/config'; +import { NotFoundException } from '@nestjs/common'; +import { REQUIRE_PERMISSION_KEY } from '../rbac/decorators/require-permission.decorator'; +import { RbacAuditService } from '../rbac/services/rbac-audit.service'; +import { ServerManagementController } from './server-management.controller'; +import { ServerManagementService } from './server-management.service'; + +jest.mock('uuid', () => { + const cryptoModule = + jest.requireActual('node:crypto'); + return { v4: cryptoModule.randomUUID }; +}); + +const node = { + id: 'local', + name: 'Local server', + url: 'http://agent:3001', + token: 'a'.repeat(40), +}; + +describe('server management boundary', () => { + const audit = { record: jest.fn(), recordDenied: jest.fn() }; + const create = (nodes: unknown = [node]) => + new ServerManagementService( + new ConfigService({ RUSTDESK_NODES: JSON.stringify(nodes) }), + audit as unknown as RbacAuditService, + ); + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('allows existing deployments with no configured nodes', async () => { + expect(await create([]).list()).toEqual({ data: [] }); + }); + + it('rejects duplicate nodes, credentials in URLs and weak tokens', () => { + expect(() => create([node, node])).toThrow(); + expect(() => + create([{ ...node, url: 'http://user:password@agent' }]), + ).toThrow(); + expect(() => create([{ ...node, token: 'short' }])).toThrow(); + }); + + it('uses only registered origins and never exposes their credentials', async () => { + const fetcher = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + Response.json({ api_version: 1, node_id: 'local', services: [] }), + ); + const service = create(); + const result = await service.list(); + expect(JSON.stringify(result)).not.toContain(node.token); + expect(JSON.stringify(result)).not.toContain(node.url); + expect(fetcher).toHaveBeenCalledWith( + 'http://agent:3001/v1/status', + expect.objectContaining({ + redirect: 'error', + headers: expect.objectContaining({ + authorization: `Bearer ${node.token}`, + }), + }), + ); + await expect(service.request('other', '/v1/status')).rejects.toBeInstanceOf( + NotFoundException, + ); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('audits successful settings changes without logging setting values', async () => { + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(Response.json({ state: 'saved' })); + await create().mutate( + 'actor', + 'local', + '/v1/services/hbbs/config', + 'PUT', + { values: { key: 'private-key-material' } }, + { settings: ['key'] }, + ); + expect(audit.record).toHaveBeenCalledWith( + expect.objectContaining({ targetGuid: 'local', result: 'allowed' }), + ); + expect(JSON.stringify(audit.record.mock.calls)).not.toContain( + 'private-key-material', + ); + }); + + it('does not report an applied operation as failed when audit persistence fails', async () => { + jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(Response.json({ state: 'applied' })); + audit.record.mockRejectedValueOnce(new Error('Audit storage unavailable')); + await expect( + create().mutate('actor', 'local', '/v1/services/hbbs/apply', 'POST'), + ).resolves.toEqual({ state: 'applied' }); + }); + + it('reports offline nodes without inventing a stopped state', async () => { + jest.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Offline')); + expect(await create().list()).toEqual({ + data: [{ id: 'local', name: node.name, reachable: false, services: [] }], + }); + }); + + it('requires global server permissions, not device permissions', () => { + const prototype = ServerManagementController.prototype; + expect( + Reflect.getMetadata(REQUIRE_PERMISSION_KEY, prototype.action), + ).toEqual(['servers.control']); + expect( + Reflect.getMetadata(REQUIRE_PERMISSION_KEY, prototype.saveConfig), + ).toEqual(['servers.config']); + expect( + Reflect.getMetadata(REQUIRE_PERMISSION_KEY, prototype.disconnect), + ).toEqual(['servers.disconnect']); + expect( + Reflect.getMetadata(REQUIRE_PERMISSION_KEY, prototype.saveBans), + ).toEqual(['servers.ban']); + }); +}); diff --git a/src/modules/server-management/server-management.service.ts b/src/modules/server-management/server-management.service.ts new file mode 100644 index 00000000..a1e3e95d --- /dev/null +++ b/src/modules/server-management/server-management.service.ts @@ -0,0 +1,197 @@ +import { + BadGatewayException, + BadRequestException, + GatewayTimeoutException, + Injectable, + Logger, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { RbacAuditService } from '../rbac/services/rbac-audit.service'; + +interface NodeConfig { + id: string; + name: string; + url: string; + token: string; +} + +@Injectable() +export class ServerManagementService { + private readonly nodes: NodeConfig[]; + private readonly logger = new Logger(ServerManagementService.name); + + constructor( + config: ConfigService, + private readonly audit: RbacAuditService, + ) { + const raw = config.get('RUSTDESK_NODES'); + let nodes: unknown; + try { + nodes = raw ? (JSON.parse(raw) as unknown) : []; + } catch { + throw new Error('RUSTDESK_NODES must be a JSON array'); + } + if (!Array.isArray(nodes) || nodes.length > 100) { + throw new Error('RUSTDESK_NODES must contain at most 100 nodes'); + } + const identifiers = new Set(); + this.nodes = nodes.map((value: unknown) => { + const node = value as NodeConfig; + if ( + !node || + typeof node.id !== 'string' || + !/^[a-zA-Z0-9_-]{1,64}$/.test(node.id) || + identifiers.has(node.id) || + typeof node.name !== 'string' || + !node.name.trim() || + node.name.length > 100 || + typeof node.token !== 'string' || + node.token.length < 32 || + typeof node.url !== 'string' + ) { + throw new Error('Invalid or duplicate RUSTDESK_NODES entry'); + } + const url = new URL(node.url); + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password || + url.search || + url.hash || + url.pathname !== '/' + ) { + throw new Error('Node URL must be an HTTP(S) origin'); + } + identifiers.add(node.id); + return { ...node, url: url.origin }; + }); + } + + async list() { + return { + data: await Promise.all( + this.nodes.map(async (node) => { + try { + const status = await this.request(node.id, '/v1/status'); + if (status.api_version !== 1 || status.node_id !== node.id) { + throw new BadGatewayException('Incompatible node management API'); + } + return { ...status, id: node.id, name: node.name, reachable: true }; + } catch { + return { + id: node.id, + name: node.name, + reachable: false, + services: [], + }; + } + }), + ), + }; + } + + async request( + nodeId: string, + path: string, + method = 'GET', + body?: unknown, + ): Promise> { + const node = this.nodes.find((item) => item.id === nodeId); + if (!node) throw new NotFoundException('Server node not found'); + let response: Response; + try { + response = await fetch(`${node.url}${path}`, { + method, + redirect: 'error', + headers: { + authorization: `Bearer ${node.token}`, + 'content-type': 'application/json', + }, + signal: AbortSignal.timeout(method === 'GET' ? 10000 : 240000), + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + } catch { + throw new ServiceUnavailableException('Server node unavailable'); + } + if (!response.ok) { + if (response.status === 400) { + throw new BadRequestException('Invalid server management request'); + } + if (response.status === 404) { + throw new NotFoundException('Server resource not found'); + } + if (response.status === 409) { + throw new BadRequestException('Server configuration conflicts'); + } + if (response.status === 504) { + throw new GatewayTimeoutException( + 'Server management operation timed out', + ); + } + throw new BadGatewayException('Server management operation failed'); + } + const reader = response.body?.getReader(); + if (!reader) throw new BadGatewayException('Empty node response'); + let size = 0; + const chunks: Buffer[] = []; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > 8 * 1024 * 1024) { + await reader.cancel(); + throw new Error('Response too large'); + } + chunks.push(Buffer.from(value)); + } + const result: unknown = JSON.parse(Buffer.concat(chunks).toString()); + if (!result || typeof result !== 'object' || Array.isArray(result)) { + throw new Error('Invalid response'); + } + return result as Record; + } catch { + throw new BadGatewayException('Invalid node management response'); + } finally { + reader.releaseLock(); + } + } + + async mutate( + actor: string, + nodeId: string, + path: string, + method: string, + body?: unknown, + details?: Record, + ) { + let result: Record; + try { + result = await this.request(nodeId, path, method, body); + } catch (error) { + await this.audit.recordDenied({ + actorUserGuid: actor, + targetType: 'server', + targetGuid: nodeId, + action: `${method} ${path}`, + reason: 'Server management operation failed', + }); + throw error; + } + try { + await this.audit.record({ + actorUserGuid: actor, + targetType: 'server', + targetGuid: nodeId, + action: `${method} ${path}`, + result: 'allowed', + afterState: details, + }); + } catch { + this.logger.warn('Unable to persist server management audit'); + } + return result; + } +}