From 57c73ca1235a36871966f7c49f9471bafc15f552 Mon Sep 17 00:00:00 2001 From: hashbk Date: Sun, 11 Oct 2026 16:56:03 +0800 Subject: [PATCH] feat(server-management): replace node agent with direct server API integration Eliminate the intermediary node agent layer and have Console call hbbs/hbbr management APIs directly. The server registry is now stored in the database with a CRUD API instead of a static RUSTDESK_NODES environment variable, allowing nodes to be added or removed without a backend restart. Key changes: - Add ServerNode entity (role, name, url, managementToken, status) and ServerBanPolicy entity for persistent ban policy storage - Add migration 1790985600002 creating server_nodes and server_ban_policies tables for both SQLite and MySQL - Rewrite ServerManagementService to call hbbs/hbbr management APIs directly using the registered node's URL and token, removing the ConfigService dependency on RUSTDESK_NODES - Add ban fan-out: PUT /api/servers/bans distributes policy to all registered nodes and reports per-node applied/pending synchronization - Add ServerHealthService with @Interval(30000) polling each server's /v1/status and updating the status column - Add servers.manage permission for server registry CRUD operations - Remove agent/ directory (app.mjs, docker.mjs, index.mjs, schema.json, Dockerfile, tests) and the agent-build CI job - Remove Docker socket mount from the integrated deployment; Console reaches hbbs/hbbr management ports over the internal Docker network - Remove container lifecycle endpoints (start/stop/restart/apply) and Docker log endpoint; container orchestration is handled by the deployment layer - Update integrated Compose to remove the rustdesk-agent service - Update CI workflow to remove agent tests and agent image build - Update docs and environment examples for the new architecture --- .env.example | 2 - .env.integrated.example | 2 +- .github/workflows/ci.yml | 14 +- README.md | 2 +- agent/Dockerfile | 6 - agent/agent.test.mjs | 493 ------------------ agent/app.mjs | 467 ----------------- agent/docker.mjs | 293 ----------- agent/index.mjs | 38 -- agent/schema.json | 195 ------- docker-compose.integrated.yml | 24 - docs/server-management.md | 85 +-- src/database/data-source-options.ts | 2 + src/database/entities.ts | 4 + .../1790985600002-AddServerNodes.ts | 38 ++ .../rbac/constants/permission-catalog.ts | 9 + .../entities/server-ban-policy.entity.ts | 16 + .../entities/server-node.entity.ts | 42 ++ .../server-management/server-config-schema.ts | 47 ++ .../server-health.service.ts | 58 +++ .../server-management.controller.ts | 124 ++--- .../server-management.dto.ts | 11 - .../server-management.module.ts | 11 +- .../server-management.service.spec.ts | 241 ++++++--- .../server-management.service.ts | 314 ++++++++--- .../server-management/server-node.dto.ts | 47 ++ 26 files changed, 781 insertions(+), 1804 deletions(-) delete mode 100644 agent/Dockerfile delete mode 100644 agent/agent.test.mjs delete mode 100644 agent/app.mjs delete mode 100644 agent/docker.mjs delete mode 100644 agent/index.mjs delete mode 100644 agent/schema.json create mode 100644 src/migrations/1790985600002-AddServerNodes.ts create mode 100644 src/modules/server-management/entities/server-ban-policy.entity.ts create mode 100644 src/modules/server-management/entities/server-node.entity.ts create mode 100644 src/modules/server-management/server-config-schema.ts create mode 100644 src/modules/server-management/server-health.service.ts create mode 100644 src/modules/server-management/server-node.dto.ts diff --git a/.env.example b/.env.example index 4a22687..734474e 100644 --- a/.env.example +++ b/.env.example @@ -19,8 +19,6 @@ # DB_PASSWORD= # DB_DATABASE=rustdesk_console -# Optional server management nodes (tokens are backend-only; minimum 32 characters). -# RUSTDESK_NODES=[{"id":"local","name":"Local server","url":"http://rustdesk-agent:3001","token":"replace-with-a-random-token-at-least-32-characters"}] # Docker only: set to 0 when a separate single migration job runs before all replicas. # DB_MIGRATE_ON_START=1 diff --git a/.env.integrated.example b/.env.integrated.example index 4855975..2417b89 100644 --- a/.env.integrated.example +++ b/.env.integrated.example @@ -1,4 +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 ae20b3c..59efe84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ concurrency: jobs: test: - name: Backend and node agent tests + name: Backend tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -22,18 +22,6 @@ jobs: 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 diff --git a/README.md b/README.md index 37b2414..1c96efb 100644 --- a/README.md +++ b/README.md @@ -173,4 +173,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding conventions ### 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. +See [Server management](docs/server-management.md) for the authenticated server APIs, server registry, server permissions, integrated Compose deployment and CI validation. diff --git a/agent/Dockerfile b/agent/Dockerfile deleted file mode 100644 index 76e3bcd..0000000 --- a/agent/Dockerfile +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 241e995..0000000 --- a/agent/agent.test.mjs +++ /dev/null @@ -1,493 +0,0 @@ -// Executed only by CI: node --test agent/*.test.mjs. -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { once } from 'node:events'; -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { createAgent, REDACTED, validateBans, validateConfig } from './app.mjs'; -import { DockerDriver } from './docker.mjs'; - -const token = 'a'.repeat(40); -const serverToken = 'b'.repeat(40); -const container = (service = 'hbbs') => ({ - Id: 'old', - State: { Running: true, Status: 'running' }, - Config: { - Image: 'managed:test', - Cmd: [service], - Labels: { - 'io.rustdesk.console.node': 'local', - 'io.rustdesk.console.service': service, - 'io.rustdesk.console.port': '21116', - }, - ExposedPorts: { - '21115/tcp': {}, - '21116/tcp': {}, - '21116/udp': {}, - '21118/tcp': {}, - '21120/tcp': {}, - }, - }, - HostConfig: { - Binds: ['/host/data:/data'], - PortBindings: { - '21115/tcp': [{ HostPort: '21115' }], - '21116/tcp': [{ HostPort: '21116' }], - '21116/udp': [{ HostPort: '21116' }], - '21118/tcp': [{ HostPort: '21118' }], - }, - }, - NetworkSettings: { Networks: { net: { Aliases: ['hbbs'] } } }, -}); - -async function fixture(t, overrides = {}) { - const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'rustdesk-agent-')); - const calls = []; - const docker = { - inspect: async (service) => container(service), - apply: async (_service, port, ready, _rollback, commit) => { - await ready(port); - await commit(); - }, - logs: async () => `Key: private-value\ntoken=${serverToken}\nnormal log`, - action: async () => {}, - }; - const fetcher = async (url, options) => { - calls.push({ url, options }); - assert.equal(options.headers.authorization, `Bearer ${serverToken}`); - const service = new URL(url).hostname; - if (url.endsWith('/status')) - return Response.json({ service, api_version: 1, uptime_seconds: 2 }); - if (url.endsWith('/config')) - return Response.json({ - values: { port: service === 'hbbs' ? '21116' : '21117', key: REDACTED }, - }); - return Response.json({ state: 'applied', data: [] }); - }; - const agent = createAgent({ - token, - serverToken, - directory, - docker, - fetcher, - readinessAttempts: 1, - ...overrides, - }); - const server = agent.server(); - server.listen(0, '127.0.0.1'); - await once(server, 'listening'); - t.after(async () => { - await new Promise((resolve) => server.close(resolve)); - await fs.rm(directory, { recursive: true, force: true }); - }); - const request = async (route, method = 'GET', body, authorized = true) => - fetch(`http://127.0.0.1:${server.address().port}${route}`, { - method, - headers: { - ...(authorized ? { authorization: `Bearer ${token}` } : {}), - 'content-type': 'application/json', - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); - return { request, directory, calls, docker, agent }; -} - -test('authorization is required even for status; arbitrary Docker commands are unavailable', async (t) => { - const { request } = await fixture(t); - assert.equal( - (await request('/v1/status', 'GET', undefined, false)).status, - 401, - ); - assert.equal((await request('/v1/services/other/stop', 'POST')).status, 404); - assert.equal( - (await request('/v1/services/hbbs/exec', 'POST', { command: 'anything' })) - .status, - 404, - ); -}); - -test('saving configuration preserves redacted keys and does not restart a service', async (t) => { - const { request, directory, docker } = await fixture(t); - let applied = false; - docker.apply = async () => { - applied = true; - }; - await fs.writeFile( - path.join(directory, 'hbbs-config.json'), - JSON.stringify({ values: { key: 'private-value' } }), - ); - const response = await request('/v1/services/hbbs/config', 'PUT', { - values: { key: REDACTED, port: '22016' }, - }); - assert.equal(response.status, 200); - const result = await response.json(); - assert.equal(result.values.key, REDACTED); - assert.equal(result.pending_restart, true); - assert.equal(applied, false); - const persisted = JSON.parse( - await fs.readFile(path.join(directory, 'hbbs-config.json'), 'utf8'), - ); - assert.equal(persisted.values.key, 'private-value'); -}); - -test('an apply failure restores the last applied document', async (t) => { - const { request, directory, docker } = await fixture(t); - const previous = { values: { port: '21116', key: 'previous-key' } }; - await fs.writeFile( - path.join(directory, 'hbbs-applied.json'), - JSON.stringify(previous), - ); - await fs.writeFile( - path.join(directory, 'hbbs-config.json'), - JSON.stringify({ values: { port: '22016' } }), - ); - docker.apply = async (_service, _port, _ready, rollback) => { - await rollback(); - throw new Error('Failed readiness'); - }; - assert.equal((await request('/v1/services/hbbs/apply', 'POST')).status, 500); - assert.deepEqual( - JSON.parse( - await fs.readFile(path.join(directory, 'hbbs-config.json'), 'utf8'), - ), - previous, - ); -}); - -test('offline services leave persisted policy pending and receive it after recovery', async (t) => { - let offline = true; - const applied = []; - const { request, agent } = await fixture(t, { - fetcher: async (url, options) => { - if (offline && url.includes('hbbr')) throw new Error('Offline'); - applied.push(JSON.parse(options.body)); - return Response.json({ state: 'applied' }); - }, - }); - const rules = { device_ids: ['123456'], ips: ['192.0.2.1'] }; - const first = await (await request('/v1/bans', 'PUT', rules)).json(); - assert.equal(first.synchronization.hbbr.state, 'pending'); - offline = false; - const next = await agent.syncPolicy(); - assert.equal(next.synchronization.hbbr.state, 'applied'); - assert.deepEqual(applied.at(-1), rules); -}); - -test('invalid settings, nonfinite limits and invalid ban entries fail validation', () => { - assert.throws(() => validateConfig('hbbs', { values: { port: '65535' } })); - assert.throws(() => - validateConfig('hbbr', { values: { 'total-bandwidth': 'Infinity' } }), - ); - assert.throws(() => - validateConfig('hbbs', { values: { RD_MANAGEMENT_TOKEN: 'secret' } }), - ); - assert.throws(() => validateBans({ device_ids: [''], ips: [] })); - assert.throws(() => validateBans({ device_ids: [], ips: ['not-an-ip'] })); - assert.deepEqual( - validateBans({ - device_ids: ['123456', '123456'], - ips: ['::ffff:192.0.2.1'], - }), - { device_ids: ['123456'], ips: ['192.0.2.1'] }, - ); -}); - -test('integer settings are serialized in the decimal format parsed by the server', () => { - assert.deepEqual( - validateConfig('hbbs', { values: { port: '21116.0', serial: '1e2' } }), - { values: { port: '21116', serial: '100' } }, - ); -}); - -test('log responses remove management credentials and key values', async (t) => { - const { request } = await fixture(t); - const result = await (await request('/v1/services/hbbs/logs')).json(); - assert.ok(!result.text.includes(serverToken)); - assert.ok(!result.text.includes('private-value')); - assert.ok(result.text.includes('normal log')); -}); - -test('Docker enrollment fails closed before any lifecycle operation', async () => { - const calls = []; - const docker = new DockerDriver({ - request: async (method, path) => { - calls.push({ method, path }); - const info = container(); - info.Config.Labels['io.rustdesk.console.node'] = 'other'; - return info; - }, - }); - await assert.rejects(docker.action('hbbs', 'stop'), /not enrolled/); - assert.equal(calls.length, 1); -}); - -test('port recreation preserves host bindings and volumes even for overlapping ranges', async () => { - const calls = []; - const docker = new DockerDriver({ - request: async (method, path, body) => { - calls.push({ method, path, body }); - if (path.endsWith('/json')) return container(); - if (path.startsWith('/containers/create')) return { Id: 'new' }; - }, - }); - await docker.apply( - 'hbbs', - 21117, - async () => {}, - async () => {}, - ); - const creation = calls.find((call) => - call.path.startsWith('/containers/create'), - ).body; - assert.deepEqual(creation.HostConfig.Binds, ['/host/data:/data']); - assert.deepEqual(creation.HostConfig.PortBindings['21117/tcp'], [ - { HostPort: '21116' }, - ]); - assert.deepEqual(creation.NetworkingConfig.EndpointsConfig.net.Aliases, [ - 'hbbs', - ]); - assert.equal(creation.Labels['io.rustdesk.console.port'], '21117'); - assert.equal(creation.Labels['io.rustdesk.console.deployment-port'], '21116'); - assert.ok( - calls.some((call) => call.method === 'DELETE' && call.path.includes('v=0')), - ); -}); - -test('failed replacement restores configuration before restoring the original container', async () => { - const calls = []; - const docker = new DockerDriver({ - request: async (method, path, body) => { - calls.push({ method, path, body }); - if (path.endsWith('/json')) return container(); - if (path.startsWith('/containers/create')) return { Id: 'new' }; - }, - }); - let healthChecks = 0; - await assert.rejects( - docker.apply( - 'hbbs', - 22016, - async () => { - if (++healthChecks === 1) throw new Error('Unhealthy replacement'); - }, - async () => { - calls.push({ path: 'restore-config' }); - }, - ), - /Unhealthy replacement/, - ); - const restore = calls.findIndex((call) => call.path === 'restore-config'); - const startOld = calls.findIndex( - (call) => call.path === '/containers/old/start', - ); - assert.ok(restore >= 0 && restore < startOld); - assert.ok( - !calls.some( - (call) => call.method === 'DELETE' && call.path.includes('/old?'), - ), - ); -}); - -test('oversized authenticated bodies return 413 without applying configuration', async (t) => { - const { request, directory } = await fixture(t); - const response = await request('/v1/services/hbbs/config', 'PUT', { - values: { key: 'x'.repeat(129 * 1024) }, - }); - assert.equal(response.status, 413); - await assert.rejects(fs.access(path.join(directory, 'hbbs-config.json'))); -}); - -test('invalid server JSON is reported as an upstream failure', async (t) => { - const { request } = await fixture(t, { - fetcher: async () => new Response('not JSON'), - }); - assert.equal((await request('/v1/peers')).status, 502); -}); - -test('removing a port override restores the immutable deployment port', async (t) => { - const { request, directory, docker } = await fixture(t); - const info = container('hbbr'); - info.Config.Labels['io.rustdesk.console.port'] = '22117'; - info.Config.Labels['io.rustdesk.console.deployment-port'] = '21117'; - docker.inspect = async () => info; - await fs.writeFile( - path.join(directory, 'hbbr-applied.json'), - JSON.stringify({ values: { port: '22117' } }), - ); - await fs.writeFile( - path.join(directory, 'hbbr-config.json'), - JSON.stringify({ values: {} }), - ); - let appliedPort; - docker.apply = async (_service, port, ready, _rollback, commit) => { - appliedPort = port; - await ready(port); - await commit(); - }; - assert.equal((await request('/v1/services/hbbr/apply', 'POST')).status, 200); - assert.equal(appliedPort, 21117); - assert.deepEqual( - JSON.parse( - await fs.readFile(path.join(directory, 'hbbr-applied.json'), 'utf8'), - ), - { values: {} }, - ); -}); - -test('management readiness rejects a listener that does not match container bindings', async (t) => { - const { request, directory } = await fixture(t); - await request('/v1/services/hbbs/config', 'PUT', { - values: { port: '22016' }, - }); - const response = await request('/v1/services/hbbs/apply', 'POST'); - assert.equal(response.status, 503); - assert.deepEqual( - JSON.parse( - await fs.readFile(path.join(directory, 'hbbs-config.json'), 'utf8'), - ), - { values: {} }, - ); - assert.deepEqual( - JSON.parse( - await fs.readFile(path.join(directory, 'hbbs-applied.json'), 'utf8'), - ), - { values: {} }, - ); -}); - -test('rollback attempts every recovery step and preserves the original failure', async () => { - const original = new Error('Unhealthy replacement'); - const calls = []; - const ports = []; - const info = container(); - info.NetworkSettings.Networks.second = { Aliases: ['hbbs'] }; - const docker = new DockerDriver({ - request: async (method, route, body) => { - calls.push({ method, route, body }); - if (route.endsWith('/json')) return info; - if (route.startsWith('/containers/create')) return { Id: 'new' }; - if ( - route === '/containers/new?force=1&v=0' || - route === '/containers/old/rename?name=hbbs' || - route === '/networks/net/connect' || - route === '/containers/old/start' - ) - throw new Error('Recovery step failed'); - }, - }); - await assert.rejects( - docker.apply( - 'hbbs', - 22016, - async (port) => { - ports.push(port); - if (ports.length === 1) throw original; - }, - async () => { - throw new Error('Configuration restore failed'); - }, - ), - (error) => { - assert.equal(error, original); - assert.deepEqual(error.rollbackFailures, [ - 'configuration', - 'remove-replacement', - 'restore-name', - 'reconnect-net', - 'start', - ]); - return true; - }, - ); - assert.deepEqual(ports, [22016, 21116]); - assert.ok(calls.some((call) => call.route === '/networks/second/connect')); - assert.ok(calls.some((call) => call.route === '/containers/old/start')); -}); - -test('same-port rollback still restarts and checks the old service if restoring configuration fails', async () => { - const calls = []; - const original = new Error('Unhealthy service'); - let checks = 0; - const docker = new DockerDriver({ - request: async (method, route) => { - calls.push({ method, route }); - if (route.endsWith('/json')) return container(); - }, - }); - await assert.rejects( - docker.apply( - 'hbbs', - 21116, - async (port) => { - assert.equal(port, 21116); - if (++checks === 1) throw original; - }, - async () => { - throw new Error('Configuration restore failed'); - }, - ), - (error) => error === original, - ); - assert.equal(checks, 2); - assert.equal( - calls.filter((call) => call.route === '/containers/old/restart?t=10') - .length, - 2, - ); -}); - -test('every returned configuration representation masks schema-marked secrets', async (t) => { - const { request } = await fixture(t, { - fetcher: async () => - Response.json({ - values: { key: 'unmasked-server-secret', port: '21116' }, - }), - }); - const response = await request('/v1/services/hbbs/config'); - assert.equal(response.status, 200); - const result = await response.json(); - assert.equal(result.values.key, REDACTED); - assert.equal(result.effective_values.key, REDACTED); - assert.ok(!JSON.stringify(result).includes('unmasked-server-secret')); -}); - -for (const port of [21116, 22016]) { - test(`failed applied-state checkpoint rolls back the runtime before deleting backup (port ${port})`, async () => { - const docker = new DockerDriver({ nodeId: 'local' }); - docker.inspect = async () => container(); - const calls = []; - docker.request = async (method, route) => { - calls.push({ method, route }); - if (route.startsWith('/containers/create')) return { Id: 'replacement' }; - return {}; - }; - const checked = []; - let restored = false; - const failure = new Error('Checkpoint write failed'); - await assert.rejects( - docker.apply( - 'hbbs', - port, - async (value) => checked.push(value), - async () => { - restored = true; - }, - async () => { - throw failure; - }, - ), - (error) => error === failure, - ); - assert.equal(restored, true); - assert.deepEqual(checked, [port, 21116]); - assert.ok( - !calls.some( - ({ method, route }) => - method === 'DELETE' && route.startsWith('/containers/old?'), - ), - ); - if (port !== 21116) - assert.ok(calls.some(({ route }) => route === '/containers/old/start')); - }); -} diff --git a/agent/app.mjs b/agent/app.mjs deleted file mode 100644 index 1f89145..0000000 --- a/agent/app.mjs +++ /dev/null @@ -1,467 +0,0 @@ -import http from 'node:http'; -import { timingSafeEqual, randomUUID } from 'node:crypto'; -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { isIP } from 'node:net'; -import { setTimeout as delay } from 'node:timers/promises'; -import { AgentError, DockerDriver } from './docker.mjs'; - -export const REDACTED = '__REDACTED__'; -export const schema = JSON.parse( - await fs.readFile(new URL('./schema.json', import.meta.url), 'utf8'), -); -const services = ['hbbs', 'hbbr']; - -export function validateConfig(service, input, previous = {}) { - if ( - !input || - typeof input !== 'object' || - Array.isArray(input) || - Object.keys(input).some((key) => key !== 'values') || - !input.values || - typeof input.values !== 'object' || - Array.isArray(input.values) - ) - throw new AgentError(400, 'Expected a values object'); - const values = {}; - for (const [name, value] of Object.entries(input.values)) { - const definition = schema[service].find((item) => item.name === name); - if ( - !definition || - typeof value !== 'string' || - value.length > 4096 || - value.includes('\0') - ) - throw new AgentError(400, `Invalid setting: ${name}`); - if (value === REDACTED) { - if (!definition.secret) - throw new AgentError(400, `Invalid placeholder: ${name}`); - if (Object.hasOwn(previous, name)) values[name] = previous[name]; - continue; - } - if (definition.kind === 'integer' || definition.kind === 'number') { - const number = Number(value); - if ( - !value.trim() || - !Number.isFinite(number) || - number < definition.minimum || - number > definition.maximum || - (definition.kind === 'integer' && !Number.isInteger(number)) - ) - throw new AgentError(400, `Out of range: ${name}`); - values[name] = String(number); - continue; - } - if (name === 'bind' && value && !isIP(value)) - throw new AgentError(400, 'Invalid bind address'); - if (name === 'always-use-relay' && !['Y', 'N'].includes(value)) - throw new AgentError(400, 'Expected Y or N'); - values[name] = value; - } - return { values }; -} - -export function validateBans(input) { - if ( - !input || - typeof input !== 'object' || - Object.keys(input).some((key) => !['device_ids', 'ips'].includes(key)) || - !Array.isArray(input.device_ids) || - !Array.isArray(input.ips) || - input.device_ids.length > 10000 || - input.ips.length > 10000 || - input.device_ids.some( - (id) => typeof id !== 'string' || !id || id.length > 100 || /\s/.test(id), - ) || - input.ips.some((ip) => typeof ip !== 'string' || !isIP(ip)) - ) - throw new AgentError(400, 'Invalid ban rules'); - return { - device_ids: [...new Set(input.device_ids)].sort(), - ips: [ - ...new Set( - input.ips.map((ip) => - ip.toLowerCase().startsWith('::ffff:') && isIP(ip.slice(7)) === 4 - ? ip.slice(7) - : ip, - ), - ), - ].sort(), - }; -} - -function redacted(config, service) { - return { - ...config, - values: Object.fromEntries( - Object.entries(config.values).map(([name, value]) => [ - name, - schema[service].find((item) => item.name === name)?.secret - ? REDACTED - : value, - ]), - ), - }; -} - -export function createAgent({ - token, - serverToken, - directory = '/data/management', - nodeId = 'local', - docker = new DockerDriver({ nodeId }), - urls = { hbbs: 'http://hbbs:21120', hbbr: 'http://hbbr:21121' }, - fetcher = fetch, - readinessAttempts = 10, -} = {}) { - if ( - typeof token !== 'string' || - token.length < 32 || - typeof serverToken !== 'string' || - serverToken.length < 32 - ) - throw new Error( - 'Both management tokens must contain at least 32 characters', - ); - let queue = Promise.resolve(); - const locked = (work) => { - const result = queue.then(work); - queue = result.catch(() => {}); - return result; - }; - const file = (name) => path.join(directory, `${name}.json`); - const read = async (name, fallback) => { - try { - return JSON.parse(await fs.readFile(file(name), 'utf8')); - } catch (error) { - if (error.code === 'ENOENT') return fallback; - throw new AgentError(500, 'Invalid persisted management data'); - } - }; - const save = async (name, value) => { - await fs.mkdir(directory, { recursive: true, mode: 0o700 }); - const temporary = `${file(name)}.${randomUUID()}.tmp`; - try { - await fs.writeFile(temporary, JSON.stringify(value), { - mode: 0o600, - flag: 'wx', - }); - await fs.rename(temporary, file(name)); - } finally { - await fs.rm(temporary, { force: true }); - } - }; - const serverRequest = async (service, route, method = 'GET', body) => { - let response; - try { - response = await fetcher(`${urls[service]}${route}`, { - method, - redirect: 'error', - headers: { - authorization: `Bearer ${serverToken}`, - 'content-type': 'application/json', - }, - signal: AbortSignal.timeout(4000), - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); - } catch { - throw new AgentError(503, `${service} management API unavailable`); - } - if (!response.ok) - throw new AgentError( - response.status === 404 ? 404 : 502, - `${service} management API rejected the request`, - ); - const reader = response.body?.getReader(); - if (!reader) - throw new AgentError( - 502, - `${service} returned an empty management response`, - ); - const chunks = []; - let size = 0; - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - size += value.byteLength; - if (size > 8 * 1024 * 1024) { - await reader.cancel(); - throw new Error('Response too large'); - } - chunks.push(Buffer.from(value)); - } - const result = JSON.parse(Buffer.concat(chunks).toString('utf8')); - if (!result || typeof result !== 'object' || Array.isArray(result)) - throw new Error('Invalid response'); - return result; - } catch { - throw new AgentError( - 502, - `${service} returned an invalid management response`, - ); - } finally { - reader.releaseLock(); - } - }; - const syncPolicy = async () => { - const bans = validateBans(await read('bans', { device_ids: [], ips: [] })); - const results = await Promise.all( - services.map(async (service) => { - try { - await serverRequest(service, '/v1/bans', 'PUT', bans); - return [service, { state: 'applied' }]; - } catch { - return [service, { state: 'pending' }]; - } - }), - ); - return { ...bans, synchronization: Object.fromEntries(results) }; - }; - const ready = async (service, expectedPort) => { - for (let attempt = 0; attempt < readinessAttempts; attempt++) { - try { - const status = await serverRequest(service, '/v1/status'); - if ( - status.service !== service || - status.api_version !== 1 || - !Number.isFinite(status.uptime_seconds) || - status.uptime_seconds < 1 - ) - throw new AgentError(502, 'Incompatible server management API'); - const effective = await serverRequest(service, '/v1/config'); - if (Number(effective.values?.port) !== expectedPort) - throw new AgentError( - 502, - 'Server listener port does not match container bindings', - ); - await serverRequest( - service, - '/v1/bans', - 'PUT', - validateBans(await read('bans', { device_ids: [], ips: [] })), - ); - return; - } catch { - if (attempt + 1 < readinessAttempts) await delay(1000); - } - } - throw new AgentError(503, `${service} failed its management health check`); - }; - const apply = async (service) => { - const desired = validateConfig( - service, - await read(`${service}-config`, { values: {} }), - ); - const previous = await read(`${service}-applied`, { values: {} }); - const container = await docker.inspect(service); - const port = Number( - desired.values.port || - container.Config.Labels['io.rustdesk.console.deployment-port'] || - (service === 'hbbs' ? 21116 : 21117), - ); - try { - await docker.apply( - service, - port, - (expectedPort) => ready(service, expectedPort), - async () => { - await save(`${service}-config`, previous); - await save(`${service}-applied`, previous); - }, - () => save(`${service}-applied`, desired), - ); - } catch (error) { - // Failed application restores the last applied override, including secrets. - try { - await save(`${service}-config`, previous); - } catch { - console.error(`Unable to restore saved configuration: ${service}`); - } - throw error; - } - return { state: 'applied' }; - }; - const config = async (service) => { - const desired = await read(`${service}-config`, { values: {} }); - let effective; - try { - effective = await serverRequest(service, '/v1/config'); - } catch { - effective = null; - } - const applied = await read(`${service}-applied`, null); - return { - schema: schema[service], - ...redacted( - { values: { ...(effective?.values || {}), ...desired.values } }, - service, - ), - effective_values: effective - ? redacted({ values: effective.values || {} }, service).values - : null, - pending_restart: - applied === null - ? Object.keys(desired.values).length > 0 - : JSON.stringify(desired) !== JSON.stringify(applied), - available: effective !== null, - }; - }; - const dispatch = async (method, pathname, input, query) => { - if (method === 'GET' && pathname === '/v1/status') { - const status = await Promise.all( - services.map(async (service) => { - try { - const info = await docker.inspect(service); - let runtime; - try { - runtime = await serverRequest(service, '/v1/status'); - } catch { - runtime = null; - } - return { - service, - state: info.State.Status, - running: info.State.Running, - image: info.Config.Image, - started_at: info.State.StartedAt, - restart_count: info.RestartCount, - runtime, - available: - runtime?.api_version === 1 && runtime.service === service, - }; - } catch (error) { - return { - service, - state: 'unavailable', - available: false, - error: error.message, - }; - } - }), - ); - return { api_version: 1, node_id: nodeId, services: status }; - } - if (pathname === '/v1/bans') { - if (method === 'GET') return locked(syncPolicy); - if (method === 'PUT') - return locked(async () => { - await save('bans', validateBans(input)); - return syncPolicy(); - }); - } - if (method === 'GET' && pathname === '/v1/peers') - return serverRequest('hbbs', '/v1/peers'); - if (method === 'GET' && pathname === '/v1/sessions') - return serverRequest('hbbr', '/v1/sessions'); - const session = /^\/v1\/sessions\/([^/]+)$/.exec(pathname); - if (session && method === 'DELETE') - return locked(() => - serverRequest( - 'hbbr', - `/v1/sessions/${encodeURIComponent(decodeURIComponent(session[1]))}`, - 'DELETE', - ), - ); - const match = - /^\/v1\/services\/(hbbs|hbbr)\/(logs|config|start|stop|restart|apply)$/.exec( - pathname, - ); - if (match) { - const [, service, action] = match; - if (action === 'logs' && method === 'GET') { - const tail = Number(query.get('tail') || 200); - if (!Number.isInteger(tail) || tail < 1 || tail > 2000) - throw new AgentError(400, 'Invalid log limit'); - const text = await docker.logs(service, tail); - const sanitized = text - .replaceAll(serverToken, '[redacted]') - .replace( - /(token|secret|(?:private[_ -]?)?key|password)\s*[:=]\s*\S+/gi, - '$1=[redacted]', - ); - return { text: sanitized }; - } - if (action === 'config' && method === 'GET') - return locked(() => config(service)); - if (action === 'config' && method === 'PUT') - return locked(async () => { - const old = await read(`${service}-config`, { values: {} }); - const next = validateConfig(service, input, old.values); - if ((await read(`${service}-applied`, null)) === null) - await save(`${service}-applied`, old); - await save(`${service}-config`, next); - return config(service); - }); - if ( - method === 'POST' && - ['start', 'stop', 'restart', 'apply'].includes(action) - ) - return locked(async () => { - if (action === 'stop') { - await docker.action(service, action); - return { state: 'stopped' }; - } - return apply(service); - }); - } - throw new AgentError(404, 'Unknown management endpoint'); - }; - const handler = async (req, res) => { - const expected = Buffer.from(`Bearer ${token}`); - const actual = Buffer.from(req.headers.authorization || ''); - res.setHeader('content-type', 'application/json'); - res.setHeader('cache-control', 'no-store'); - if ( - actual.length !== expected.length || - !timingSafeEqual(actual, expected) - ) { - res.writeHead(401); - res.end('{"error":"Unauthorized"}'); - return; - } - try { - const url = new URL(req.url, 'http://agent'); - const chunks = []; - let size = 0; - for await (const chunk of req.iterator({ destroyOnReturn: false })) { - size += chunk.length; - if (size > 128 * 1024) { - req.resume(); - throw new AgentError(413, 'Request too large'); - } - chunks.push(chunk); - } - const body = Buffer.concat(chunks).toString('utf8'); - let input; - try { - input = body ? JSON.parse(body) : undefined; - } catch { - throw new AgentError(400, 'Invalid JSON'); - } - const result = await dispatch( - req.method, - url.pathname, - input, - url.searchParams, - ); - res.writeHead(200); - res.end(JSON.stringify(result)); - } catch (error) { - res.writeHead(error instanceof AgentError ? error.status : 500); - res.end( - JSON.stringify({ - error: - error instanceof AgentError - ? error.message - : 'Management operation failed', - }), - ); - } - }; - return { - handler, - syncPolicy: () => locked(syncPolicy), - server: () => http.createServer(handler), - }; -} diff --git a/agent/docker.mjs b/agent/docker.mjs deleted file mode 100644 index c915963..0000000 --- a/agent/docker.mjs +++ /dev/null @@ -1,293 +0,0 @@ -import http from 'node:http'; -import { randomUUID } from 'node:crypto'; - -export class AgentError extends Error { - constructor(status, message) { - super(message); - this.status = status; - } -} - -// This driver accepts service names, never arbitrary container IDs or commands. -export class DockerDriver { - constructor({ - socket = '/var/run/docker.sock', - nodeId = 'local', - containers = { hbbs: 'hbbs', hbbr: 'hbbr' }, - request, - } = {}) { - this.socket = socket; - this.nodeId = nodeId; - this.containers = containers; - if (request) this.request = request; - } - - request(method, path, data, binary = false) { - return new Promise((resolve, reject) => { - const req = http.request( - { - socketPath: this.socket, - path: `/v1.45${path}`, - method, - headers: - data === undefined ? {} : { 'content-type': 'application/json' }, - }, - (res) => { - const chunks = []; - let size = 0; - res.on('data', (chunk) => { - size += chunk.length; - if (size > 8 * 1024 * 1024) { - req.destroy(new AgentError(502, 'Docker response too large')); - return; - } - chunks.push(chunk); - }); - res.on('error', reject); - res.on('end', () => { - const buffer = Buffer.concat(chunks); - if (res.statusCode < 200 || res.statusCode >= 300) { - reject( - new AgentError( - res.statusCode === 404 ? 404 : 502, - 'Docker operation failed', - ), - ); - return; - } - try { - resolve( - binary - ? buffer - : buffer.length - ? JSON.parse(buffer.toString()) - : undefined, - ); - } catch { - reject(new AgentError(502, 'Invalid Docker response')); - } - }); - }, - ); - req.setTimeout(30000, () => - req.destroy(new AgentError(504, 'Docker request timed out')), - ); - req.on('error', reject); - if (data !== undefined) req.write(JSON.stringify(data)); - req.end(); - }); - } - - async inspect(service) { - if (!Object.hasOwn(this.containers, service)) - throw new AgentError(400, 'Unknown service'); - const info = await this.request( - 'GET', - `/containers/${encodeURIComponent(this.containers[service])}/json`, - ); - if ( - info.Config.Labels?.['io.rustdesk.console.service'] !== service || - info.Config.Labels?.['io.rustdesk.console.node'] !== this.nodeId - ) { - throw new AgentError(403, 'Container is not enrolled in this node'); - } - return info; - } - - async action(service, action) { - if (!['start', 'stop', 'restart'].includes(action)) - throw new AgentError(400, 'Unknown action'); - const info = await this.inspect(service); - if ( - (action === 'start' && info.State.Running) || - (action === 'stop' && !info.State.Running) - ) - return; - await this.request('POST', `/containers/${info.Id}/${action}?t=10`); - } - - async logs(service, tail = 200) { - const info = await this.inspect(service); - const buffer = await this.request( - 'GET', - `/containers/${info.Id}/logs?stdout=1&stderr=1&tail=${tail}`, - undefined, - true, - ); - if (info.Config.Tty) return buffer.toString(); - let offset = 0; - const chunks = []; - while (offset + 8 <= buffer.length) { - const size = buffer.readUInt32BE(offset + 4); - if (offset + 8 + size > buffer.length) - throw new AgentError(502, 'Invalid Docker log frame'); - chunks.push(buffer.subarray(offset + 8, offset + 8 + size)); - offset += 8 + size; - } - return Buffer.concat(chunks).toString(); - } - - // Keep the old container until the replacement passes its authenticated API - // health check. Preserve mounts, image, resources, network and host ports. - async apply(service, port, ready, beforeRollback, commit = async () => {}) { - const info = await this.inspect(service); - const oldPort = Number( - info.Config.Labels['io.rustdesk.console.port'] || - (service === 'hbbs' ? 21116 : 21117), - ); - const rollbackFailures = []; - const recover = async (step, operation) => { - try { - await operation(); - } catch { - rollbackFailures.push(step); - console.error(`Service rollback step failed: ${service}/${step}`); - } - }; - const reportRollback = (error) => { - if (rollbackFailures.length && error instanceof Error) - error.rollbackFailures = rollbackFailures; - }; - if (port === oldPort) { - try { - await this.action(service, info.State.Running ? 'restart' : 'start'); - await ready(port); - await commit(); - } catch (error) { - await recover('configuration', beforeRollback); - if (info.State.Running) { - await recover('restart', () => this.action(service, 'restart')); - await recover('readiness', () => ready(oldPort)); - } else { - await recover('stop', () => this.action(service, 'stop')); - } - reportRollback(error); - throw error; - } - return; - } - const name = this.containers[service]; - const backup = `${name}-rollback-${randomUUID()}`; - const config = structuredClone(info.Config); - const hostConfig = structuredClone(info.HostConfig); - const protocols = - service === 'hbbs' - ? [ - [-1, 'tcp'], - [0, 'tcp'], - [0, 'udp'], - [2, 'tcp'], - ] - : [ - [0, 'tcp'], - [2, 'tcp'], - ]; - const bindings = structuredClone(hostConfig.PortBindings || {}); - const exposed = structuredClone(config.ExposedPorts || {}); - for (const [offset, protocol] of protocols) { - delete hostConfig.PortBindings?.[`${oldPort + offset}/${protocol}`]; - delete config.ExposedPorts?.[`${oldPort + offset}/${protocol}`]; - } - for (const [offset, protocol] of protocols) { - const oldKey = `${oldPort + offset}/${protocol}`; - const newKey = `${port + offset}/${protocol}`; - if (hostConfig.PortBindings?.[newKey] || config.ExposedPorts?.[newKey]) - throw new AgentError(409, 'Port conflicts with an existing binding'); - if (bindings[oldKey]) { - hostConfig.PortBindings ||= {}; - hostConfig.PortBindings[newKey] = bindings[oldKey]; - } - if (exposed[oldKey]) { - config.ExposedPorts ||= {}; - config.ExposedPorts[newKey] = exposed[oldKey]; - } - } - config.Labels['io.rustdesk.console.deployment-port'] ||= String(oldPort); - config.Labels['io.rustdesk.console.port'] = String(port); - const endpoints = Object.fromEntries( - Object.entries(info.NetworkSettings.Networks).map( - ([network, endpoint]) => [ - network, - { - Aliases: (endpoint.Aliases || []).filter( - (alias) => !/^[a-f0-9]{64}$/.test(alias), - ), - IPAMConfig: endpoint.IPAMConfig, - DriverOpts: endpoint.DriverOpts, - }, - ], - ), - ); - let renamed = false; - let replacement; - const disconnected = []; - try { - if (info.State.Running) await this.action(service, 'stop'); - await this.request( - 'POST', - `/containers/${info.Id}/rename?name=${encodeURIComponent(backup)}`, - ); - renamed = true; - // Disconnect the backup from service aliases while it is retained. - for (const network of Object.keys(endpoints)) { - await this.request( - 'POST', - `/networks/${encodeURIComponent(network)}/disconnect`, - { Container: info.Id, Force: true }, - ); - disconnected.push(network); - } - replacement = await this.request( - 'POST', - `/containers/create?name=${encodeURIComponent(name)}`, - { - ...config, - HostConfig: hostConfig, - NetworkingConfig: { EndpointsConfig: endpoints }, - }, - ); - await this.request('POST', `/containers/${replacement.Id}/start`); - await ready(port); - await commit(); - } catch (error) { - // Recovery steps are independent: cleanup or rename failure must not - // prevent reconnecting networks or attempting to start the old service. - await recover('configuration', beforeRollback); - if (replacement) - await recover('remove-replacement', () => - this.request('DELETE', `/containers/${replacement.Id}?force=1&v=0`), - ); - if (renamed) { - await recover('restore-name', () => - this.request( - 'POST', - `/containers/${info.Id}/rename?name=${encodeURIComponent(name)}`, - ), - ); - for (const network of disconnected) - await recover(`reconnect-${network}`, () => - this.request( - 'POST', - `/networks/${encodeURIComponent(network)}/connect`, - { Container: info.Id, EndpointConfig: endpoints[network] }, - ), - ); - } - if (info.State.Running) { - await recover('start', () => - this.request('POST', `/containers/${info.Id}/start`), - ); - await recover('readiness', () => ready(oldPort)); - } - reportRollback(error); - throw error; - } - try { - await this.request('DELETE', `/containers/${info.Id}?force=1&v=0`); - } catch { - console.warn( - 'Applied configuration; old rollback container requires cleanup', - ); - } - } -} diff --git a/agent/index.mjs b/agent/index.mjs deleted file mode 100644 index 4718747..0000000 --- a/agent/index.mjs +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 4525c6a..0000000 --- a/agent/schema.json +++ /dev/null @@ -1,195 +0,0 @@ -{ - "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 index 137fbfc..7f50fc1 100644 --- a/docker-compose.integrated.yml +++ b/docker-compose.integrated.yml @@ -5,7 +5,6 @@ services: 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] @@ -19,19 +18,6 @@ services: 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 @@ -40,11 +26,6 @@ services: container_name: hbbs command: [hbbs, -r, '${RUSTDESK_RELAY_ADDRESS:?Set RUSTDESK_RELAY_ADDRESS}'] restart: unless-stopped - labels: - io.rustdesk.console.node: local - io.rustdesk.console.service: hbbs - io.rustdesk.console.deployment-port: '21116' - io.rustdesk.console.port: '21116' environment: RD_MANAGEMENT_BIND: 0.0.0.0:21120 RD_MANAGEMENT_TOKEN: ${SERVER_MANAGEMENT_TOKEN:?Set SERVER_MANAGEMENT_TOKEN} @@ -64,11 +45,6 @@ services: container_name: hbbr command: [hbbr] restart: unless-stopped - labels: - io.rustdesk.console.node: local - io.rustdesk.console.service: hbbr - io.rustdesk.console.deployment-port: '21117' - io.rustdesk.console.port: '21117' environment: RD_MANAGEMENT_BIND: 0.0.0.0:21121 RD_MANAGEMENT_TOKEN: ${SERVER_MANAGEMENT_TOKEN:?Set SERVER_MANAGEMENT_TOKEN} diff --git a/docs/server-management.md b/docs/server-management.md index 5c1b59d..4170ece 100644 --- a/docs/server-management.md +++ b/docs/server-management.md @@ -1,6 +1,15 @@ # 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. +Console manages hbbs and hbbr instances by calling their private management APIs directly. No intermediary agent process is required. The server registry is stored in the Console database and managed through a CRUD API; adding or removing servers does not require a backend restart. Client protocol messages remain compatible. No browser receives server management credentials. + +## Architecture + +``` +Browser → Console (NestJS) → hbbs management API (direct, 1 instance) + → hbbr[i] management API (direct, fan-out to n instances) +``` + +Console stores the server registry (URLs and management tokens) in the `server_nodes` table. Each entry has a role of `hbbs` or `hbbr`. The topology constraint is 1 APIServer + 1 hbbs + n hbbr; the create endpoint rejects a second hbbs node. Ban policy is stored in the `server_ban_policies` table and distributed to all registered servers on every update. ## Integrated deployment @@ -10,15 +19,17 @@ 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 ``` -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. +After the stack starts, sign in to Console and register the hbbs and hbbr nodes through the API: -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: +``` +POST /api/servers +{ "role": "hbbs", "name": "Primary hbbs", "url": "http://hbbs:21120", "managementToken": "" } -```json -[{"id":"local","name":"Local RustDesk","url":"http://rustdesk-agent:3001","token":"a-random-token-at-least-32-characters"}] +POST /api/servers +{ "role": "hbbr", "name": "Primary hbbr", "url": "http://hbbr:21121", "managementToken": "" } ``` -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. +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. Server management ports (21120, 21121) have no host publication; Console reaches them over the internal Docker network. ## Permissions and endpoints @@ -26,54 +37,56 @@ All operations require the existing JWT authentication. Super administrators hav | 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.view` | Server nodes, status, hbbs registrations, hbbr sessions, config | +| `servers.manage` | Register, update, and remove server nodes | +| `servers.config` | Read and save server configuration | | `servers.disconnect` | Close an hbbr session | -| `servers.ban` | Read, save and synchronize ban rules | +| `servers.ban` | Read, save, and distribute 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. +Every additional permission requires `servers.view`. 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 | +| `/api/servers` | GET, POST | +| `/api/servers/:id` | PATCH, DELETE | +| `/api/servers/:id/peers` | GET | +| `/api/servers/:id/sessions` | GET | +| `/api/servers/:id/sessions/:uuid` | DELETE | +| `/api/servers/:id/config` | GET, PUT | +| `/api/servers/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. +Server management APIs 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 +## Server registry -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. +Server nodes are stored in the `server_nodes` table with a UUID primary key, role (`hbbs` or `hbbr`), display name, management API URL, and management token. The URL and token columns are excluded from default SELECT queries (`select: false`) to prevent accidental credential exposure in API responses. The `status` column is updated by a periodic health check (every 30 seconds) that calls each server's `/v1/status` endpoint. -`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. +Creating a node requires the `servers.manage` permission. The create endpoint enforces: +- At most one `hbbs` node (the topology is 1 hbbs + n hbbr). +- At most 100 total nodes. +- URLs must be HTTP(S) origins without credentials, paths, query strings, or fragments. +- Management tokens must be at least 32 characters. -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. +Updating a node (`PATCH /api/servers/:id`) accepts optional `name`, `url`, and `managementToken` fields. Deleting a node (`DELETE /api/servers/:id`) removes it from the registry; the server itself is not affected. -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. +## Configuration lifecycle -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. +`GET /api/servers/:id/config` calls the server's `/v1/config` endpoint directly and returns the effective configuration. `PUT /api/servers/:id/config` forwards the override to the server. Values are strings; omitted fields fall back to deployment arguments/environment. Saving persists a complete override document on the server; it does **not** change the running process until the server is restarted. 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. -## Registrations, sessions and bans +Container lifecycle (start, stop, restart) is managed by the deployment orchestrator (Docker Compose, Kubernetes, systemd), not by the Console API. Port changes are deployment manifest changes, not runtime API calls. -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. +## Ban policy -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 policy is stored in the Console database as a single source of truth. `PUT /api/servers/bans` saves the policy and fans out `PUT /v1/bans` to all registered server nodes (hbbs and all hbbr instances). The response includes a `synchronization` array showing per-node `applied` or `pending` state. Unreachable nodes are marked `pending`; the health check loop retries synchronization on subsequent intervals. -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. +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. +Servers persist their last applied rules for startup, so a Console 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 persistent ban policy. -## CI validation +## Health monitoring -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. +A scheduled task (`@Interval(30000)`) polls each server's `/v1/status` endpoint every 30 seconds and updates the `status` (`online`, `offline`, `degraded`) and `lastCheckedAt` columns. A server is `online` when its management API responds with `api_version: 1` and the expected `service` name. It is `degraded` when the API responds but the response is incompatible. It is `offline` when the API is unreachable. The health check respects maintenance mode and skips writes when `businessWritesAllowed()` returns false. +## CI validation -### 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. +The repository CI provides permanent build, type, lint, and regression checks. Console CI includes backend tests that verify the direct-connect service, ban fan-out, and permission requirements. 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 and native servers together. diff --git a/src/database/data-source-options.ts b/src/database/data-source-options.ts index bb5d39d..c92d77a 100644 --- a/src/database/data-source-options.ts +++ b/src/database/data-source-options.ts @@ -3,6 +3,7 @@ import { getDbPath, getDbType } from '../common/utils/data-dir.util'; import { DATABASE_ENTITIES } from './entities'; import { InitialSchema1790985600000 } from '../migrations/1790985600000-InitialSchema'; import { FixMySqlDefaultUserGroupIndex1790985600001 } from '../migrations/1790985600001-FixMySqlDefaultUserGroupIndex'; +import { AddServerNodes1790985600002 } from '../migrations/1790985600002-AddServerNodes'; export function createDataSourceOptions(): DataSourceOptions { const common = { @@ -10,6 +11,7 @@ export function createDataSourceOptions(): DataSourceOptions { migrations: [ InitialSchema1790985600000, FixMySqlDefaultUserGroupIndex1790985600001, + AddServerNodes1790985600002, ], migrationsRun: false, synchronize: false, diff --git a/src/database/entities.ts b/src/database/entities.ts index e42a4db..b8ba004 100644 --- a/src/database/entities.ts +++ b/src/database/entities.ts @@ -28,6 +28,8 @@ import { RolePermission } from '../modules/rbac/entities/role-permission.entity' import { UserRoleAssignment } from '../modules/rbac/entities/user-role-assignment.entity'; import { UserRoleAssignmentDeviceGroup } from '../modules/rbac/entities/user-role-assignment-device-group.entity'; import { ConsoleAudit } from '../modules/rbac/entities/console-audit.entity'; +import { ServerNode } from '../modules/server-management/entities/server-node.entity'; +import { ServerBanPolicy } from '../modules/server-management/entities/server-ban-policy.entity'; // Keep the runtime and migration CLI on the same entity set. export const DATABASE_ENTITIES = [ @@ -62,4 +64,6 @@ export const DATABASE_ENTITIES = [ UserRoleAssignment, UserRoleAssignmentDeviceGroup, ConsoleAudit, + ServerNode, + ServerBanPolicy, ]; diff --git a/src/migrations/1790985600002-AddServerNodes.ts b/src/migrations/1790985600002-AddServerNodes.ts new file mode 100644 index 0000000..7e20d33 --- /dev/null +++ b/src/migrations/1790985600002-AddServerNodes.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const SQLITE_STATEMENTS = [ + 'CREATE TABLE "server_nodes" ("guid" varchar PRIMARY KEY NOT NULL, "role" varchar NOT NULL, "name" varchar NOT NULL, "url" varchar NOT NULL, "managementToken" varchar NOT NULL, "status" varchar NOT NULL DEFAULT \'offline\', "lastCheckedAt" datetime, "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')))', + 'CREATE INDEX "IDX_server_nodes_role" ON "server_nodes" ("role")', + 'CREATE TABLE "server_ban_policies" ("id" integer PRIMARY KEY NOT NULL, "deviceIds" text NOT NULL DEFAULT \'[]\', "ips" text NOT NULL DEFAULT \'[]\', "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')))', +] as const; + +const MYSQL_STATEMENTS = [ + 'CREATE TABLE `server_nodes` (`guid` varchar(255) NOT NULL, `role` varchar(255) NOT NULL, `name` varchar(255) NOT NULL, `url` varchar(255) NOT NULL, `managementToken` varchar(255) NOT NULL, `status` varchar(255) NOT NULL DEFAULT \'offline\', `lastCheckedAt` datetime NULL, `createdAt` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updatedAt` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), INDEX `IDX_server_nodes_role` (`role`), PRIMARY KEY (`guid`)) ENGINE=InnoDB', + 'CREATE TABLE `server_ban_policies` (`id` int NOT NULL, `deviceIds` text NOT NULL, `ips` text NOT NULL, `updatedAt` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB', +] as const; + +export class AddServerNodes1790985600002 implements MigrationInterface { + name = 'AddServerNodes1790985600002'; + + async up(queryRunner: QueryRunner): Promise { + const type = queryRunner.connection.options.type; + const statements = + type === 'sqlite' + ? SQLITE_STATEMENTS + : type === 'mysql' + ? MYSQL_STATEMENTS + : null; + if (!statements) throw new Error(`Unsupported database type: ${type}`); + for (const statement of statements) { + await queryRunner.query(statement); + } + } + + down(): Promise { + return Promise.reject( + new Error( + 'Dropping server management tables is not reversible; restore a database backup instead.', + ), + ); + } +} \ No newline at end of file diff --git a/src/modules/rbac/constants/permission-catalog.ts b/src/modules/rbac/constants/permission-catalog.ts index 903495f..08d92e8 100644 --- a/src/modules/rbac/constants/permission-catalog.ts +++ b/src/modules/rbac/constants/permission-catalog.ts @@ -32,6 +32,7 @@ export type PermissionCode = | 'roles.view' | 'roles.assign' | 'servers.view' + | 'servers.manage' | 'servers.control' | 'servers.config' | 'servers.disconnect' @@ -92,6 +93,14 @@ const systemDefinition = ( export const PERMISSION_CATALOG: readonly PermissionDefinition[] = [ definition('servers.view', 'servers', 'view', 'View servers'), + definition( + 'servers.manage', + 'servers', + 'manage', + 'Manage server registry', + 'global', + ['servers.view'], + ), definition( 'servers.control', 'servers', diff --git a/src/modules/server-management/entities/server-ban-policy.entity.ts b/src/modules/server-management/entities/server-ban-policy.entity.ts new file mode 100644 index 0000000..775a7da --- /dev/null +++ b/src/modules/server-management/entities/server-ban-policy.entity.ts @@ -0,0 +1,16 @@ +import { Column, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('server_ban_policies') +export class ServerBanPolicy { + @PrimaryColumn() + id: number; + + @Column({ type: 'text', default: '[]' }) + deviceIds: string; + + @Column({ type: 'text', default: '[]' }) + ips: string; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/src/modules/server-management/entities/server-node.entity.ts b/src/modules/server-management/entities/server-node.entity.ts new file mode 100644 index 0000000..1983a3d --- /dev/null +++ b/src/modules/server-management/entities/server-node.entity.ts @@ -0,0 +1,42 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; + +export type ServerRole = 'hbbs' | 'hbbr'; +export type ServerStatus = 'online' | 'offline' | 'degraded'; + +@Entity('server_nodes') +@Index(['role']) +export class ServerNode { + @PrimaryColumn() + guid: string; + + @Column({ type: 'varchar' }) + role: ServerRole; + + @Column({ type: 'varchar' }) + name: string; + + @Column({ type: 'varchar', select: false }) + url: string; + + @Column({ type: 'varchar', select: false }) + managementToken: string; + + @Column({ type: 'varchar', default: 'offline' }) + status: ServerStatus; + + @Column({ type: 'datetime', nullable: true }) + lastCheckedAt: Date | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/src/modules/server-management/server-config-schema.ts b/src/modules/server-management/server-config-schema.ts new file mode 100644 index 0000000..2b14682 --- /dev/null +++ b/src/modules/server-management/server-config-schema.ts @@ -0,0 +1,47 @@ +export interface ConfigFieldDefinition { + name: string; + default: string; + kind: string; + secret: boolean; + restart_required: boolean; + minimum?: number; + maximum?: number; +} + +export interface ServerConfigSchema { + version: number; + hbbs: ConfigFieldDefinition[]; + hbbr: ConfigFieldDefinition[]; +} + +export const SERVER_CONFIG_SCHEMA: ServerConfigSchema = { + 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 }, + ], +}; \ No newline at end of file diff --git a/src/modules/server-management/server-health.service.ts b/src/modules/server-management/server-health.service.ts new file mode 100644 index 0000000..1a29ea1 --- /dev/null +++ b/src/modules/server-management/server-health.service.ts @@ -0,0 +1,58 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Interval } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { businessWritesAllowed } from '../../updater/maintenance'; +import { ServerNode } from './entities/server-node.entity'; +import { ServerManagementService } from './server-management.service'; + +const HEALTH_CHECK_INTERVAL_MS = 30000; + +@Injectable() +export class ServerHealthService { + private readonly logger = new Logger(ServerHealthService.name); + + constructor( + @InjectRepository(ServerNode) + private readonly nodeRepository: Repository, + private readonly serverManagementService: ServerManagementService, + ) {} + + @Interval(HEALTH_CHECK_INTERVAL_MS) + async handleHealthCheck(): Promise { + if (!businessWritesAllowed()) return; + try { + const nodes = await this.nodeRepository.find(); + await Promise.allSettled(nodes.map((node) => this.checkNode(node))); + } catch (error: unknown) { + const stack = error instanceof Error ? error.stack : String(error); + this.logger.error('Server health check failed', stack); + } + } + + private async checkNode(node: ServerNode): Promise { + try { + const status = await this.serverManagementService.request( + node, + '/v1/status', + ); + if (status.api_version !== 1 || status.service !== node.role) { + await this.updateStatus(node.guid, 'degraded'); + return; + } + await this.updateStatus(node.guid, 'online'); + } catch { + await this.updateStatus(node.guid, 'offline'); + } + } + + private async updateStatus( + guid: string, + status: 'online' | 'offline' | 'degraded', + ): Promise { + await this.nodeRepository.update(guid, { + status, + lastCheckedAt: new Date(), + }); + } +} \ No newline at end of file diff --git a/src/modules/server-management/server-management.controller.ts b/src/modules/server-management/server-management.controller.ts index e9fb714..086a1cf 100644 --- a/src/modules/server-management/server-management.controller.ts +++ b/src/modules/server-management/server-management.controller.ts @@ -1,25 +1,18 @@ import { Body, Controller, - DefaultValuePipe, Delete, Get, Param, - ParseEnumPipe, - ParseIntPipe, + Patch, 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 { CreateServerNodeDto, UpdateServerNodeDto } from './server-node.dto'; +import { ServerBansDto, ServerConfigDto } from './server-management.dto'; import { ServerManagementService } from './server-management.service'; @Controller('servers') @@ -33,109 +26,74 @@ export class ServerManagementController { return this.service.list(); } - @Get(':node/peers') + @Post() + @RequirePermission('servers.manage') + create(@Body() dto: CreateServerNodeDto) { + return this.service.create(dto.role, dto.name, dto.url, dto.managementToken); + } + + @Patch(':id') + @RequirePermission('servers.manage') + update(@Param('id') id: string, @Body() dto: UpdateServerNodeDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RequirePermission('servers.manage') + remove(@Param('id') id: string) { + return this.service.remove(id); + } + + @Get(':id/peers') @RequirePermission('servers.view') - peers(@Param('node') node: string) { - return this.service.request(node, '/v1/peers'); + peers(@Param('id') id: string) { + return this.service.getPeers(id); } - @Get(':node/sessions') + @Get(':id/sessions') @RequirePermission('servers.view') - sessions(@Param('node') node: string) { - return this.service.request(node, '/v1/sessions'); + sessions(@Param('id') id: string) { + return this.service.getSessions(id); } - @Delete(':node/sessions/:uuid') + @Delete(':id/sessions/:uuid') @RequirePermission('servers.disconnect') disconnect( - @Param('node') node: string, + @Param('id') id: 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}`, - ); + return this.service.disconnectSession(actor, id, uuid); } - @Get(':node/services/:service/config') + @Get(':id/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`); + config(@Param('id') id: string) { + return this.service.getConfig(id); } - @Put(':node/services/:service/config') + @Put(':id/config') @RequirePermission('servers.config') saveConfig( - @Param('node') node: string, - @Param('service', new ParseEnumPipe(RustdeskService)) - service: RustdeskService, + @Param('id') id: string, @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', - ); + return this.service.saveConfig(actor, id, dto); } - @Get(':node/bans') + @Get('bans') @RequirePermission('servers.ban') - bans(@Param('node') node: string) { - return this.service.request(node, '/v1/bans'); + bans() { + return this.service.getBans(); } - @Put(':node/bans') + @Put('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, - }); + return this.service.applyBans(actor, dto); } } diff --git a/src/modules/server-management/server-management.dto.ts b/src/modules/server-management/server-management.dto.ts index 8d5788a..623cb81 100644 --- a/src/modules/server-management/server-management.dto.ts +++ b/src/modules/server-management/server-management.dto.ts @@ -9,17 +9,6 @@ import { 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() diff --git a/src/modules/server-management/server-management.module.ts b/src/modules/server-management/server-management.module.ts index cf16d91..b4cab76 100644 --- a/src/modules/server-management/server-management.module.ts +++ b/src/modules/server-management/server-management.module.ts @@ -1,11 +1,18 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { RbacModule } from '../rbac/rbac.module'; +import { ServerBanPolicy } from './entities/server-ban-policy.entity'; +import { ServerNode } from './entities/server-node.entity'; +import { ServerHealthService } from './server-health.service'; import { ServerManagementController } from './server-management.controller'; import { ServerManagementService } from './server-management.service'; @Module({ - imports: [RbacModule], + imports: [ + RbacModule, + TypeOrmModule.forFeature([ServerNode, ServerBanPolicy]), + ], controllers: [ServerManagementController], - providers: [ServerManagementService], + providers: [ServerManagementService, ServerHealthService], }) 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 index 41dc52e..e4aebd6 100644 --- a/src/modules/server-management/server-management.service.spec.ts +++ b/src/modules/server-management/server-management.service.spec.ts @@ -1,7 +1,8 @@ -import { ConfigService } from '@nestjs/config'; -import { NotFoundException } from '@nestjs/common'; +import { NotFoundException, ConflictException } from '@nestjs/common'; import { REQUIRE_PERMISSION_KEY } from '../rbac/decorators/require-permission.decorator'; import { RbacAuditService } from '../rbac/services/rbac-audit.service'; +import { ServerBanPolicy } from './entities/server-ban-policy.entity'; +import { ServerNode } from './entities/server-node.entity'; import { ServerManagementController } from './server-management.controller'; import { ServerManagementService } from './server-management.service'; @@ -11,105 +12,227 @@ jest.mock('uuid', () => { return { v4: cryptoModule.randomUUID }; }); -const node = { - id: 'local', - name: 'Local server', - url: 'http://agent:3001', - token: 'a'.repeat(40), +type MockRepo = { + find: jest.Mock; + findOne: jest.Mock; + count: jest.Mock; + create: jest.Mock; + save: jest.Mock; + remove: jest.Mock; + update: jest.Mock; }; -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) }), +function createMockRepo(): MockRepo { + return { + find: jest.fn(), + findOne: jest.fn(), + count: jest.fn(), + create: jest.fn((entity) => entity), + save: jest.fn(async (entity) => entity), + remove: jest.fn(async () => undefined), + update: jest.fn(async () => undefined), + }; +} + +const hbbsNode: ServerNode = { + guid: 'hbbs-1', + role: 'hbbs', + name: 'Primary hbbs', + url: 'http://hbbs:21120', + managementToken: 'a'.repeat(40), + status: 'online', + lastCheckedAt: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const hbbrNode: ServerNode = { + guid: 'hbbr-1', + role: 'hbbr', + name: 'Primary hbbr', + url: 'http://hbbr:21121', + managementToken: 'b'.repeat(40), + status: 'online', + lastCheckedAt: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe('server management', () => { + let nodeRepo: MockRepo; + let banPolicyRepo: MockRepo; + let audit: { record: jest.Mock; recordDenied: jest.Mock }; + let service: ServerManagementService; + + beforeEach(() => { + nodeRepo = createMockRepo(); + banPolicyRepo = createMockRepo(); + audit = { record: jest.fn(), recordDenied: jest.fn() }; + service = new ServerManagementService( + nodeRepo as never, + banPolicyRepo as never, 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('lists server nodes from the database', async () => { + nodeRepo.find.mockResolvedValue([hbbsNode, hbbrNode]); + const result = await service.list(); + expect(result.data).toHaveLength(2); + }); + + it('enforces at most one hbbs node', async () => { + nodeRepo.count.mockResolvedValue(0); + nodeRepo.findOne.mockResolvedValue(hbbsNode); + await expect( + service.create('hbbs', 'Second hbbs', 'http://hbbs2:21120', 'c'.repeat(40)), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('enforces a maximum node count', async () => { + nodeRepo.count.mockResolvedValue(100); + await expect( + service.create('hbbr', 'Extra', 'http://hbbr2:21121', 'c'.repeat(40)), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('rejects invalid URLs', async () => { + nodeRepo.count.mockResolvedValue(0); + await expect( + service.create('hbbr', 'Bad', 'ftp://hbbr:21121', 'c'.repeat(40)), + ).rejects.toThrow(); + await expect( + service.create('hbbr', 'Bad', 'http://user:pass@hbbr:21121', 'c'.repeat(40)), + ).rejects.toThrow(); + }); + + it('creates a new hbbr node when no hbbs conflict', async () => { + nodeRepo.count.mockResolvedValue(1); + nodeRepo.findOne.mockResolvedValue(null); + const result = await service.create( + 'hbbr', + 'New relay', + 'http://hbbr2:21121', + 'c'.repeat(40), + ); + expect(result.role).toBe('hbbr'); + expect(nodeRepo.save).toHaveBeenCalled(); }); - 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('throws NotFound for unknown node guids', async () => { + nodeRepo.findOne.mockResolvedValue(null); + await expect(service.getNode('unknown')).rejects.toBeInstanceOf( + NotFoundException, + ); }); - it('uses only registered origins and never exposes their credentials', async () => { + it('calls the server management API directly without an agent', async () => { + nodeRepo.findOne.mockResolvedValue(hbbsNode); const fetcher = jest .spyOn(globalThis, 'fetch') .mockResolvedValue( - Response.json({ api_version: 1, node_id: 'local', services: [] }), + Response.json({ api_version: 1, service: 'hbbs' }), ); - const service = create(); - const result = await service.list(); - expect(JSON.stringify(result)).not.toContain(node.token); - expect(JSON.stringify(result)).not.toContain(node.url); + await service.getConfig('hbbs-1'); expect(fetcher).toHaveBeenCalledWith( - 'http://agent:3001/v1/status', + 'http://hbbs:21120/v1/config', expect.objectContaining({ - redirect: 'error', headers: expect.objectContaining({ - authorization: `Bearer ${node.token}`, + authorization: `Bearer ${hbbsNode.managementToken}`, }), }), ); - await expect(service.request('other', '/v1/status')).rejects.toBeInstanceOf( - NotFoundException, + }); + + it('never exposes management tokens in responses', async () => { + nodeRepo.find.mockResolvedValue([hbbsNode]); + const result = await service.list(); + expect(JSON.stringify(result)).not.toContain(hbbsNode.managementToken); + }); + + it('fans out ban policy to all registered nodes', async () => { + nodeRepo.find.mockResolvedValue([hbbsNode, hbbrNode]); + banPolicyRepo.findOne.mockResolvedValue(null); + const fetcher = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(Response.json({})); + const result = await service.applyBans('actor', { + device_ids: ['dev1'], + ips: ['192.0.2.1'], + }); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(fetcher).toHaveBeenCalledWith( + 'http://hbbs:21120/v1/bans', + expect.objectContaining({ method: 'PUT' }), + ); + expect(fetcher).toHaveBeenCalledWith( + 'http://hbbr:21121/v1/bans', + expect.objectContaining({ method: 'PUT' }), ); - expect(fetcher).toHaveBeenCalledTimes(1); + expect(result.synchronization).toHaveLength(2); + expect(result.synchronization.every((s) => s.state === 'applied')).toBe(true); }); - it('audits successful settings changes without logging setting values', async () => { + it('reports pending synchronization for unreachable nodes', async () => { + nodeRepo.find.mockResolvedValue([hbbsNode, hbbrNode]); + banPolicyRepo.findOne.mockResolvedValue(null); 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'] }, + .mockRejectedValueOnce(new Error('Offline')) + .mockResolvedValueOnce(Response.json({})); + const result = await service.applyBans('actor', { + device_ids: [], + ips: [], + }); + expect(result.synchronization).toEqual([ + { guid: 'hbbs-1', role: 'hbbs', state: 'pending' }, + { guid: 'hbbr-1', role: 'hbbr', state: 'applied' }, + ]); + }); + + it('audits successful mutations without logging setting values', async () => { + nodeRepo.findOne.mockResolvedValue(hbbsNode); + jest.spyOn(globalThis, 'fetch').mockResolvedValue( + Response.json({ state: 'saved' }), ); + await service.saveConfig('actor', 'hbbs-1', { + values: { key: 'private-key-material' }, + }); expect(audit.record).toHaveBeenCalledWith( - expect.objectContaining({ targetGuid: 'local', result: 'allowed' }), + expect.objectContaining({ targetGuid: 'hbbs-1', 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')); + it('records denied audit when server operation fails', async () => { + nodeRepo.findOne.mockResolvedValue(hbbsNode); + jest.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Down')); 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: [] }], - }); + service.saveConfig('actor', 'hbbs-1', { values: {} }), + ).rejects.toThrow(); + expect(audit.recordDenied).toHaveBeenCalledWith( + expect.objectContaining({ targetGuid: 'hbbs-1' }), + ); }); - it('requires global server permissions, not device permissions', () => { + it('requires global server permissions', () => { const prototype = ServerManagementController.prototype; expect( - Reflect.getMetadata(REQUIRE_PERMISSION_KEY, prototype.action), - ).toEqual(['servers.control']); + Reflect.getMetadata(REQUIRE_PERMISSION_KEY, prototype.create), + ).toEqual(['servers.manage']); + expect( + Reflect.getMetadata(REQUIRE_PERMISSION_KEY, prototype.update), + ).toEqual(['servers.manage']); + expect( + Reflect.getMetadata(REQUIRE_PERMISSION_KEY, prototype.remove), + ).toEqual(['servers.manage']); expect( Reflect.getMetadata(REQUIRE_PERMISSION_KEY, prototype.saveConfig), ).toEqual(['servers.config']); diff --git a/src/modules/server-management/server-management.service.ts b/src/modules/server-management/server-management.service.ts index a1e3e95..5a1f284 100644 --- a/src/modules/server-management/server-management.service.ts +++ b/src/modules/server-management/server-management.service.ts @@ -1,129 +1,131 @@ import { BadGatewayException, BadRequestException, + ConflictException, + ForbiddenException, GatewayTimeoutException, Injectable, Logger, NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; import { RbacAuditService } from '../rbac/services/rbac-audit.service'; +import { ServerBanPolicy } from './entities/server-ban-policy.entity'; +import { ServerNode, ServerRole } from './entities/server-node.entity'; +import { ServerBansDto, ServerConfigDto } from './server-management.dto'; -interface NodeConfig { - id: string; - name: string; - url: string; - token: string; -} +const BAN_POLICY_ID = 1; +const MAX_NODES = 100; @Injectable() export class ServerManagementService { - private readonly nodes: NodeConfig[]; private readonly logger = new Logger(ServerManagementService.name); constructor( - config: ConfigService, + @InjectRepository(ServerNode) + private readonly nodeRepository: Repository, + @InjectRepository(ServerBanPolicy) + private readonly banPolicyRepository: Repository, private readonly audit: RbacAuditService, + ) {} + + async list() { + const nodes = await this.nodeRepository.find(); + return { data: nodes }; + } + + async create( + role: ServerRole, + name: string, + url: string, + managementToken: string, ) { - 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 count = await this.nodeRepository.count(); + if (count >= MAX_NODES) { + throw new ConflictException('Server node limit reached'); } - 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'); + if (role === 'hbbs') { + const existing = await this.nodeRepository.findOne({ + where: { role: 'hbbs' }, + }); + if (existing) { + throw new ConflictException('Only one hbbs node is supported'); } - identifiers.add(node.id); - return { ...node, url: url.origin }; + } + const normalizedUrl = this.validateUrl(url); + const node = this.nodeRepository.create({ + guid: uuidv4(), + role, + name: name.trim(), + url: normalizedUrl, + managementToken, + status: 'offline', + lastCheckedAt: null, }); + return this.nodeRepository.save(node); } - 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 update( + guid: string, + patch: { name?: string; url?: string; managementToken?: string }, + ) { + const node = await this.nodeRepository.findOne({ where: { guid } }); + if (!node) throw new NotFoundException('Server node not found'); + if (patch.name !== undefined) node.name = patch.name.trim(); + if (patch.url !== undefined) node.url = this.validateUrl(patch.url); + if (patch.managementToken !== undefined) + node.managementToken = patch.managementToken; + return this.nodeRepository.save(node); + } + + async remove(guid: string) { + const node = await this.nodeRepository.findOne({ where: { guid } }); + if (!node) throw new NotFoundException('Server node not found'); + await this.nodeRepository.remove(node); + } + + async getNode(guid: string): Promise { + const node = await this.nodeRepository.findOne({ where: { guid } }); + if (!node) throw new NotFoundException('Server node not found'); + return node; } async request( - nodeId: string, + node: ServerNode, 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}`, + authorization: `Bearer ${node.managementToken}`, 'content-type': 'application/json', }, signal: AbortSignal.timeout(method === 'GET' ? 10000 : 240000), ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); } catch { - throw new ServiceUnavailableException('Server node unavailable'); + throw new ServiceUnavailableException('Server management API unavailable'); } if (!response.ok) { if (response.status === 400) { throw new BadRequestException('Invalid server management request'); } + if (response.status === 403) { + throw new ForbiddenException('Server management credentials rejected'); + } if (response.status === 404) { throw new NotFoundException('Server resource not found'); } if (response.status === 409) { - throw new BadRequestException('Server configuration conflicts'); + throw new ConflictException('Server configuration conflicts'); } if (response.status === 504) { throw new GatewayTimeoutException( @@ -133,7 +135,7 @@ export class ServerManagementService { throw new BadGatewayException('Server management operation failed'); } const reader = response.body?.getReader(); - if (!reader) throw new BadGatewayException('Empty node response'); + if (!reader) throw new BadGatewayException('Empty server response'); let size = 0; const chunks: Buffer[] = []; try { @@ -153,7 +155,7 @@ export class ServerManagementService { } return result as Record; } catch { - throw new BadGatewayException('Invalid node management response'); + throw new BadGatewayException('Invalid server management response'); } finally { reader.releaseLock(); } @@ -161,7 +163,7 @@ export class ServerManagementService { async mutate( actor: string, - nodeId: string, + node: ServerNode, path: string, method: string, body?: unknown, @@ -169,12 +171,12 @@ export class ServerManagementService { ) { let result: Record; try { - result = await this.request(nodeId, path, method, body); + result = await this.request(node, path, method, body); } catch (error) { await this.audit.recordDenied({ actorUserGuid: actor, targetType: 'server', - targetGuid: nodeId, + targetGuid: node.guid, action: `${method} ${path}`, reason: 'Server management operation failed', }); @@ -184,7 +186,7 @@ export class ServerManagementService { await this.audit.record({ actorUserGuid: actor, targetType: 'server', - targetGuid: nodeId, + targetGuid: node.guid, action: `${method} ${path}`, result: 'allowed', afterState: details, @@ -194,4 +196,156 @@ export class ServerManagementService { } return result; } + + async getPeers(guid: string) { + const node = await this.getNode(guid); + if (node.role !== 'hbbs') { + throw new BadRequestException('Peers are only available on hbbs nodes'); + } + return this.request(node, '/v1/peers'); + } + + async getSessions(guid: string) { + const node = await this.getNode(guid); + if (node.role !== 'hbbr') { + throw new BadRequestException( + 'Sessions are only available on hbbr nodes', + ); + } + return this.request(node, '/v1/sessions'); + } + + async disconnectSession(actor: string, guid: string, uuid: string) { + const node = await this.getNode(guid); + if (node.role !== 'hbbr') { + throw new BadRequestException( + 'Session disconnect is only available on hbbr nodes', + ); + } + return this.mutate( + actor, + node, + `/v1/sessions/${encodeURIComponent(uuid)}`, + 'DELETE', + ); + } + + async getConfig(guid: string) { + const node = await this.getNode(guid); + return this.request(node, '/v1/config'); + } + + async saveConfig(actor: string, guid: string, dto: ServerConfigDto) { + const node = await this.getNode(guid); + return this.mutate(actor, node, '/v1/config', 'PUT', dto, { + settings: Object.keys(dto.values), + }); + } + + async getBans() { + const policy = await this.banPolicyRepository.findOne({ + where: { id: BAN_POLICY_ID }, + }); + if (!policy) { + return { device_ids: [], ips: [] }; + } + return { + device_ids: JSON.parse(policy.deviceIds) as string[], + ips: JSON.parse(policy.ips) as string[], + }; + } + + async applyBans(actor: string, dto: ServerBansDto) { + const normalized = { + device_ids: [...new Set(dto.device_ids)].sort(), + ips: [ + ...new Set( + dto.ips.map((ip) => + ip.toLowerCase().startsWith('::ffff:') ? ip.slice(7) : ip, + ), + ), + ].sort(), + }; + + let policy = await this.banPolicyRepository.findOne({ + where: { id: BAN_POLICY_ID }, + }); + if (!policy) { + policy = this.banPolicyRepository.create({ + id: BAN_POLICY_ID, + deviceIds: JSON.stringify(normalized.device_ids), + ips: JSON.stringify(normalized.ips), + }); + } else { + policy.deviceIds = JSON.stringify(normalized.device_ids); + policy.ips = JSON.stringify(normalized.ips); + } + await this.banPolicyRepository.save(policy); + + const nodes = await this.nodeRepository.find(); + const results = await Promise.allSettled( + nodes.map(async (node) => { + await this.request(node, '/v1/bans', 'PUT', normalized); + return { guid: node.guid, role: node.role, state: 'applied' }; + }), + ); + const synchronization = results.map((result, index) => { + const node = nodes[index]; + if (result.status === 'fulfilled') return result.value; + this.logger.warn( + `Ban synchronization failed for node ${node.guid}: ${result.reason}`, + ); + return { guid: node.guid, role: node.role, state: 'pending' }; + }); + + try { + await this.audit.record({ + actorUserGuid: actor, + targetType: 'server', + targetGuid: null, + action: 'PUT /v1/bans', + result: 'allowed', + afterState: { + device_ids: normalized.device_ids.length, + ips: normalized.ips.length, + }, + }); + } catch { + this.logger.warn('Unable to persist server ban audit'); + } + + return { ...normalized, synchronization }; + } + + async syncBansToNode(node: ServerNode): Promise { + const policy = await this.banPolicyRepository.findOne({ + where: { id: BAN_POLICY_ID }, + }); + if (!policy) return true; + const bans = { + device_ids: JSON.parse(policy.deviceIds) as string[], + ips: JSON.parse(policy.ips) as string[], + }; + try { + await this.request(node, '/v1/bans', 'PUT', bans); + return true; + } catch { + return false; + } + } + + private validateUrl(url: string): string { + const parsed = new URL(url); + if ( + !['http:', 'https:'].includes(parsed.protocol) || + parsed.username || + parsed.password || + parsed.search || + parsed.hash || + parsed.pathname !== '/' + ) { + throw new BadRequestException('Server URL must be an HTTP(S) origin'); + } + return parsed.origin; + } } diff --git a/src/modules/server-management/server-node.dto.ts b/src/modules/server-management/server-node.dto.ts new file mode 100644 index 0000000..1f731e4 --- /dev/null +++ b/src/modules/server-management/server-node.dto.ts @@ -0,0 +1,47 @@ +import { + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + IsUrl, + MaxLength, + MinLength, +} from 'class-validator'; + +export class CreateServerNodeDto { + @IsEnum(['hbbs', 'hbbr']) + role: 'hbbs' | 'hbbr'; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + name: string; + + @IsString() + @IsNotEmpty() + @IsUrl({ require_tld: false, require_protocol: true, protocols: ['http', 'https'] }) + url: string; + + @IsString() + @MinLength(32) + managementToken: string; +} + +export class UpdateServerNodeDto { + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + @IsUrl({ require_tld: false, require_protocol: true, protocols: ['http', 'https'] }) + url?: string; + + @IsOptional() + @IsString() + @MinLength(32) + managementToken?: string; +} \ No newline at end of file