UNPKG

tyo-mq

Version:

Distributed Message Pub/Sub Service with socket.io

1,253 lines (1,111 loc) 151 kB
/** * Messaging Server */ 'esversion: 6'; const crypto = require('crypto'); const fs = require('fs'); const http = require('http'); const https = require('https'); const path = require('path'); const pak = require('../package.json'); const env = require('./env'); const adminSignature = require('./admin-signature'); const attachRemoteNamespace = require('./remote-namespace'); const ClusterSync = require('./cluster'); // info var eventManager = require('./events'); const Constants = require('./constants'); const Logger = require('./logger'); const Settings = require('./settings'); const Storage = require('./storage'); function Server(options) { this.options = options || {}; // Hot-loadable settings store — seeded with constructor options. // Call server.loadSettings(path) to watch a JSON file for live updates. this.settings = new Settings(this.options); // Convenience alias kept for backwards compatibility; always reads live value. Object.defineProperty(this, 'authOptions', { get: function () { return this.settings.get('auth') || {}; }, enumerable: true }); var server = this; var remoteNsp = null; var remoteIo = null; this.logger = new Logger('tyo-mq', { level: Logger.LOG }); this.store = Storage.createStore(this.options); Object.defineProperty(this, 'remote', { get: function () { return remoteNsp; }, enumerable: true }); var app = http.createServer((req, res) => { if (!handleHttpApiRequest(req, res)) { res.writeHead(403); res.end(); } }); // Default to 50 MB so large messages don't silently drop the connection. // Callers can override by passing maxHttpBufferSize in options. var ioOptions = Object.assign({ maxHttpBufferSize: 50 * 1024 * 1024 }, options); var io = require('socket.io')(app, ioOptions); // app.listen(this.options.port || Constants.DEFAULT_PORT); var DEFAULT_REALM = 'default'; var REALM_ALL = '*'; var realms = {}; var authorizationRequests = []; var authorizationRequestMap = {}; var authorizationRequestClientMap = {}; var usedManagerNonces = new Set(); var loadedSettingsFile = null; var applyingPersistenceChange = false; var clusterSync = null; var applyingRemoteSettings = false; Object.defineProperty(this, 'cluster', { get: function () { return clusterSync; }, enumerable: true }); var getEnvPath = function () { return server.authOptions.env_file || process.env.TYO_MQ_ENV_FILE || '.env'; }; var getAdminTokenEnvName = function () { return server.authOptions.admin_token_env || 'TYO_MQ_ADMIN_TOKEN'; }; var generateToken = function () { return crypto.randomBytes(32).toString('hex'); }; var ensureRemoteNamespace = function (socketIo) { if (remoteNsp && remoteIo === socketIo) return remoteNsp; remoteIo = socketIo; remoteNsp = attachRemoteNamespace(socketIo, server.options.remote || {}); return remoteNsp; }; var hasAdminToken = function (auth) { var tokens = (auth && auth.tokens) || []; for (var i = 0; i < tokens.length; i++) { if (tokens[i].token && tokens[i].realm === REALM_ALL && tokens[i].role === 'admin') return true; } return false; }; var getAdminTokens = function () { var tokens = (server.authOptions && server.authOptions.tokens) || []; return tokens.filter(function (item) { return item && item.token && item.realm === REALM_ALL && item.role === 'admin'; }).map(function (item) { return item.token; }); }; var validateManagerProofEnvelope = function (proof) { if (!proof || !proof.nonce) return {ok: false, code: 401, message: 'Missing manager proof'}; if (usedManagerNonces.has(proof.nonce)) return {ok: false, code: 401, message: 'Manager proof nonce was already used'}; return {ok: true}; }; var verifyProofWithSecrets = function (secrets, action, body, proof) { for (var i = 0; i < secrets.length; i++) { if (secrets[i] && adminSignature.verifyAdminProof(secrets[i], action, body || {}, proof)) return true; } return false; }; var markManagerProofUsed = function (proof) { usedManagerNonces.add(proof.nonce); }; var getRealmConfig = function (realm) { var auth = server.authOptions || {}; var authRealms = auth.realms || {}; return authRealms[realm] || null; }; // A name is "anonymous" when it is the bare ANONYMOUS placeholder or a // client-minted unique ANONYMOUS-<uuid> identity (no app_id was configured). var isAnonymousName = function (name) { return name === Constants.ANONYMOUS || (typeof name === 'string' && name.indexOf(Constants.ANONYMOUS + '-') === 0); }; // Whether unnamed (ANONYMOUS / no app_id) producers and consumers may // register in a realm. An explicit realms.<id>.allow_anonymous wins; // otherwise only the open 'default' realm permits anonymous — configured // and named realms reject it unless they opt in. var isAnonymousAllowed = function (realmId) { var rc = getRealmConfig(realmId); if (rc) { if (typeof rc.allow_anonymous === 'boolean') return rc.allow_anonymous; if (typeof rc.allowAnonymous === 'boolean') return rc.allowAnonymous; } return realmId === DEFAULT_REALM; }; var getRealmManagerKey = function (realm) { var realmConfig = getRealmConfig(realm); if (!realmConfig) return null; return realmConfig.manager_key || realmConfig.managerKey || null; }; var verifyGlobalManagerProof = function (action, body, proof) { var envelope = validateManagerProofEnvelope(proof); if (!envelope.ok) return envelope; if (verifyProofWithSecrets(getAdminTokens(), action, body, proof)) { markManagerProofUsed(proof); return {ok: true, scope: 'global'}; } return {ok: false, code: 401, message: 'Invalid manager proof'}; }; var verifyAuthorizationNextProof = function (body, proof) { body = body || {}; var envelope = validateManagerProofEnvelope(proof); if (!envelope.ok) return envelope; if (verifyProofWithSecrets(getAdminTokens(), 'AUTHORIZATION_NEXT', body, proof)) { markManagerProofUsed(proof); return {ok: true, scope: 'global'}; } if (!body.realm) return {ok: false, code: 401, message: 'Invalid manager proof'}; var managerKey = getRealmManagerKey(body.realm); if (managerKey && adminSignature.verifyAdminProof(managerKey, 'AUTHORIZATION_NEXT', body, proof)) { markManagerProofUsed(proof); return {ok: true, scope: 'realm', realm: body.realm}; } return {ok: false, code: 401, message: 'Invalid manager proof'}; }; var verifyAuthorizationDecisionProof = function (body, proof, request) { body = body || {}; var envelope = validateManagerProofEnvelope(proof); if (!envelope.ok) return envelope; if (verifyProofWithSecrets(getAdminTokens(), 'AUTHORIZATION_DECIDE', body, proof)) { markManagerProofUsed(proof); return {ok: true, scope: 'global'}; } if (!request || request.status !== 'pending') return {ok: false, code: 401, message: 'Invalid manager proof'}; var managerKey = getRealmManagerKey(request.realm); if (managerKey && adminSignature.verifyAdminProof(managerKey, 'AUTHORIZATION_DECIDE', body, proof)) { markManagerProofUsed(proof); return {ok: true, scope: 'realm', realm: request.realm}; } return {ok: false, code: 401, message: 'Invalid manager proof'}; }; var ensureAdminToken = function () { var auth = server.authOptions || {}; if (!auth.enabled || hasAdminToken(auth)) return; var envFile = getEnvPath(); var tokenEnv = getAdminTokenEnvName(); env.loadEnvFile(envFile); var token = process.env[tokenEnv]; if (!token) { if (auth.auto_admin_token === false) return; token = generateToken(); env.appendEnvValue(envFile, tokenEnv, token); if (server.logger) server.logger.warn("Generated admin auth token and saved it to " + envFile + " as " + tokenEnv); } var nextAuth = Object.assign({}, auth, { tokens: (auth.tokens || []).concat([{token: token, realm: REALM_ALL, role: 'admin'}]) }); server.settings.merge({auth: nextAuth}); }; var hashToken = function (token) { return crypto.createHash('sha256').update(String(token)).digest('hex'); }; var publicAuthorizationRequest = function (request) { if (!request) return null; return { request_id: request.request_id, status: request.status, realm: request.realm, role: request.role, client_id: request.client_id, client_name: request.client_name, client_token_hash: request.client_token_hash, challenge_response: request.challenge_response, created_at: request.created_at, decided_at: request.decided_at || null, decision_reason: request.decision_reason || null }; }; var addRuntimeAuthToken = function (token, realm, role, meta) { var auth = server.authOptions || {}; var tokens = auth.tokens || []; for (var i = 0; i < tokens.length; i++) { if (tokens[i].token === token) return; } var entry = Object.assign({}, meta || {}, { token: token, realm: realm, role: normalizeRole(role) }); server.settings.merge({ auth: Object.assign({}, auth, {tokens: tokens.concat([entry])}) }); }; var clone = function (obj) { return JSON.parse(JSON.stringify(obj || {})); }; var createStoreFromSettings = function (settings) { settings = settings || {}; var storageOptions = Object.assign({}, settings.storage_options || settings.storageOptions || {}); var storage = settings.storage || settings.store || 'memory'; if (storage === 'custom') { var modulePath = storageOptions.module || storageOptions.module_path || settings.storage_module; if (!modulePath) throw new Error('custom storage requires storage_options.module'); storage = require(path.isAbsolute(modulePath) ? modulePath : path.resolve(process.cwd(), modulePath)); } return Storage.createStore({ storage: storage, storage_options: storageOptions }); }; var publicPersistenceSettings = function () { var allSettings = server.settings.get() || {}; var storage = allSettings.storage || allSettings.store || 'memory'; var storageOptions = clone(allSettings.storage_options || allSettings.storageOptions || {}); if (storage && typeof storage !== 'string') storage = 'custom'; return { storage: storage, storage_options: storageOptions }; }; var persistenceSignature = function (settings) { settings = settings || {}; return JSON.stringify({ storage: settings.storage || settings.store || 'memory', storage_options: settings.storage_options || settings.storageOptions || {} }); }; var publicManagementSettings = function () { var auth = publicAuthSettings(); auth.persistence = publicPersistenceSettings(); return auth; }; var normalizePersistenceSettings = function (body) { var storage = String(body.storage || body.backend || 'memory').toLowerCase(); var allowed = {memory: true, sqlite: true, redis: true, custom: true}; if (!allowed[storage]) return {ok: false, code: 400, message: 'unsupported storage backend'}; var storageOptions = Object.assign({}, body.storage_options || body.storageOptions || body.options || {}); if (body.default_ttl !== undefined && body.default_ttl !== '') storageOptions.default_ttl = Number(body.default_ttl); if (!Number.isFinite(Number(storageOptions.default_ttl)) && storageOptions.default_ttl !== undefined) return {ok: false, code: 400, message: 'default_ttl must be a number'}; if (storage === 'sqlite') { if (body.filename) storageOptions.filename = body.filename; if (body.path) storageOptions.filename = body.path; } if (storage === 'redis') { if (body.url) storageOptions.url = body.url; if (body.prefix) storageOptions.prefix = body.prefix; } if (storage === 'custom') { if (body.module) storageOptions.module = body.module; if (!storageOptions.module && !storageOptions.module_path) return {ok: false, code: 400, message: 'custom storage requires a module path'}; } return { ok: true, settings: { storage: storage, storage_options: storageOptions } }; }; var applyPersistenceSettings = function (persistence) { var nextStore = createStoreFromSettings(persistence); var previousStore = server.store; server.store = nextStore; if (previousStore && previousStore !== nextStore && typeof previousStore.close === 'function') { try { var closeResult = previousStore.close(); if (closeResult && typeof closeResult.catch === 'function') closeResult.catch(function (err) { server.logger.error("Previous storage close failed: " + err.message); }); } catch (err) { server.logger.error("Previous storage close failed: " + err.message); } } }; var revokeAuthTokens = function (auth, body) { var token = body.token || null; var tokenHash = body.token_hash || body.tokenHash || null; var realm = body.realm || null; var clientId = body.client_id || body.clientId || null; var allowAdmin = !!body.allow_admin; if (!token && !tokenHash && !(realm && clientId)) return {ok: false, code: 400, message: 'token, token_hash, or realm + client_id is required'}; var removed = []; var kept = []; auth.tokens.forEach(function (entry) { var matches = false; if (token && entry.token === token) matches = true; if (tokenHash && entry.token && hashToken(entry.token) === tokenHash) matches = true; if (realm && clientId && entry.realm === realm && entry.client_id === clientId) matches = true; if (matches) { if (entry.realm === REALM_ALL && entry.role === 'admin' && !allowAdmin) { kept.push(entry); return; } removed.push(entry); return; } kept.push(entry); }); if (removed.length === 0) return {ok: false, code: 404, message: 'matching revocable token not found'}; auth.tokens = kept; return { ok: true, revoked: removed.map(function (entry) { return { realm: entry.realm, role: entry.role, client_id: entry.client_id || null, client_name: entry.client_name || null, token_hash: entry.token ? hashToken(entry.token) : null }; }) }; }; var publicAuthSettings = function () { var auth = clone(server.authOptions || {}); auth.tokens = (auth.tokens || []).map(function (token) { return { realm: token.realm, role: token.role, client_id: token.client_id || null, client_name: token.client_name || null, token_hash: token.token ? hashToken(token.token) : null }; }); if (auth.realms) { Object.keys(auth.realms).forEach(function (realm) { var realmConfig = auth.realms[realm] || {}; if (realmConfig.manager_key || realmConfig.managerKey) { delete realmConfig.manager_key; delete realmConfig.managerKey; realmConfig.manager_key_configured = true; } if (realmConfig.key || realmConfig.preshared_key || realmConfig.presharedKey) { delete realmConfig.key; delete realmConfig.preshared_key; delete realmConfig.presharedKey; realmConfig.key_configured = true; } }); } if (auth.jwt_secret) auth.jwt_secret = '<configured>'; if (auth.auth_secret) auth.auth_secret = '<configured>'; if (auth.management_tokens || auth.managementTokens) { auth.management_tokens = (auth.management_tokens || auth.managementTokens || []).map(function (entry) { entry = entry || {}; return { realm_prefix: entry.realm_prefix || entry.realmPrefix || null, token_hash: entry.token ? hashToken(entry.token) : null }; }); delete auth.managementTokens; } return auth; }; var writeSettingsFile = function () { if (!loadedSettingsFile) return; var dir = path.dirname(loadedSettingsFile); if (!fs.existsSync(dir)) fs.mkdirSync(dir, {recursive: true}); fs.writeFileSync(loadedSettingsFile, JSON.stringify(server.settings.get(), null, 2) + '\n'); }; var persistSettings = function () { writeSettingsFile(); if (clusterSync && !applyingRemoteSettings) { clusterSync.publishSettings(server.settings.get()).catch(function (err) { server.logger.error("Cluster settings publish failed: " + err.message); }); } }; // A peer published new settings: adopt them as the source of truth and // refresh the local file cache, without re-publishing (echo guard). var applyRemoteSettings = function (remoteSettings, revision) { applyingRemoteSettings = true; try { server.settings.replace(remoteSettings); } finally { applyingRemoteSettings = false; } writeSettingsFile(); server.logger.info("Cluster settings applied" + (revision !== undefined ? " (revision " + revision + ")" : "")); }; var initCluster = function () { if (clusterSync) return; // The raw constructor options keep injected redis clients intact; // the settings store (JSON-cloned) covers file-based configuration. var config = server.options.cluster || server.settings.get('cluster'); if (!config || !config.enabled) return; var allSettings = server.settings.get() || {}; var storageOptions = allSettings.storage_options || allSettings.storageOptions || {}; try { clusterSync = new ClusterSync({ prefix: config.prefix || config.channel_prefix || config.channelPrefix, redis_url: config.redis_url || config.redisUrl || config.url || storageOptions.url, client: config.client, subscriber: config.subscriber, node_id: config.node_id || config.nodeId, logger: server.logger }); } catch (err) { server.logger.error("Cluster sync init failed: " + err.message); clusterSync = null; return; } clusterSync.ready = clusterSync.connect().then(function () { return clusterSync.onSettingsChange(function (settings, revision) { applyRemoteSettings(settings, revision); }); }).then(function () { return clusterSync.fetchSettings(); }).then(function (doc) { if (doc && doc.settings) applyRemoteSettings(doc.settings); else return clusterSync.publishSettings(server.settings.get()); }).then(function () { server.logger.log("Cluster sync active (node " + clusterSync.nodeId + ")"); }).catch(function (err) { server.logger.error("Cluster sync startup failed: " + err.message); }); }; var NONCE_CLAIM_TTL_MS = 10 * 60 * 1000; // 2x the proof timestamp window // Cluster-wide replay protection: a manager proof nonce may be used on // exactly one node. Resolves true when this node won the claim (or no // cluster is configured — the local nonce set still protects the node). var claimClusterNonce = function (proof) { if (!clusterSync || !proof || !proof.nonce) return Promise.resolve(true); return clusterSync.claimNonce(proof.nonce, NONCE_CLAIM_TTL_MS).catch(function (err) { server.logger.error("Cluster nonce claim failed: " + err.message); return true; // fail open: redis outage must not lock out managers }); }; // ── shared authorization requests ───────────────────────────────────── // In a cluster, pending authorization requests live in Redis so a manager // can poll and decide them from any node. The local in-memory maps keep // working unchanged for single-node deployments. var mirrorAuthRequestToCluster = function (request) { if (!clusterSync) return; clusterSync.getAuthRequests().then(function (doc) { Object.keys(doc).forEach(function (id) { var existing = doc[id]; if (existing.status === 'pending' && existing.realm === request.realm && existing.client_id === request.client_id) { existing.status = 'superseded'; existing.superseded_at = request.created_at; } }); doc[request.request_id] = request; return clusterSync.putAuthRequests(doc); }).catch(function (err) { server.logger.error("Cluster auth request mirror failed: " + err.message); }); }; var findNextAuthorizationRequest = function (filter) { if (!clusterSync) return Promise.resolve(nextPendingAuthorizationRequest(filter)); return clusterSync.getAuthRequests().then(function (doc) { var pending = Object.keys(doc).map(function (id) { return doc[id]; }) .filter(function (request) { if (request.status !== 'pending') return false; if (filter && filter.realm && request.realm !== filter.realm) return false; return true; }) .sort(function (a, b) { return String(a.created_at).localeCompare(String(b.created_at)); }); return pending[0] || null; }).catch(function (err) { server.logger.error("Cluster auth request lookup failed: " + err.message); return nextPendingAuthorizationRequest(filter); }); }; var lookupAuthorizationRequest = function (requestId) { var local = authorizationRequestMap[requestId]; if (!clusterSync) return Promise.resolve(local); return clusterSync.getAuthRequests().then(function (doc) { return doc[requestId] || local; }).catch(function () { return local; }); }; var persistAuthDecisionToCluster = function (request) { if (!clusterSync) return; clusterSync.getAuthRequests().then(function (doc) { doc[request.request_id] = request; return clusterSync.putAuthRequests(doc); }).then(function () { return clusterSync.publishAuthDecision(request); }).catch(function (err) { server.logger.error("Cluster auth decision publish failed: " + err.message); }); }; var isLocalRequester = function (request) { return !clusterSync || !request.node_id || request.node_id === clusterSync.nodeId; }; var syncLocalAuthRequest = function (request) { var local = authorizationRequestMap[request.request_id]; if (local && local !== request) { local.status = request.status; local.decided_at = request.decided_at; local.decision_reason = request.decision_reason; local.role = request.role; } }; // ── opt-in HTTP observability surface (Phase 5, revised) ───────────────── // // Enabled with `http_api: { enabled: true }` (constructor options or // settings file) and served on the SAME port as the socket server. // Read-only: /health, /api/metrics, /api/stats, /api/realms/{realm}/dlq. // Disabled by default — without the option no HTTP endpoint exists. var currentIo = null; var metricsCounters = {}; var incMetric = function (name, labels) { var labelKeys = labels ? Object.keys(labels).sort() : []; var key = name + '|' + labelKeys.map(function (k) { return k + '=' + labels[k]; }).join(','); var entry = metricsCounters[key] = metricsCounters[key] || {name: name, labels: labels || {}, value: 0}; entry.value++; }; var escapeLabelValue = function (value) { return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); }; var renderPrometheusMetrics = function () { var lines = []; var byName = {}; Object.keys(metricsCounters).forEach(function (key) { var entry = metricsCounters[key]; byName[entry.name] = byName[entry.name] || []; byName[entry.name].push(entry); }); Object.keys(byName).sort().forEach(function (name) { lines.push('# TYPE ' + name + ' counter'); byName[name].forEach(function (entry) { var labelKeys = Object.keys(entry.labels).sort(); var labelStr = labelKeys.length ? '{' + labelKeys.map(function (k) { return k + '="' + escapeLabelValue(entry.labels[k]) + '"'; }).join(',') + '}' : ''; lines.push(name + labelStr + ' ' + entry.value); }); }); var connected = connectionsCurrent(); if (connected !== null) { lines.push('# TYPE tyo_mq_connections_current gauge'); lines.push('tyo_mq_connections_current ' + connected); } return lines.join('\n') + '\n'; }; var connectionsCurrent = function () { if (currentIo && currentIo.engine && typeof currentIo.engine.clientsCount === 'number') return currentIo.engine.clientsCount; return null; }; var buildStats = function () { var stats = {realms: {}, connections_current: connectionsCurrent()}; Object.keys(realms).forEach(function (realmId) { var realm = realms[realmId]; var producers = {total: 0, online: 0}; var consumers = {total: 0, online: 0}; Object.keys(realm.producers || {}).forEach(function (name) { producers.total++; if (realm.producers[name].socket) producers.online++; }); Object.keys(realm.consumers || {}).forEach(function (name) { consumers.total++; if (realm.consumers[name].socket) consumers.online++; }); stats.realms[realmId] = { producers: producers, consumers: consumers, subscriptions: Object.keys(realm.subscriptions || {}).length }; }); if (clusterSync) stats.cluster_node_id = clusterSync.nodeId; return stats; }; // Public: snapshot of realm/producer/consumer/subscription counts. this.getStats = function () { return buildStats(); }; var getHttpApiConfig = function () { var config = server.options.http_api || server.options.httpApi || server.settings.get('http_api') || server.settings.get('httpApi'); return (config && config.enabled) ? config : null; }; var httpAuthOk = function (req) { if (!isAuthEnabled()) return true; var header = req.headers['authorization'] || ''; var match = header.match(/^Bearer\s+(.+)$/i); if (!match) return false; var provided = hashToken(match[1]); return getAdminTokens().some(function (token) { return hashToken(token) === provided; }); }; var sendJson = function (res, status, body) { res.writeHead(status, {'content-type': 'application/json; charset=utf-8'}); res.end(JSON.stringify(body)); }; var managementTokens = function () { var auth = server.authOptions || {}; return (auth.management_tokens || auth.managementTokens || []).filter(function (entry) { return entry && entry.token && entry.realm_prefix; }); }; var managementTokenForRequest = function (req) { var header = req.headers['authorization'] || ''; var match = header.match(/^Bearer\s+(.+)$/i); if (!match) return null; var provided = hashToken(match[1]); var tokens = managementTokens(); for (var i = 0; i < tokens.length; i++) { if (hashToken(tokens[i].token) === provided) return {realm_prefix: tokens[i].realm_prefix}; } return null; }; // A management token may only touch realms under its prefix, and never the // wildcard/default realms or the bare prefix itself. var realmAllowedForPrefix = function (realm, prefix) { if (!prefix || typeof realm !== 'string') return false; if (realm === REALM_ALL || realm === DEFAULT_REALM) return false; return realm.indexOf(prefix) === 0 && realm.length > prefix.length; }; // Read a bounded JSON request body. Calls back with (err, obj); an empty // body yields {}. Rejects bodies over maxBytes to avoid unbounded buffering. var readJsonBody = function (req, maxBytes, callback) { var chunks = []; var total = 0; var done = false; var finish = function (err, obj) { if (done) return; done = true; callback(err, obj); }; req.on('data', function (chunk) { total += chunk.length; if (total > maxBytes) { finish(new Error('request body too large'), null); return; } chunks.push(chunk); }); req.on('end', function () { var raw = Buffer.concat(chunks).toString('utf8'); if (!raw) { finish(null, {}); return; } try { finish(null, JSON.parse(raw)); } catch (err) { finish(err, null); } }); req.on('error', function (err) { finish(err, null); }); }; // POST /api/realms — create or rotate a realm's manager_key. Returns 404 when // the endpoint is unconfigured (auth off or no management tokens), 401 without a // valid management bearer token, 400 on an invalid body, 403 for a realm outside // the token's prefix, otherwise creates or rotates the realm via // applyAuthManagementCommand and returns {ok, realm, created, manager_key_configured}. var handleCreateRealmRequest = function (req, res) { if (!isAuthEnabled() || managementTokens().length === 0) { sendJson(res, 404, {ok: false, code: 404, message: 'not found'}); return; } var entry = managementTokenForRequest(req); if (!entry) { sendJson(res, 401, {ok: false, code: 401, message: 'management token required'}); return; } readJsonBody(req, 64 * 1024, function (err, body) { if (err) { sendJson(res, 400, {ok: false, code: 400, message: 'invalid JSON body'}); return; } body = body || {}; var realm = body.realm; var managerKey = body.manager_key || body.managerKey; if (typeof realm !== 'string' || !realm) { sendJson(res, 400, {ok: false, code: 400, message: 'realm is required'}); return; } if (typeof managerKey !== 'string' || !managerKey) { sendJson(res, 400, {ok: false, code: 400, message: 'manager_key is required'}); return; } if (!realmAllowedForPrefix(realm, entry.realm_prefix)) { sendJson(res, 403, {ok: false, code: 403, message: 'realm outside managed prefix'}); return; } var existed = !!(server.authOptions.realms && server.authOptions.realms[realm]); var result = existed ? applyAuthManagementCommand({command: 'set_realm_manager_key', realm: realm, manager_key: managerKey}) : applyAuthManagementCommand({command: 'add_realm', realm: realm, required: true, manager_key: managerKey}); if (!result || !result.ok) { var code = (result && result.code) || 500; sendJson(res, code, {ok: false, code: code, message: (result && result.message) || 'realm update failed'}); return; } sendJson(res, 200, {ok: true, realm: realm, created: !existed, manager_key_configured: true}); }); }; var handleHttpApiRequest = function (req, res) { var config = getHttpApiConfig(); if (!config) return false; var pathname; try { pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); } catch (err) { return false; } if (req.method === 'POST' && pathname === '/api/realms') { handleCreateRealmRequest(req, res); return true; } if (req.method !== 'GET') return false; // the rest of the surface is read-only by design if ((pathname === '/health' || pathname === '/healthz') && config.health !== false) { sendJson(res, 200, { status: 'ok', version: pak.version, uptime_seconds: Math.floor(process.uptime()), node_id: clusterSync ? clusterSync.nodeId : null }); return true; } if (pathname === '/api/metrics' && config.metrics !== false) { if (config.metrics_auth !== false && !httpAuthOk(req)) { sendJson(res, 401, {ok: false, code: 401, message: 'Bearer admin token required'}); return true; } res.writeHead(200, {'content-type': 'text/plain; version=0.0.4; charset=utf-8'}); res.end(renderPrometheusMetrics()); return true; } if (pathname === '/api/stats' && config.stats !== false) { if (!httpAuthOk(req)) { sendJson(res, 401, {ok: false, code: 401, message: 'Bearer admin token required'}); return true; } sendJson(res, 200, buildStats()); return true; } var dlqMatch = pathname.match(/^\/api\/realms\/([^\/]+)\/dlq$/); if (dlqMatch && config.stats !== false) { if (!httpAuthOk(req)) { sendJson(res, 401, {ok: false, code: 401, message: 'Bearer admin token required'}); return true; } if (!server.store || typeof server.store.listDlq !== 'function') { sendJson(res, 501, {ok: false, code: 501, message: 'Storage backend has no DLQ support'}); return true; } server.store.listDlq(dlqMatch[1]).then(function (entries) { sendJson(res, 200, {realm: dlqMatch[1], entries: entries || []}); }).catch(function (err) { sendJson(res, 500, {ok: false, code: 500, message: err.message}); }); return true; } return false; }; var applyAuthManagementCommand = function (body) { body = body || {}; var command = body.command; var auth = clone(server.authOptions || {}); auth.realms = auth.realms || {}; auth.tokens = auth.tokens || []; if (command === 'get') { return {ok: true, settings: publicManagementSettings()}; } if (command === 'reload_settings') { if (!loadedSettingsFile) return {ok: false, code: 400, message: 'No settings file is being watched'}; try { server.settings.reloadSync(); } catch (err) { return {ok: false, code: 500, message: 'Settings reload failed: ' + err.message}; } return {ok: true, settings: publicManagementSettings()}; } if (command === 'set_global_auth') { auth.enabled = !!body.enabled; } else if (command === 'set_persistence') { var normalized = normalizePersistenceSettings(body); if (!normalized.ok) return normalized; try { applyPersistenceSettings(normalized.settings); } catch (err) { return {ok: false, code: 400, message: err.message}; } var nextPersistenceSettings = clone(server.settings.get()); nextPersistenceSettings.storage = normalized.settings.storage; nextPersistenceSettings.storage_options = normalized.settings.storage_options; applyingPersistenceChange = true; try { server.settings.replace(nextPersistenceSettings); } finally { applyingPersistenceChange = false; } persistSettings(); return {ok: true, settings: publicManagementSettings()}; } else if (command === 'add_realm') { if (!body.realm) return {ok: false, code: 400, message: 'realm is required'}; if (auth.realms[body.realm]) return {ok: false, code: 409, message: 'realm already exists'}; auth.realms[body.realm] = {required: body.required !== false}; if (body.manager_key || body.managerKey) auth.realms[body.realm].manager_key = body.manager_key || body.managerKey; if (body.key || body.preshared_key || body.presharedKey) auth.realms[body.realm].key = body.key || body.preshared_key || body.presharedKey; if (body.require_acceptance !== undefined && body.require_acceptance !== null) auth.realms[body.realm].require_acceptance = !!body.require_acceptance; } else if (command === 'rename_realm') { if (!body.from || !body.to) return {ok: false, code: 400, message: 'from and to are required'}; if (!auth.realms[body.from]) return {ok: false, code: 404, message: 'realm not found'}; if (auth.realms[body.to]) return {ok: false, code: 409, message: 'target realm already exists'}; auth.realms[body.to] = auth.realms[body.from]; delete auth.realms[body.from]; auth.tokens.forEach(function (token) { if (token.realm === body.from) token.realm = body.to; }); if (realms[body.from] && !realms[body.to]) { realms[body.to] = realms[body.from]; delete realms[body.from]; } } else if (command === 'remove_realm') { if (!body.realm) return {ok: false, code: 400, message: 'realm is required'}; if (body.realm === DEFAULT_REALM || body.realm === REALM_ALL) return {ok: false, code: 400, message: 'cannot remove the "' + body.realm + '" realm'}; if (!auth.realms[body.realm]) return {ok: false, code: 404, message: 'realm not found'}; delete auth.realms[body.realm]; // Drop tokens scoped to the removed realm — they would be orphaned. var tokensBefore = auth.tokens.length; auth.tokens = auth.tokens.filter(function (token) { return token.realm !== body.realm; }); body._removed_tokens = tokensBefore - auth.tokens.length; // Drop in-memory runtime state (producers/consumers/subscriptions). if (realms[body.realm]) delete realms[body.realm]; } else if (command === 'set_realm_auth') { if (!body.realm) return {ok: false, code: 400, message: 'realm is required'}; auth.realms[body.realm] = auth.realms[body.realm] || {}; auth.realms[body.realm].required = !!body.required; } else if (command === 'set_realm_manager_key') { if (!body.realm) return {ok: false, code: 400, message: 'realm is required'}; if (!auth.realms[body.realm]) return {ok: false, code: 404, message: 'realm not found'}; if (body.manager_key || body.managerKey) auth.realms[body.realm].manager_key = body.manager_key || body.managerKey; else delete auth.realms[body.realm].manager_key; } else if (command === 'set_realm_key') { if (!body.realm) return {ok: false, code: 400, message: 'realm is required'}; if (!auth.realms[body.realm]) return {ok: false, code: 404, message: 'realm not found'}; if (body.key || body.preshared_key || body.presharedKey) auth.realms[body.realm].key = body.key || body.preshared_key || body.presharedKey; else delete auth.realms[body.realm].key; if (body.require_key !== undefined && body.require_key !== null) auth.realms[body.realm].require_key = !!body.require_key; } else if (command === 'set_realm_acceptance') { if (!body.realm) return {ok: false, code: 400, message: 'realm is required'}; auth.realms[body.realm] = auth.realms[body.realm] || {}; auth.realms[body.realm].require_acceptance = !!body.required; } else if (command === 'add_management_token') { if (!body.token || typeof body.token !== 'string') return {ok: false, code: 400, message: 'token is required'}; if (!body.realm_prefix || typeof body.realm_prefix !== 'string') return {ok: false, code: 400, message: 'realm_prefix is required'}; auth.management_tokens = auth.management_tokens || auth.managementTokens || []; delete auth.managementTokens; var mgmtExists = auth.management_tokens.some(function (entry) { return entry && entry.token === body.token; }); if (mgmtExists) return {ok: false, code: 409, message: 'management token already exists'}; auth.management_tokens.push({ token: body.token, realm_prefix: body.realm_prefix }); } else if (command === 'revoke_management_token') { if (!body.token_hash) return {ok: false, code: 400, message: 'token_hash is required'}; var mgmtTokens = auth.management_tokens || auth.managementTokens || []; var mgmtBefore = mgmtTokens.length; mgmtTokens = mgmtTokens.filter(function (entry) { return !(entry && entry.token && hashToken(entry.token) === body.token_hash); }); if (mgmtTokens.length === mgmtBefore) return {ok: false, code: 404, message: 'management token not found'}; auth.management_tokens = mgmtTokens; delete auth.managementTokens; } else if (command === 'set_external_auth') { if (body.auth_url) { try { new URL(String(body.auth_url)); } catch (err) { return {ok: false, code: 400, message: 'auth_url must be a valid URL'}; } auth.auth_url = String(body.auth_url); } else { delete auth.auth_url; } // Omitted secret keeps the current one (managers can't read it back); // an explicit empty string clears it. if (body.auth_secret !== undefined && body.auth_secret !== null) { if (body.auth_secret) auth.auth_secret = String(body.auth_secret); else delete auth.auth_secret; } } else if (command === 'revoke_token') { var revoked = revokeAuthTokens(auth, body); if (!revoked.ok) return revoked; body._revoked = revoked.revoked; } else { return {ok: false, code: 400, message: 'unknown management command'}; } var nextSettings = clone(server.settings.get()); nextSettings.auth = auth; server.settings.replace(nextSettings); persistSettings(); var result = {ok: true, settings: publicManagementSettings()}; if (body._revoked) result.revoked = body._revoked; if (body._removed_tokens !== undefined) result.removed_tokens = body._removed_tokens; return result; }; var nextPendingAuthorizationRequest = function (filter) { filter = filter || {}; for (var i = 0; i < authorizationRequests.length; i++) { var request = authorizationRequests[i]; if (request.status !== 'pending') continue; if (filter.realm && request.realm !== filter.realm) continue; return request; } return null; }; var authorizationClientKey = function (realm, clientId) { return String(realm) + '\n' + String(clientId); }; var saveLatestAuthorizationRequest = function (request) { var key = authorizationClientKey(request.realm, request.client_id); var previous = authorizationRequestClientMap[key]; if (previous && previous.status === 'pending') { previous.status = 'superseded'; previous.superseded_at = request.created_at; delete authorizationRequestMap[previous.request_id]; } authorizationRequestClientMap[key] = request; authorizationRequests.push(request); authorizationRequestMap[request.request_id] = request; }; var getRealm = function (realmId) { realmId = realmId || DEFAULT_REALM; realms[realmId] = realms[realmId] || {producers: {}, consumers: {}, subscriptions: {}}; return realms[realmId]; }; var getSocketRealmId = function (socket) { return (socket.tyoAuth && socket.tyoAuth.realm) || DEFAULT_REALM; }; var getSocketRealm = function (socket) { return getRealm(getSocketRealmId(socket)); }; // Global auth toggle — true if auth.enabled is set in current settings. var isAuthEnabled = function () { return !!(server.settings.get('auth') && server.settings.get('auth').enabled); }; // Per-realm auth requirement. Resolution order: // 1. auth.realms[realmId].required (explicit per-realm override) // 2. auth.enabled (global default) // Returns true when auth is required for this realm. var isAuthRequiredForRealm = function (realmId) { if (!isAuthEnabled()) return false; var auth = server.settings.get('auth') || {}; if (realmId && auth.realms && auth.realms[realmId]) { var rc = auth.realms[realmId]; if (typeof rc.required === 'boolean') return rc.required; } return true; }; var normalizeRole = function (role) { return role || 'both'; }; // Roles a client may declare for itself on connection. 'manager' and // 'admin' are deliberately excluded — those are only granted via tokens. var CONNECT_ROLES = ['producer', 'consumer', 'both']; // MQTT-style topic matching: '+' matches exactly one level, '#' matches // any number of trailing levels (must be the last segment). var topicMatches = function (pattern, topic) { if (pattern =