From eaf2ac8ee7cc301ebe5da4f2c3530e16b3e2a78c Mon Sep 17 00:00:00 2001 From: hashbk Date: Fri, 2 Oct 2026 19:47:02 +0800 Subject: [PATCH 1/5] feat: integrate hbbs and hbbr management with node agents --- .env.example | 3 + .env.integrated.example | 4 + .github/workflows/ci.yml | 25 + .github/workflows/server-integration.yml | 54 +++ README.md | 4 + agent/Dockerfile | 6 + agent/agent.test.mjs | 302 ++++++++++++ agent/app.mjs | 452 ++++++++++++++++++ agent/docker.mjs | 268 +++++++++++ agent/index.mjs | 38 ++ agent/schema.json | 195 ++++++++ docker-compose.integrated.yml | 81 ++++ docs/server-management.md | 72 +++ scripts/server-management-smoke.mjs | 249 ++++++++++ src/app.module.ts | 2 + .../rbac/constants/permission-catalog.ts | 35 +- .../server-management.controller.ts | 141 ++++++ .../server-management.dto.ts | 42 ++ .../server-management.module.ts | 11 + .../server-management.service.spec.ts | 117 +++++ .../server-management.service.ts | 197 ++++++++ 21 files changed, 2297 insertions(+), 1 deletion(-) create mode 100644 .env.integrated.example create mode 100644 .github/workflows/server-integration.yml create mode 100644 agent/Dockerfile create mode 100644 agent/agent.test.mjs create mode 100644 agent/app.mjs create mode 100644 agent/docker.mjs create mode 100644 agent/index.mjs create mode 100644 agent/schema.json create mode 100644 docker-compose.integrated.yml create mode 100644 docs/server-management.md create mode 100644 scripts/server-management-smoke.mjs create mode 100644 src/modules/server-management/server-management.controller.ts create mode 100644 src/modules/server-management/server-management.dto.ts create mode 100644 src/modules/server-management/server-management.module.ts create mode 100644 src/modules/server-management/server-management.service.spec.ts create mode 100644 src/modules/server-management/server-management.service.ts diff --git a/.env.example b/.env.example index ced7f2a3..0e89e3d7 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,6 @@ # DB_USERNAME=root # 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"}] 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 9a1b3056..a13f7436 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: CI on: + workflow_dispatch: push: branches: [main] pull_request: @@ -11,6 +12,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/.github/workflows/server-integration.yml b/.github/workflows/server-integration.yml new file mode 100644 index 00000000..84f5166f --- /dev/null +++ b/.github/workflows/server-integration.yml @@ -0,0 +1,54 @@ +name: RustDesk integrated deployment + +on: + workflow_dispatch: + inputs: + server_ref: + description: rustdesk-server ref containing the management changes + required: true + web_ref: + description: rustdesk-console-web ref containing the management changes + required: true + +permissions: + contents: read + +jobs: + integration: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + with: + path: rustdesk-console + - uses: actions/checkout@v7 + with: + repository: databk/rustdesk-server + ref: ${{ inputs.server_ref }} + submodules: recursive + path: rustdesk-server + - uses: actions/checkout@v7 + with: + repository: databk/rustdesk-console-web + ref: ${{ inputs.web_ref }} + path: rustdesk-console-web + - uses: actions/setup-node@v7 + with: + node-version: 24 + - name: Verify the cross-repository configuration contract + run: cmp rustdesk-console/agent/schema.json rustdesk-server/management-schema.json + - name: Prepare ephemeral CI credentials + working-directory: rustdesk-console + run: | + umask 077 + printf 'JWT_SECRET=%s\nNODE_AGENT_TOKEN=%s\nSERVER_MANAGEMENT_TOKEN=%s\nRUSTDESK_RELAY_ADDRESS=localhost:21117\n' "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .env.integrated + - name: Build and start the integrated stack + working-directory: rustdesk-console + run: docker compose --env-file .env.integrated -f docker-compose.integrated.yml up -d --build + - name: Exercise the integrated management flow + working-directory: rustdesk-console + run: node scripts/server-management-smoke.mjs + - name: Tear down the CI stack + if: always() + working-directory: rustdesk-console + run: docker compose --env-file .env.integrated -f docker-compose.integrated.yml down -v diff --git a/README.md b/README.md index 16be6139..43308cd7 100644 --- a/README.md +++ b/README.md @@ -153,3 +153,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..b85a1500 --- /dev/null +++ b/agent/agent.test.mjs @@ -0,0 +1,302 @@ +// 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) => ready(), + 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.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); +}); diff --git a/agent/app.mjs b/agent/app.mjs new file mode 100644 index 00000000..0e35a7b9 --- /dev/null +++ b/agent/app.mjs @@ -0,0 +1,452 @@ +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) => { + 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'); + 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.port'] || + (service === 'hbbs' ? 21116 : 21117), + ); + try { + await docker.apply( + service, + port, + () => ready(service), + () => save(`${service}-config`, previous), + ); + } catch (error) { + // Failed application restores the last applied override, including secrets. + await save(`${service}-config`, previous); + throw error; + } + await save(`${service}-applied`, desired); + 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?.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..c87568c6 --- /dev/null +++ b/agent/docker.mjs @@ -0,0 +1,268 @@ +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) { + const info = await this.inspect(service); + const oldPort = Number( + info.Config.Labels['io.rustdesk.console.port'] || + (service === 'hbbs' ? 21116 : 21117), + ); + if (port === oldPort) { + try { + await this.action(service, info.State.Running ? 'restart' : 'start'); + await ready(); + } catch (error) { + await beforeRollback(); + if (info.State.Running) { + await this.action(service, 'restart'); + await ready(); + } else { + await this.action(service, 'stop'); + } + 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.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(); + } catch (error) { + await beforeRollback(); + if (replacement) + await this.request( + 'DELETE', + `/containers/${replacement.Id}?force=1&v=0`, + ); + if (renamed) { + await this.request( + 'POST', + `/containers/${info.Id}/rename?name=${encodeURIComponent(name)}`, + ); + for (const network of disconnected) + await this.request( + 'POST', + `/networks/${encodeURIComponent(network)}/connect`, + { Container: info.Id, EndpointConfig: endpoints[network] }, + ); + } + if (info.State.Running) { + await this.request('POST', `/containers/${info.Id}/start`); + await ready(); + } + 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..4d26b78f --- /dev/null +++ b/docker-compose.integrated.yml @@ -0,0 +1,81 @@ +# 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: ['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.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.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..b4bca670 --- /dev/null +++ b/docs/server-management.md @@ -0,0 +1,72 @@ +# 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 +``` + +Build and exercise these images in CI before deploying. Console is published on port 21114. 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. + +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`; CI integration checks keep the copies identical. 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 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. Data volumes are never deleted. Applying or stopping a service interrupts its sessions. + +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 + +Local compilation, application execution and tests are intentionally not part of this workflow. Repository CI builds/types/checks/tests the changes. 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. The optional Console integration workflow checks out explicit server/web refs and exercises the five-service deployment in CI. For changes spanning repositories, pass refs containing the corresponding changes rather than using unrelated default branches. diff --git a/scripts/server-management-smoke.mjs b/scripts/server-management-smoke.mjs new file mode 100644 index 00000000..6f6abd9b --- /dev/null +++ b/scripts/server-management-smoke.mjs @@ -0,0 +1,249 @@ +// CI-only end-to-end checks against the integrated Docker deployment. +import assert from 'node:assert/strict'; +import net from 'node:net'; +import dgram from 'node:dgram'; +import { randomBytes } from 'node:crypto'; +import { once } from 'node:events'; +import { randomUUID } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; + +const origin = process.env.CONSOLE_URL || 'http://127.0.0.1:21114'; +let token; +async function api(route, method = 'GET', body) { + const response = await fetch(`${origin}/api${route}`, { + method, + headers: { + 'content-type': 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + signal: AbortSignal.timeout(240000), + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + assert.ok(response.ok, `${method} ${route}: HTTP ${response.status}`); + return response.json(); +} +async function eventually(check, attempts = 60) { + for (let i = 0; i < attempts; i++) { + try { + const result = await check(); + if (result) return result; + } catch { + /* Retry startup/transient failures in CI. */ + } + await delay(1000); + } + throw new Error('Timed out waiting for integrated stack'); +} +await eventually(async () => (await fetch(`${origin}/api/login-options`)).ok); +const anonymous = await fetch(`${origin}/api/servers`); +assert.equal(anonymous.status, 401); +const login = await api('/login', 'POST', { + username: 'databk', + password: 'databk', + deviceInfo: { type: 'browser', name: 'CI integration' }, +}); +token = login.access_token; +assert.ok(token, 'Default CI administrator login must return an access token'); +await eventually(async () => { + const nodes = await api('/servers'); + return ( + nodes.data[0]?.reachable && + nodes.data[0].services.every((service) => service.available) + ); +}); +const root = '/servers/local'; +const registrations = await api(`${root}/peers`); +assert.ok(Array.isArray(registrations.data)); +assert.ok(Array.isArray((await api(`${root}/sessions`)).data)); +const logs = await api(`${root}/services/hbbs/logs`); +assert.equal(typeof logs.text, 'string'); + +const configuration = await api(`${root}/services/hbbs/config`); +assert.equal(configuration.values.key, '__REDACTED__'); +await api(`${root}/services/hbbs/config`, 'PUT', { + values: { ...configuration.values, 'relay-servers': 'localhost:21117' }, +}); +assert.equal((await api(`${root}/services/hbbs/config`)).pending_restart, true); +await api(`${root}/services/hbbs/apply`, 'POST'); +assert.equal( + (await api(`${root}/services/hbbs/config`)).pending_restart, + false, +); + +// Minimal protobuf encoder for RequestRelay and RustDesk's length frame. +const varint = (value) => { + const bytes = []; + do { + const byte = value & 127; + value >>>= 7; + bytes.push(byte | (value ? 128 : 0)); + } while (value); + return Buffer.from(bytes); +}; +const stringField = (field, text) => { + const bytes = Buffer.isBuffer(text) ? text : Buffer.from(text); + return Buffer.concat([varint((field << 3) | 2), varint(bytes.length), bytes]); +}; +// Register a real native peer over hbbs UDP without inventing Console device records. +const nativeId = '900001'; +const nativeUuid = randomBytes(16); +const registration = Buffer.concat([ + stringField(1, nativeId), + stringField(2, nativeUuid), + stringField(3, randomBytes(32)), +]); +const registerMessage = Buffer.concat([ + varint((15 << 3) | 2), + varint(registration.length), + registration, +]); +const udp = dgram.createSocket('udp4'); +try { + await new Promise((resolve, reject) => + udp.send(registerMessage, 21116, '127.0.0.1', (error) => + error ? reject(error) : resolve(), + ), + ); + await eventually(async () => { + const peer = (await api(`${root}/peers`)).data.find( + (peer) => peer.id === nativeId, + ); + return peer?.online && peer.uuid === nativeUuid.toString('base64'); + }, 20); +} finally { + udp.close(); +} + +function relayFrame(uuid) { + const request = Buffer.concat([ + stringField(1, '123456'), + stringField(2, uuid), + ]); + const message = Buffer.concat([ + varint((18 << 3) | 2), + varint(request.length), + request, + ]); + const header = Buffer.alloc(message.length <= 63 ? 1 : 2); + if (header.length === 1) header[0] = message.length << 2; + else header.writeUInt16LE((message.length << 2) | 1); + return Buffer.concat([header, message]); +} +async function pair(uuid) { + const connect = async () => { + const socket = net.connect(21117, '127.0.0.1'); + socket.on('error', () => {}); + await once(socket, 'connect'); + socket.write(relayFrame(uuid)); + return socket; + }; + const sockets = []; + try { + sockets.push(await connect()); + sockets.push(await connect()); + await eventually( + async () => + (await api(`${root}/sessions`)).data.some( + (session) => session.uuid === uuid, + ), + 20, + ); + const payload = Buffer.from('RustDesk managed relay integration'); + const received = new Promise((resolve, reject) => { + const chunks = []; + let bytes = 0; + const timer = setTimeout( + () => reject(new Error('Relay data transfer timed out')), + 10000, + ); + sockets[1].on('data', (chunk) => { + chunks.push(chunk); + bytes += chunk.length; + if (bytes >= payload.length) { + clearTimeout(timer); + resolve(Buffer.concat(chunks)); + } + }); + }); + sockets[0].write(payload); + assert.deepEqual(await received, payload); + return sockets; + } catch (error) { + sockets.forEach((socket) => socket.destroy()); + throw error; + } +} +let sockets = []; +try { + const uuid = randomUUID(); + sockets = await pair(uuid); + const response = await api(`${root}/sessions/${uuid}`, 'DELETE'); + assert.equal(response.state, 'closing'); + await eventually( + async () => + !(await api(`${root}/sessions`)).data.some( + (session) => session.uuid === uuid, + ), + 20, + ); + sockets.forEach((socket) => socket.destroy()); + + const bannedUuid = randomUUID(); + sockets = await pair(bannedUuid); + const policy = await api(`${root}/bans`, 'PUT', { + device_ids: ['123456', nativeId], + ips: [], + }); + assert.ok( + Object.values(policy.synchronization).every( + (state) => state.state === 'applied', + ), + ); + await eventually( + async () => + !(await api(`${root}/sessions`)).data.some( + (session) => session.uuid === bannedUuid, + ), + 20, + ); + await eventually(async () => { + const peer = (await api(`${root}/peers`)).data.find( + (peer) => peer.id === nativeId, + ); + return peer?.banned && !peer.online; + }, 20); + sockets.forEach((socket) => socket.destroy()); + await api(`${root}/services/hbbr/restart`, 'POST'); + assert.deepEqual((await api(`${root}/bans`)).device_ids, [ + '123456', + nativeId, + ]); + await api(`${root}/bans`, 'PUT', { device_ids: [], ips: [] }); + + await api(`${root}/services/hbbr/stop`, 'POST'); + await eventually( + async () => + !(await api('/servers')).data[0].services.find( + (service) => service.service === 'hbbr', + ).running, + 20, + ); + await api(`${root}/services/hbbr/start`, 'POST'); + + // Listener changes must retain the public host port and survive recreation. + const relayConfig = await api(`${root}/services/hbbr/config`); + await api(`${root}/services/hbbr/config`, 'PUT', { + values: { ...relayConfig.values, port: '22117' }, + }); + await api(`${root}/services/hbbr/apply`, 'POST'); + assert.equal( + (await api(`${root}/services/hbbr/config`)).effective_values.port, + '22117', + ); + const movedUuid = randomUUID(); + sockets = await pair(movedUuid); + await api(`${root}/sessions/${movedUuid}`, 'DELETE'); +} finally { + sockets.forEach((socket) => socket.destroy()); +} +console.log('Integrated server management checks passed'); diff --git a/src/app.module.ts b/src/app.module.ts index 9588b0fd..290bc46a 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'; @@ -193,6 +194,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..88738a23 --- /dev/null +++ b/src/modules/server-management/server-management.service.spec.ts @@ -0,0 +1,117 @@ +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'; + +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; + } +} From 3309fd9ba9cb517f473e13fa5b2e7dac1593d68b Mon Sep 17 00:00:00 2001 From: hashbk Date: Fri, 2 Oct 2026 19:50:08 +0800 Subject: [PATCH 2/5] test: mock ESM UUID dependency in server management suite --- .../server-management/server-management.service.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/modules/server-management/server-management.service.spec.ts b/src/modules/server-management/server-management.service.spec.ts index 88738a23..41dc52e5 100644 --- a/src/modules/server-management/server-management.service.spec.ts +++ b/src/modules/server-management/server-management.service.spec.ts @@ -5,6 +5,12 @@ 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', From 9d992549305a061acd4bd9e4878b0ae006821d2f Mon Sep 17 00:00:00 2001 From: hashbk Date: Fri, 2 Oct 2026 20:57:58 +0800 Subject: [PATCH 3/5] fix: verify listener ports and complete best-effort rollback --- agent/agent.test.mjs | 134 +++++++++++++++++++++++++++++++++- agent/app.mjs | 18 ++++- agent/docker.mjs | 61 +++++++++++----- docker-compose.integrated.yml | 2 + docs/server-management.md | 6 +- 5 files changed, 195 insertions(+), 26 deletions(-) diff --git a/agent/agent.test.mjs b/agent/agent.test.mjs index b85a1500..618d84cf 100644 --- a/agent/agent.test.mjs +++ b/agent/agent.test.mjs @@ -46,7 +46,7 @@ async function fixture(t, overrides = {}) { const calls = []; const docker = { inspect: async (service) => container(service), - apply: async (_service, _port, ready) => ready(), + apply: async (_service, port, ready) => ready(port), logs: async () => `Key: private-value\ntoken=${serverToken}\nnormal log`, action: async () => {}, }; @@ -245,6 +245,7 @@ test('port recreation preserves host bindings and volumes even for overlapping r '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')), ); @@ -300,3 +301,134 @@ test('invalid server JSON is reported as an upstream failure', async (t) => { }); 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) => { + appliedPort = port; + await ready(port); + }; + 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, + ); +}); diff --git a/agent/app.mjs b/agent/app.mjs index 0e35a7b9..5614757a 100644 --- a/agent/app.mjs +++ b/agent/app.mjs @@ -218,7 +218,7 @@ export function createAgent({ ); return { ...bans, synchronization: Object.fromEntries(results) }; }; - const ready = async (service) => { + const ready = async (service, expectedPort) => { for (let attempt = 0; attempt < readinessAttempts; attempt++) { try { const status = await serverRequest(service, '/v1/status'); @@ -229,6 +229,12 @@ export function createAgent({ 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', @@ -251,19 +257,23 @@ export function createAgent({ const container = await docker.inspect(service); const port = Number( desired.values.port || - container.Config.Labels['io.rustdesk.console.port'] || + container.Config.Labels['io.rustdesk.console.deployment-port'] || (service === 'hbbs' ? 21116 : 21117), ); try { await docker.apply( service, port, - () => ready(service), + (expectedPort) => ready(service, expectedPort), () => save(`${service}-config`, previous), ); } catch (error) { // Failed application restores the last applied override, including secrets. - await save(`${service}-config`, previous); + try { + await save(`${service}-config`, previous); + } catch { + console.error(`Unable to restore saved configuration: ${service}`); + } throw error; } await save(`${service}-applied`, desired); diff --git a/agent/docker.mjs b/agent/docker.mjs index c87568c6..3a1dbbe6 100644 --- a/agent/docker.mjs +++ b/agent/docker.mjs @@ -135,18 +135,32 @@ export class DockerDriver { 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(); + await ready(port); } catch (error) { - await beforeRollback(); + await recover('configuration', beforeRollback); if (info.State.Running) { - await this.action(service, 'restart'); - await ready(); + await recover('restart', () => this.action(service, 'restart')); + await recover('readiness', () => ready(oldPort)); } else { - await this.action(service, 'stop'); + await recover('stop', () => this.action(service, 'stop')); } + reportRollback(error); throw error; } return; @@ -187,6 +201,7 @@ export class DockerDriver { 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( @@ -231,30 +246,38 @@ export class DockerDriver { }, ); await this.request('POST', `/containers/${replacement.Id}/start`); - await ready(); + await ready(port); } catch (error) { - await beforeRollback(); + // 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 this.request( - 'DELETE', - `/containers/${replacement.Id}?force=1&v=0`, + await recover('remove-replacement', () => + this.request('DELETE', `/containers/${replacement.Id}?force=1&v=0`), ); if (renamed) { - await this.request( - 'POST', - `/containers/${info.Id}/rename?name=${encodeURIComponent(name)}`, + await recover('restore-name', () => + this.request( + 'POST', + `/containers/${info.Id}/rename?name=${encodeURIComponent(name)}`, + ), ); for (const network of disconnected) - await this.request( - 'POST', - `/networks/${encodeURIComponent(network)}/connect`, - { Container: info.Id, EndpointConfig: endpoints[network] }, + await recover(`reconnect-${network}`, () => + this.request( + 'POST', + `/networks/${encodeURIComponent(network)}/connect`, + { Container: info.Id, EndpointConfig: endpoints[network] }, + ), ); } if (info.State.Running) { - await this.request('POST', `/containers/${info.Id}/start`); - await ready(); + await recover('start', () => + this.request('POST', `/containers/${info.Id}/start`), + ); + await recover('readiness', () => ready(oldPort)); } + reportRollback(error); throw error; } try { diff --git a/docker-compose.integrated.yml b/docker-compose.integrated.yml index 4d26b78f..4bdc9f47 100644 --- a/docker-compose.integrated.yml +++ b/docker-compose.integrated.yml @@ -43,6 +43,7 @@ services: 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 @@ -66,6 +67,7 @@ services: 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 diff --git a/docs/server-management.md b/docs/server-management.md index b4bca670..2bfdaf03 100644 --- a/docs/server-management.md +++ b/docs/server-management.md @@ -10,7 +10,7 @@ Place `rustdesk-console`, `rustdesk-console-web`, and `rustdesk-server` beside e docker compose --env-file .env.integrated -f docker-compose.integrated.yml up -d --build ``` -Build and exercise these images in CI before deploying. Console is published on port 21114. 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. +Build and exercise these images in CI before deploying. Console is published on port 21114. 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: @@ -53,7 +53,9 @@ The schema lists effective service settings, including deprecated hbbs settings, `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 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. Data volumes are never deleted. Applying or stopping a service interrupts its sessions. +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. 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. From 46c1e72c2b64f9da744b1935b8c17ad886278b05 Mon Sep 17 00:00:00 2001 From: hashbk Date: Fri, 2 Oct 2026 23:10:43 +0800 Subject: [PATCH 4/5] fix: roll back failed apply checkpoints and redact effective secrets --- agent/agent.test.mjs | 63 +++++++++++++++++++++++++++++++++++++-- agent/app.mjs | 11 +++++-- agent/docker.mjs | 4 ++- docs/server-management.md | 7 ++++- 4 files changed, 78 insertions(+), 7 deletions(-) diff --git a/agent/agent.test.mjs b/agent/agent.test.mjs index 618d84cf..241e995d 100644 --- a/agent/agent.test.mjs +++ b/agent/agent.test.mjs @@ -46,7 +46,10 @@ async function fixture(t, overrides = {}) { const calls = []; const docker = { inspect: async (service) => container(service), - apply: async (_service, port, ready) => ready(port), + apply: async (_service, port, ready, _rollback, commit) => { + await ready(port); + await commit(); + }, logs: async () => `Key: private-value\ntoken=${serverToken}\nnormal log`, action: async () => {}, }; @@ -317,9 +320,10 @@ test('removing a port override restores the immutable deployment port', async (t JSON.stringify({ values: {} }), ); let appliedPort; - docker.apply = async (_service, port, ready) => { + 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); @@ -432,3 +436,58 @@ test('same-port rollback still restarts and checks the old service if restoring 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 index 5614757a..1f891450 100644 --- a/agent/app.mjs +++ b/agent/app.mjs @@ -265,7 +265,11 @@ export function createAgent({ service, port, (expectedPort) => ready(service, expectedPort), - () => save(`${service}-config`, previous), + 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. @@ -276,7 +280,6 @@ export function createAgent({ } throw error; } - await save(`${service}-applied`, desired); return { state: 'applied' }; }; const config = async (service) => { @@ -294,7 +297,9 @@ export function createAgent({ { values: { ...(effective?.values || {}), ...desired.values } }, service, ), - effective_values: effective?.values || null, + effective_values: effective + ? redacted({ values: effective.values || {} }, service).values + : null, pending_restart: applied === null ? Object.keys(desired.values).length > 0 diff --git a/agent/docker.mjs b/agent/docker.mjs index 3a1dbbe6..c9159630 100644 --- a/agent/docker.mjs +++ b/agent/docker.mjs @@ -129,7 +129,7 @@ export class DockerDriver { // 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) { + async apply(service, port, ready, beforeRollback, commit = async () => {}) { const info = await this.inspect(service); const oldPort = Number( info.Config.Labels['io.rustdesk.console.port'] || @@ -152,6 +152,7 @@ export class DockerDriver { 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) { @@ -247,6 +248,7 @@ export class DockerDriver { ); 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. diff --git a/docs/server-management.md b/docs/server-management.md index 2bfdaf03..89f073d6 100644 --- a/docs/server-management.md +++ b/docs/server-management.md @@ -53,7 +53,7 @@ The schema lists effective service settings, including deprecated hbbs settings, `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. Data volumes are never deleted. Applying or stopping a service interrupts its sessions. +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. @@ -72,3 +72,8 @@ The agent persists desired rules and reports per-service `applied` or `pending` ## CI validation Local compilation, application execution and tests are intentionally not part of this workflow. Repository CI builds/types/checks/tests the changes. 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. The optional Console integration workflow checks out explicit server/web refs and exercises the five-service deployment in CI. For changes spanning repositories, pass refs containing the corresponding changes rather than using unrelated default branches. + + +### 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. From 3146e36863ca0c8af5ff6fa82e7e5f4e471657ab Mon Sep 17 00:00:00 2001 From: hashbk Date: Sat, 3 Oct 2026 13:49:44 +0800 Subject: [PATCH 5/5] fix: restrict integrated Console ingress and remove temporary CI scaffolding --- .github/workflows/ci.yml | 1 - .github/workflows/server-integration.yml | 54 ----- docker-compose.integrated.yml | 2 +- docs/server-management.md | 6 +- scripts/server-management-smoke.mjs | 249 ----------------------- 5 files changed, 4 insertions(+), 308 deletions(-) delete mode 100644 .github/workflows/server-integration.yml delete mode 100644 scripts/server-management-smoke.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d707f3d..ae20b3c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,6 @@ name: CI on: - workflow_dispatch: push: branches: [main] pull_request: diff --git a/.github/workflows/server-integration.yml b/.github/workflows/server-integration.yml deleted file mode 100644 index 84f5166f..00000000 --- a/.github/workflows/server-integration.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: RustDesk integrated deployment - -on: - workflow_dispatch: - inputs: - server_ref: - description: rustdesk-server ref containing the management changes - required: true - web_ref: - description: rustdesk-console-web ref containing the management changes - required: true - -permissions: - contents: read - -jobs: - integration: - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - uses: actions/checkout@v7 - with: - path: rustdesk-console - - uses: actions/checkout@v7 - with: - repository: databk/rustdesk-server - ref: ${{ inputs.server_ref }} - submodules: recursive - path: rustdesk-server - - uses: actions/checkout@v7 - with: - repository: databk/rustdesk-console-web - ref: ${{ inputs.web_ref }} - path: rustdesk-console-web - - uses: actions/setup-node@v7 - with: - node-version: 24 - - name: Verify the cross-repository configuration contract - run: cmp rustdesk-console/agent/schema.json rustdesk-server/management-schema.json - - name: Prepare ephemeral CI credentials - working-directory: rustdesk-console - run: | - umask 077 - printf 'JWT_SECRET=%s\nNODE_AGENT_TOKEN=%s\nSERVER_MANAGEMENT_TOKEN=%s\nRUSTDESK_RELAY_ADDRESS=localhost:21117\n' "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .env.integrated - - name: Build and start the integrated stack - working-directory: rustdesk-console - run: docker compose --env-file .env.integrated -f docker-compose.integrated.yml up -d --build - - name: Exercise the integrated management flow - working-directory: rustdesk-console - run: node scripts/server-management-smoke.mjs - - name: Tear down the CI stack - if: always() - working-directory: rustdesk-console - run: docker compose --env-file .env.integrated -f docker-compose.integrated.yml down -v diff --git a/docker-compose.integrated.yml b/docker-compose.integrated.yml index 4bdc9f47..137fbfc9 100644 --- a/docker-compose.integrated.yml +++ b/docker-compose.integrated.yml @@ -13,7 +13,7 @@ services: rustdesk-console-web: build: ../rustdesk-console-web restart: unless-stopped - ports: ['21114:80'] + ports: ['127.0.0.1:21114:80'] environment: BACKEND_URL: http://rustdesk-console:3000 depends_on: [rustdesk-console] diff --git a/docs/server-management.md b/docs/server-management.md index 89f073d6..5c1b59de 100644 --- a/docs/server-management.md +++ b/docs/server-management.md @@ -10,7 +10,7 @@ Place `rustdesk-console`, `rustdesk-console-web`, and `rustdesk-server` beside e docker compose --env-file .env.integrated -f docker-compose.integrated.yml up -d --build ``` -Build and exercise these images in CI before deploying. Console is published on port 21114. 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. +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: @@ -49,7 +49,7 @@ Node and server protocols carry `api_version: 1`. hbbs exposes status/config/pee ## 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`; CI integration checks keep the copies identical. 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. +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. @@ -71,7 +71,7 @@ The agent persists desired rules and reports per-service `applied` or `pending` ## CI validation -Local compilation, application execution and tests are intentionally not part of this workflow. Repository CI builds/types/checks/tests the changes. 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. The optional Console integration workflow checks out explicit server/web refs and exercises the five-service deployment in CI. For changes spanning repositories, pass refs containing the corresponding changes rather than using unrelated default branches. +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 diff --git a/scripts/server-management-smoke.mjs b/scripts/server-management-smoke.mjs deleted file mode 100644 index 6f6abd9b..00000000 --- a/scripts/server-management-smoke.mjs +++ /dev/null @@ -1,249 +0,0 @@ -// CI-only end-to-end checks against the integrated Docker deployment. -import assert from 'node:assert/strict'; -import net from 'node:net'; -import dgram from 'node:dgram'; -import { randomBytes } from 'node:crypto'; -import { once } from 'node:events'; -import { randomUUID } from 'node:crypto'; -import { setTimeout as delay } from 'node:timers/promises'; - -const origin = process.env.CONSOLE_URL || 'http://127.0.0.1:21114'; -let token; -async function api(route, method = 'GET', body) { - const response = await fetch(`${origin}/api${route}`, { - method, - headers: { - 'content-type': 'application/json', - ...(token ? { authorization: `Bearer ${token}` } : {}), - }, - signal: AbortSignal.timeout(240000), - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); - assert.ok(response.ok, `${method} ${route}: HTTP ${response.status}`); - return response.json(); -} -async function eventually(check, attempts = 60) { - for (let i = 0; i < attempts; i++) { - try { - const result = await check(); - if (result) return result; - } catch { - /* Retry startup/transient failures in CI. */ - } - await delay(1000); - } - throw new Error('Timed out waiting for integrated stack'); -} -await eventually(async () => (await fetch(`${origin}/api/login-options`)).ok); -const anonymous = await fetch(`${origin}/api/servers`); -assert.equal(anonymous.status, 401); -const login = await api('/login', 'POST', { - username: 'databk', - password: 'databk', - deviceInfo: { type: 'browser', name: 'CI integration' }, -}); -token = login.access_token; -assert.ok(token, 'Default CI administrator login must return an access token'); -await eventually(async () => { - const nodes = await api('/servers'); - return ( - nodes.data[0]?.reachable && - nodes.data[0].services.every((service) => service.available) - ); -}); -const root = '/servers/local'; -const registrations = await api(`${root}/peers`); -assert.ok(Array.isArray(registrations.data)); -assert.ok(Array.isArray((await api(`${root}/sessions`)).data)); -const logs = await api(`${root}/services/hbbs/logs`); -assert.equal(typeof logs.text, 'string'); - -const configuration = await api(`${root}/services/hbbs/config`); -assert.equal(configuration.values.key, '__REDACTED__'); -await api(`${root}/services/hbbs/config`, 'PUT', { - values: { ...configuration.values, 'relay-servers': 'localhost:21117' }, -}); -assert.equal((await api(`${root}/services/hbbs/config`)).pending_restart, true); -await api(`${root}/services/hbbs/apply`, 'POST'); -assert.equal( - (await api(`${root}/services/hbbs/config`)).pending_restart, - false, -); - -// Minimal protobuf encoder for RequestRelay and RustDesk's length frame. -const varint = (value) => { - const bytes = []; - do { - const byte = value & 127; - value >>>= 7; - bytes.push(byte | (value ? 128 : 0)); - } while (value); - return Buffer.from(bytes); -}; -const stringField = (field, text) => { - const bytes = Buffer.isBuffer(text) ? text : Buffer.from(text); - return Buffer.concat([varint((field << 3) | 2), varint(bytes.length), bytes]); -}; -// Register a real native peer over hbbs UDP without inventing Console device records. -const nativeId = '900001'; -const nativeUuid = randomBytes(16); -const registration = Buffer.concat([ - stringField(1, nativeId), - stringField(2, nativeUuid), - stringField(3, randomBytes(32)), -]); -const registerMessage = Buffer.concat([ - varint((15 << 3) | 2), - varint(registration.length), - registration, -]); -const udp = dgram.createSocket('udp4'); -try { - await new Promise((resolve, reject) => - udp.send(registerMessage, 21116, '127.0.0.1', (error) => - error ? reject(error) : resolve(), - ), - ); - await eventually(async () => { - const peer = (await api(`${root}/peers`)).data.find( - (peer) => peer.id === nativeId, - ); - return peer?.online && peer.uuid === nativeUuid.toString('base64'); - }, 20); -} finally { - udp.close(); -} - -function relayFrame(uuid) { - const request = Buffer.concat([ - stringField(1, '123456'), - stringField(2, uuid), - ]); - const message = Buffer.concat([ - varint((18 << 3) | 2), - varint(request.length), - request, - ]); - const header = Buffer.alloc(message.length <= 63 ? 1 : 2); - if (header.length === 1) header[0] = message.length << 2; - else header.writeUInt16LE((message.length << 2) | 1); - return Buffer.concat([header, message]); -} -async function pair(uuid) { - const connect = async () => { - const socket = net.connect(21117, '127.0.0.1'); - socket.on('error', () => {}); - await once(socket, 'connect'); - socket.write(relayFrame(uuid)); - return socket; - }; - const sockets = []; - try { - sockets.push(await connect()); - sockets.push(await connect()); - await eventually( - async () => - (await api(`${root}/sessions`)).data.some( - (session) => session.uuid === uuid, - ), - 20, - ); - const payload = Buffer.from('RustDesk managed relay integration'); - const received = new Promise((resolve, reject) => { - const chunks = []; - let bytes = 0; - const timer = setTimeout( - () => reject(new Error('Relay data transfer timed out')), - 10000, - ); - sockets[1].on('data', (chunk) => { - chunks.push(chunk); - bytes += chunk.length; - if (bytes >= payload.length) { - clearTimeout(timer); - resolve(Buffer.concat(chunks)); - } - }); - }); - sockets[0].write(payload); - assert.deepEqual(await received, payload); - return sockets; - } catch (error) { - sockets.forEach((socket) => socket.destroy()); - throw error; - } -} -let sockets = []; -try { - const uuid = randomUUID(); - sockets = await pair(uuid); - const response = await api(`${root}/sessions/${uuid}`, 'DELETE'); - assert.equal(response.state, 'closing'); - await eventually( - async () => - !(await api(`${root}/sessions`)).data.some( - (session) => session.uuid === uuid, - ), - 20, - ); - sockets.forEach((socket) => socket.destroy()); - - const bannedUuid = randomUUID(); - sockets = await pair(bannedUuid); - const policy = await api(`${root}/bans`, 'PUT', { - device_ids: ['123456', nativeId], - ips: [], - }); - assert.ok( - Object.values(policy.synchronization).every( - (state) => state.state === 'applied', - ), - ); - await eventually( - async () => - !(await api(`${root}/sessions`)).data.some( - (session) => session.uuid === bannedUuid, - ), - 20, - ); - await eventually(async () => { - const peer = (await api(`${root}/peers`)).data.find( - (peer) => peer.id === nativeId, - ); - return peer?.banned && !peer.online; - }, 20); - sockets.forEach((socket) => socket.destroy()); - await api(`${root}/services/hbbr/restart`, 'POST'); - assert.deepEqual((await api(`${root}/bans`)).device_ids, [ - '123456', - nativeId, - ]); - await api(`${root}/bans`, 'PUT', { device_ids: [], ips: [] }); - - await api(`${root}/services/hbbr/stop`, 'POST'); - await eventually( - async () => - !(await api('/servers')).data[0].services.find( - (service) => service.service === 'hbbr', - ).running, - 20, - ); - await api(`${root}/services/hbbr/start`, 'POST'); - - // Listener changes must retain the public host port and survive recreation. - const relayConfig = await api(`${root}/services/hbbr/config`); - await api(`${root}/services/hbbr/config`, 'PUT', { - values: { ...relayConfig.values, port: '22117' }, - }); - await api(`${root}/services/hbbr/apply`, 'POST'); - assert.equal( - (await api(`${root}/services/hbbr/config`)).effective_values.port, - '22117', - ); - const movedUuid = randomUUID(); - sockets = await pair(movedUuid); - await api(`${root}/sessions/${movedUuid}`, 'DELETE'); -} finally { - sockets.forEach((socket) => socket.destroy()); -} -console.log('Integrated server management checks passed');