UNPKG

signalk-server

Version:

An implementation of a [Signal K](http://signalk.org) server for boats.

1,058 lines (1,057 loc) 88 kB
"use strict"; /* * Copyright 2017 Teppo Kurki <teppo.kurki@iki.fi> * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const http = __importStar(require("http")); const https = __importStar(require("https")); const ssrfGuard_1 = require("./ssrfGuard"); const remoteConnectionSchemas_1 = require("./remoteConnectionSchemas"); const bcryptjs_1 = __importDefault(require("bcryptjs")); const busboy_1 = __importDefault(require("busboy")); const command_exists_1 = __importDefault(require("command-exists")); const express_1 = __importDefault(require("express")); const zip_1 = require("./zip"); const fs_1 = __importDefault(require("fs")); const lodash_1 = require("lodash"); const moment_1 = __importDefault(require("moment")); const ncp_1 = __importDefault(require("ncp")); const os_1 = __importDefault(require("os")); const path_1 = __importDefault(require("path")); const unzipper_1 = __importDefault(require("unzipper")); const util_1 = __importDefault(require("util")); const swagger_1 = require("./api/swagger"); const config_1 = require("./config/config"); const priorities_file_1 = require("./config/priorities-file"); const deviceIdentities_1 = require("./deviceIdentities"); const constants_1 = require("./constants"); const cors_1 = require("./cors"); const debug_1 = require("./debug"); const modules_1 = require("./modules"); const ports_1 = require("./ports"); const requestResponse_1 = require("./requestResponse"); const security_1 = require("./security"); const serialports_1 = require("./serialports"); const path_metadata_1 = require("@signalk/path-metadata"); const interfaces_1 = __importDefault(require("./interfaces")); const redirects_json_1 = __importDefault(require("./redirects.json")); const express_rate_limit_1 = __importDefault(require("express-rate-limit")); const child_process_1 = require("child_process"); const version_1 = require("./version"); const typebox_1 = require("@sinclair/typebox"); const value_1 = require("@sinclair/typebox/value"); const readdir = util_1.default.promisify(fs_1.default.readdir); const debug = (0, debug_1.createDebug)('signalk-server:serverroutes'); // Schemas for the atomic priorities payload and its sub-documents. These are // the same shapes the delta engine and the persisted settings.json already // expect; the schemas exist so bad admin requests cannot poison settings.json // with the wrong types. const priorityEntrySchema = typebox_1.Type.Object({ sourceRef: typebox_1.Type.String({ minLength: 1 }), // String form is accepted because the admin UI sometimes round-trips // numbers through input fields, but the value must still parse as a // (possibly negative) integer — anything else would corrupt the // priority engine when it does Number(timeout). timeout: typebox_1.Type.Union([typebox_1.Type.Number(), typebox_1.Type.String({ pattern: '^-?\\d+$' })]) }); const priorityOverridesSchema = typebox_1.Type.Record(typebox_1.Type.String(), typebox_1.Type.Array(priorityEntrySchema)); const priorityGroupSchema = typebox_1.Type.Object({ id: typebox_1.Type.String({ minLength: 1 }), sources: typebox_1.Type.Array(typebox_1.Type.String({ minLength: 1 })), inactive: typebox_1.Type.Optional(typebox_1.Type.Boolean()) }); const priorityGroupsSchema = typebox_1.Type.Array(priorityGroupSchema); const priorityDefaultsSchema = typebox_1.Type.Object({ fallbackMs: typebox_1.Type.Optional(typebox_1.Type.Number({ exclusiveMinimum: 0 })) }); const prioritiesPayloadSchema = typebox_1.Type.Object({ groups: priorityGroupsSchema, overrides: priorityOverridesSchema, defaults: priorityDefaultsSchema }); // Coerce numeric-string `timeout` values to numbers in-place. The schema // accepts both shapes for round-tripping through the admin-ui form, but // downstream consumers (the priority engine, settings.json) expect a // number — leaving a string here makes typeof checks downstream // silently misbehave. function normaliseSourcePriorityTimeouts(priorities) { for (const entries of Object.values(priorities)) { for (const entry of entries) { if (typeof entry.timeout === 'string') { entry.timeout = Number(entry.timeout); } } } } function validatePrioritiesPayload(body) { if (!value_1.Value.Check(prioritiesPayloadSchema, body)) { const first = value_1.Value.Errors(prioritiesPayloadSchema, body).First(); return { ok: false, error: first ? `Invalid priorities payload at ${first.path}: ${first.message}` : 'Invalid priorities payload' }; } const value = body; // Reject duplicate sourceRefs across active groups. The engine resolves // a source's group with first-found-wins, but the connected-component // construction on the client should never produce overlap; defending // here stops a hand-edited or out-of-sync payload from poisoning the // engine's source→group map. const seen = new Map(); for (const g of value.groups) { if (g.inactive) continue; for (const src of g.sources) { const prev = seen.get(src); if (prev && prev !== g.id) { return { ok: false, error: `Source ${src} appears in groups ${prev} and ${g.id}; a source may belong to at most one active group.` }; } seen.set(src, g.id); } } normaliseSourcePriorityTimeouts(value.overrides); return { ok: true, value }; } const sourceAliasesSchema = typebox_1.Type.Record(typebox_1.Type.String(), typebox_1.Type.String({ minLength: 1 })); const ignoredInstanceConflictsSchema = typebox_1.Type.Record(typebox_1.Type.String(), typebox_1.Type.String({ minLength: 1 })); function validateAgainst(schema, body, name) { if (!value_1.Value.Check(schema, body)) { const first = value_1.Value.Errors(schema, body).First(); return { ok: false, error: first ? `Invalid ${name} payload at ${first.path}: ${first.message}` : `Invalid ${name} payload` }; } return { ok: true, value: body }; } const ncp = ncp_1.default.ncp; const defaultSecurityStrategy = './tokensecurity'; const skPrefix = '/signalk/v1'; const DEFAULT_HTTP_RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; // 10 minutes const DEFAULT_HTTP_RATE_LIMIT_API_MAX = 1000; const DEFAULT_HTTP_RATE_LIMIT_LOGIN_STATUS_MAX = 1000; function getHttpRateLimitOverridesFromEnv() { const raw = process.env.HTTP_RATE_LIMITS; const defaults = { windowMs: DEFAULT_HTTP_RATE_LIMIT_WINDOW_MS, apiMax: DEFAULT_HTTP_RATE_LIMIT_API_MAX, loginStatusMax: DEFAULT_HTTP_RATE_LIMIT_LOGIN_STATUS_MAX }; if (!raw || typeof raw !== 'string') { return defaults; } const parts = raw .split(/[\s,]+/) .map((p) => p.trim()) .filter(Boolean); let overrides = { ...defaults }; for (const part of parts) { const eqIndex = part.indexOf('='); if (eqIndex === -1) { continue; } const key = part.slice(0, eqIndex).trim().toLowerCase(); const value = part.slice(eqIndex + 1).trim(); const parsed = Number.parseInt(value, 10); if ((key === 'windowms' || key === 'window') && Number.isFinite(parsed)) { overrides = { ...overrides, windowMs: parsed }; } else if ((key === 'api' || key === 'apimax') && Number.isFinite(parsed)) { overrides = { ...overrides, apiMax: parsed }; } else if ((key === 'loginstatus' || key === 'loginstatusmax') && Number.isFinite(parsed)) { overrides = { ...overrides, loginStatusMax: parsed }; } } return overrides; } module.exports = function (app, saveSecurityConfig, getSecurityConfig) { const httpRateLimitOverrides = getHttpRateLimitOverridesFromEnv(); const rateLimitValidationOptions = (0, security_1.getRateLimitValidationOptions)(app); const apiLimiter = (0, express_rate_limit_1.default)({ windowMs: httpRateLimitOverrides.windowMs, max: httpRateLimitOverrides.apiMax, message: { message: 'Too many requests from this IP, please try again after 10 minutes' }, validate: rateLimitValidationOptions }); const loginStatusLimiter = (0, express_rate_limit_1.default)({ windowMs: httpRateLimitOverrides.windowMs, max: httpRateLimitOverrides.loginStatusMax, message: { message: 'Too many requests from this IP, please try again after 10 minutes' }, validate: rateLimitValidationOptions }); let securityWasEnabled = false; const restoreSessions = new Map(); const logopath = path_1.default.resolve(app.config.configPath, 'logo.svg'); if (fs_1.default.existsSync(logopath)) { debug(`Found custom logo at ${logopath}, adding route for it`); // Intercept Webpack (fonts/), Vite 6 (assets/ hashed), and Vite 8 (assets/public_src/img/) paths app.use('/admin/fonts/signal-k-logo-image-text.*', (req, res) => res.sendFile(logopath)); app.use('/admin/assets/signal-k-logo-image-text*.svg', (req, res) => res.sendFile(logopath)); app.use('/admin/assets/public_src/img/signal-k-logo-image-text.svg', (req, res) => res.sendFile(logopath)); // Check for custom logo for minimized sidebar, otherwise use the existing logo. const minimizedLogoPath = path_1.default.resolve(app.config.configPath, 'logo-minimized.svg'); const minimizedLogo = fs_1.default.existsSync(minimizedLogoPath) ? minimizedLogoPath : logopath; // Intercept Webpack (fonts/), Vite 6 (assets/ hashed), and Vite 8 (assets/public_src/img/) paths app.use('/admin/fonts/signal-k-logo-image.*', (req, res) => res.sendFile(minimizedLogo)); app.use('/admin/assets/signal-k-logo-image*.svg', (req, res) => res.sendFile(minimizedLogo)); app.use('/admin/assets/public_src/img/signal-k-logo-image.svg', (req, res) => res.sendFile(minimizedLogo)); } // Vite 8 (Rolldown) changed CSS url() rewriting for publicDir assets: the built CSS // references logos as url(public_src/img/...) which resolves to assets/public_src/img/ // relative to the CSS file, not the actual img/ location. Serve default logos from there. app.use('/admin/assets/public_src/img', express_1.default.static(path_1.default.join(__dirname, '/../node_modules/@signalk/server-admin-ui/public/img'))); // mount before the main /admin (0, swagger_1.mountSwaggerUi)(app, '/doc/openapi'); // mount server-guide app.use('/documentation', express_1.default.static(__dirname + '/../docs/dist')); // Redirect old documentation URLs to new ones let oldpath; for (oldpath in redirects_json_1.default) { const from = `/documentation/${oldpath}`; const to = `/documentation/${redirects_json_1.default[oldpath]}`; app.get(from, (_, res) => { res.redirect(301, to); }); } const adminUiPath = path_1.default.join(__dirname, '/../node_modules/@signalk/server-admin-ui/public'); function serveIndexWithAddonScripts(indexPath, res) { fs_1.default.readFile(indexPath, (err, indexContent) => { if (err) { console.error(err); res.status(500); res.type('text/plain'); res.send('Could not handle admin ui root request'); return; } res.type('html'); const addonScripts = (0, lodash_1.uniq)([] .concat(app.addons) .concat(app.pluginconfigurators) .concat(app.embeddablewebapps)); setNoCache(res); res.send(indexContent.toString().replace(/%ADDONSCRIPTS%/g, addonScripts .map((moduleInfo) => moduleInfo.type === 'module' ? `<script type="module" src="/${moduleInfo.name}/remoteEntry.js"></script>` : `<script src="/${moduleInfo.name}/remoteEntry.js"></script>`) .join('\n') .toString())); }); } app.get('/admin/', (req, res) => { if (!req.originalUrl.endsWith('/')) { res.redirect(301, req.originalUrl + '/'); return; } serveIndexWithAddonScripts(path_1.default.join(adminUiPath, 'index.html'), res); }); app.use('/admin', express_1.default.static(adminUiPath)); app.get('/', (req, res) => { let landingPage = '/admin/'; // if accessed with hostname that starts with a webapp's displayName redirect there //strip possible port number const firstHostName = (req.headers?.host || '') .split(':')[0] .split('.')[0] .toLowerCase(); const targetWebapp = app.webapps.find((webapp) => // eslint-disable-next-line @typescript-eslint/no-explicit-any webapp.signalk?.displayName?.toLowerCase() === firstHostName); if (targetWebapp) { landingPage = `/${targetWebapp.name}/`; } res.redirect(app.config.settings.landingPage || landingPage); }); app.get('/@signalk/server-admin-ui', (req, res) => { res.redirect('/admin/'); }); app.put(`${constants_1.SERVERROUTESPREFIX}/restart`, (req, res) => { if (app.securityStrategy.allowRestart(req)) { res.json('Restarting...'); setTimeout(function () { process.exit(0); }, 2000); } else { res.status(401).json('Restart not allowed'); } }); const securityActivationDisabled = process.env.DISABLE_SECURITY_ACTIVATION === '1' || process.env.DISABLE_SECURITY_ACTIVATION === 'true'; const getLoginStatus = (req, res) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = app.securityStrategy.getLoginStatus(req); result.securityWasEnabled = securityWasEnabled; if (securityActivationDisabled) { delete result.noUsers; } setNoCache(res); res.json(result); }; app.get(`${constants_1.SERVERROUTESPREFIX}/loginStatus`, loginStatusLimiter, getLoginStatus); //TODO remove after a grace period app.get(`/loginStatus`, loginStatusLimiter, (req, res) => { console.log(`/loginStatus is deprecated, try updating webapps to the latest version`); getLoginStatus(req, res); }); app.get(`${constants_1.SERVERROUTESPREFIX}/security/config`, (req, res) => { if (app.securityStrategy.allowConfigure(req)) { const config = getSecurityConfig(app); res.json(app.securityStrategy.getConfig(config)); } else { res.status(401).json('Security config not allowed'); } }); app.put(`${constants_1.SERVERROUTESPREFIX}/security/config`, (req, res) => { if (app.securityStrategy.allowConfigure(req)) { try { app.securityStrategy.validateConfiguration(req.body); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err) { res.status(400).send(err.message); return; } let config = getSecurityConfig(app); const configToSave = (0, cors_1.handleAdminUICORSOrigin)(req.body); config = app.securityStrategy.setConfig(config, configToSave); saveSecurityConfig(app, config, (err) => { if (err) { console.log(err); res.status(500); res.json('Unable to save configuration change'); return; } res.json('security config saved'); }); } else { res.status(401).send('Security config not allowed'); } }); // eslint-disable-next-line @typescript-eslint/no-explicit-any function getConfigSavingCallback(success, failure, res) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return (err, config) => { if (err) { console.log(err); res.status(500).type('text/plain').send(failure); } else if (config) { saveSecurityConfig(app, config, (theError) => { if (theError) { console.log(theError); res.status(500).send('Unable to save configuration change'); return; } res.type('text/plain').send(success); }); } else { res.type('text/plain').send(success); } }; } function checkAllowConfigure(req, res) { if (app.securityStrategy.allowConfigure(req)) { return true; } else { res.status(401).json('Security config not allowed'); return false; } } app.get(`${constants_1.SERVERROUTESPREFIX}/security/devices`, (req, res) => { if (checkAllowConfigure(req, res)) { const config = getSecurityConfig(app); res.json(app.securityStrategy.getDevices(config)); } }); app.put(`${constants_1.SERVERROUTESPREFIX}/security/devices/:uuid`, (req, res) => { if (checkAllowConfigure(req, res)) { const config = getSecurityConfig(app); app.securityStrategy.updateDevice(config, req.params.uuid, req.body, getConfigSavingCallback('Device updated', 'Unable to update device', res)); } }); app.delete(`${constants_1.SERVERROUTESPREFIX}/security/devices/:uuid`, (req, res) => { if (checkAllowConfigure(req, res)) { const config = getSecurityConfig(app); app.securityStrategy.deleteDevice(config, req.params.uuid, getConfigSavingCallback('Device deleted', 'Unable to delete device', res)); } }); app.get(`${constants_1.SERVERROUTESPREFIX}/security/users`, (req, res) => { if (checkAllowConfigure(req, res)) { const config = getSecurityConfig(app); res.json(app.securityStrategy.getUsers(config)); } }); app.put(`${constants_1.SERVERROUTESPREFIX}/security/users/:id`, (req, res) => { if (checkAllowConfigure(req, res)) { const config = getSecurityConfig(app); app.securityStrategy.updateUser(config, req.params.id, req.body, getConfigSavingCallback('User updated', 'Unable to add user', res)); } }); app.post(`${constants_1.SERVERROUTESPREFIX}/security/users/:id`, (req, res) => { if (checkAllowConfigure(req, res)) { const config = getSecurityConfig(app); const user = req.body; user.userId = req.params.id; app.securityStrategy.addUser(config, user, (err, savedConfig) => { if (err) { const status = err.message === 'User already exists' ? 400 : 500; res.status(status).type('text/plain').send(err.message); } else if (savedConfig) { saveSecurityConfig(app, savedConfig, (saveErr) => { if (saveErr) { console.log(saveErr); res.status(500).send('Unable to save configuration change'); return; } res.type('text/plain').send('User added'); }); } else { res.status(500).type('text/plain').send('Unable to add user'); } }); } }); app.put(`${constants_1.SERVERROUTESPREFIX}/security/user/:username/password`, (req, res) => { if (checkAllowConfigure(req, res)) { const config = getSecurityConfig(app); app.securityStrategy.setPassword(config, req.params.username, req.body, getConfigSavingCallback('Password changed', 'Unable to change password', res)); } }); app.delete(`${constants_1.SERVERROUTESPREFIX}/security/users/:username`, (req, res) => { if (checkAllowConfigure(req, res)) { const config = getSecurityConfig(app); app.securityStrategy.deleteUser(config, req.params.username, getConfigSavingCallback('User deleted', 'Unable to delete user', res)); } }); app.put([ `${constants_1.SERVERROUTESPREFIX}/security/access/requests/:identifier/:status`, '/security/access/requests/:identifier/:status' // for backwards compatibly with existing clients ], (req, res) => { if (!app.securityStrategy.setAccessRequestStatus) { res.status(404).json({ message: 'Access requests not available. Server security may not be enabled.' }); return; } if (checkAllowConfigure(req, res)) { const config = getSecurityConfig(app); app.securityStrategy.setAccessRequestStatus(config, req.params.identifier, req.params.status, req.body, getConfigSavingCallback('Request updated', 'Unable update request', res)); } }); app.get(`${constants_1.SERVERROUTESPREFIX}/security/access/requests`, (req, res) => { if (!app.securityStrategy.getAccessRequestsResponse) { res.status(404).json({ message: 'Access requests not available. Server security may not be enabled.' }); return; } if (checkAllowConfigure(req, res)) { res.json(app.securityStrategy.getAccessRequestsResponse()); } }); app.post(`${skPrefix}/access/requests`, apiLimiter, (req, res) => { if (req.headers['content-length'] && parseInt(req.headers['content-length']) > 10 * 1024) { res.status(413).send('Payload too large'); return; } const config = getSecurityConfig(app); const ip = req.ip; if (app.securityStrategy.isDummy()) { res.status(404).json({ message: 'Access requests not available. Server security is not enabled.' }); return; } app.securityStrategy .requestAccess(config, { accessRequest: req.body }, ip) // eslint-disable-next-line @typescript-eslint/no-explicit-any .then((reply) => { res.status(reply.state === 'PENDING' ? 202 : reply.statusCode); res.json(reply); }) // eslint-disable-next-line @typescript-eslint/no-explicit-any .catch((err) => { console.error(err.message); res.status(err.statusCode || 500).send(err.message); }); }); app.get(`${skPrefix}/requests/:id`, apiLimiter, (req, res) => { (0, requestResponse_1.queryRequest)(req.params.id) // eslint-disable-next-line @typescript-eslint/no-explicit-any .then((reply) => { res.json(reply); }) // eslint-disable-next-line @typescript-eslint/no-explicit-any .catch((err) => { console.log(err); res.status(500); res.type('text/plain').send(`Unable to check request: ${err.message}`); }); }); app.get(`${constants_1.SERVERROUTESPREFIX}/settings`, (req, res) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const settings = { interfaces: {}, options: { mdns: app.config.settings.mdns ?? true, wsCompression: app.config.settings.wsCompression || false, wsPingInterval: app.config.settings.wsPingInterval ?? 30000, accessLogging: (0, lodash_1.isUndefined)(app.config.settings.accessLogging) || app.config.settings.accessLogging, enablePluginLogging: (0, lodash_1.isUndefined)(app.config.settings.enablePluginLogging) || app.config.settings.enablePluginLogging, trustProxy: app.config.settings.trustProxy || false }, loggingDirectory: app.config.settings.loggingDirectory, pruneContextsMinutes: app.config.settings.pruneContextsMinutes || 60, keepMostRecentLogsOnly: (0, lodash_1.isUndefined)(app.config.settings.keepMostRecentLogsOnly) || app.config.settings.keepMostRecentLogsOnly, logCountToKeep: app.config.settings.logCountToKeep || 24, runFromSystemd: process.env.RUN_FROM_SYSTEMD === 'true', courseApi: { apiOnly: app.config.settings.courseApi?.apiOnly || false } }; if (!settings.runFromSystemd) { settings.sslport = (0, ports_1.getSslPort)(app); settings.port = (0, ports_1.getHttpPort)(app); settings.options.ssl = app.config.settings.ssl || false; } (0, lodash_1.forIn)(interfaces_1.default, function (_interface, name) { settings.interfaces[name] = (0, lodash_1.isUndefined)(app.config.settings.interfaces) || (0, lodash_1.isUndefined)(app.config.settings.interfaces[name]) || app.config.settings.interfaces[name]; }); res.json(settings); }); if (app.securityStrategy.getUsers(getSecurityConfig(app)).length === 0) { if (securityActivationDisabled) { app.post(`${constants_1.SERVERROUTESPREFIX}/enableSecurity`, (_req, res) => { res.status(403).send('Security activation is disabled'); }); } else { app.post(`${constants_1.SERVERROUTESPREFIX}/enableSecurity`, (req, res) => { if (securityWasEnabled || app.securityStrategy.getUsers(getSecurityConfig(app)).length > 0) { res.status(403).send('Security already enabled'); return; } if (req.body.restore === true) { const { username, password } = req.body; if (!username || !password) { res.status(400).send('Username and password are required'); return; } const securityConfigPath = (0, security_1.pathForSecurityConfig)(app); const backupPath = securityConfigPath + '.disabled'; if (!fs_1.default.existsSync(backupPath)) { res.status(404).send('No security backup found'); return; } let backupConfig; try { backupConfig = JSON.parse(fs_1.default.readFileSync(backupPath, 'utf8')); } catch (err) { console.error(err); res.status(500).send('Unable to read security backup'); return; } const user = backupConfig.users?.find((u) => u.username === username && u.type === 'admin'); const hashToCompare = user?.password || '$2b$10$invalidhashpadding'; bcryptjs_1.default.compare(password, hashToCompare, (err, matches) => { if (err) { console.error(err); res.status(500).send('Unable to verify credentials'); return; } if (!matches || !user?.password) { res.status(401).send('Invalid username or password'); return; } try { fs_1.default.renameSync(backupPath, securityConfigPath); app.config.settings.security = { strategy: defaultSecurityStrategy }; (0, config_1.writeSettingsFile)(app, app.config.settings, // eslint-disable-next-line @typescript-eslint/no-explicit-any (err) => { if (err) { console.error(err); fs_1.default.renameSync(securityConfigPath, backupPath); res.status(500).send('Unable to save settings'); return; } securityWasEnabled = true; res.send('Security restored, please restart the server'); }); } catch (err) { console.error(err); res.status(500).send('Unable to restore security'); } }); return; } if (app.securityStrategy.isDummy()) { const adminUser = req.body; if (!adminUser.userId || adminUser.userId.length === 0 || !adminUser.password || adminUser.password.length === 0) { res.status(400).send('userId or password missing or too short'); return; } const updatedSettings = structuredClone(app.config.settings); updatedSettings.security = { strategy: defaultSecurityStrategy }; // eslint-disable-next-line @typescript-eslint/no-explicit-any (0, config_1.writeSettingsFile)(app, updatedSettings, (err) => { if (err) { console.log(err); res.status(500).send('Unable to save to settings file'); } else { app.config.settings = updatedSettings; // eslint-disable-next-line @typescript-eslint/no-explicit-any const config = {}; // eslint-disable-next-line @typescript-eslint/no-require-imports const securityStrategy = require(defaultSecurityStrategy)(app, config, saveSecurityConfig); if (req.body.allow_readonly === true) { config.allow_readonly = true; } addUser(req, res, securityStrategy, config); } }); } else { addUser(req, res, app.securityStrategy); } function addUser(request, response, securityStrategy, // eslint-disable-next-line @typescript-eslint/no-explicit-any config) { if (!config) { config = app.securityStrategy.getConfiguration(); } request.body.type = 'admin'; securityStrategy.addUser(config, request.body, (err, theConfig) => { if (err) { console.log(err); response.status(500); response.send('Unable to add user'); } else { saveSecurityConfig(app, theConfig, (theError) => { if (theError) { console.log(theError); response.status(500); response.send('Unable to save security configuration change'); } else { securityWasEnabled = true; response.send('Security enabled'); } }); } }); } }); } } app.post(`${constants_1.SERVERROUTESPREFIX}/disableSecurity`, (req, res) => { if (!app.securityStrategy.allowConfigure(req)) { res.status(401).send('Disable security not allowed'); return; } const { username, password } = req.body || {}; if (!username || !password) { res.status(400).send('Username and password are required'); return; } if (!app.securityStrategy.login) { res.status(500).send('Login not supported by security strategy'); return; } const config = getSecurityConfig(app); const user = config?.users?.find((u) => u.username === username && u.type === 'admin'); if (!user) { res.status(401).send('Invalid username or password'); return; } app.securityStrategy .login(username, password) .then((reply) => { if (reply.statusCode !== 200) { res.status(401).send('Invalid username or password'); return; } const securityConfigPath = (0, security_1.pathForSecurityConfig)(app); const backupPath = securityConfigPath + '.disabled'; try { if (fs_1.default.existsSync(securityConfigPath)) { fs_1.default.renameSync(securityConfigPath, backupPath); } delete app.config.settings.security; (0, config_1.writeSettingsFile)(app, app.config.settings, (err) => { if (err) { console.error(err); if (fs_1.default.existsSync(backupPath)) { fs_1.default.renameSync(backupPath, securityConfigPath); } res.status(500).send('Unable to save settings'); return; } res.send('Security disabled, please restart the server'); }); } catch (err) { console.error(err); res.status(500).send('Unable to disable security'); } }) .catch((err) => { console.error(err); res.status(500).send('Unable to verify credentials'); }); }); app.get(`${constants_1.SERVERROUTESPREFIX}/security/hasBackup`, (req, res) => { if (!app.securityStrategy.isDummy() && !app.securityStrategy.allowConfigure(req)) { res.status(401).send('Not authorized'); return; } const backupPath = (0, security_1.pathForSecurityConfig)(app) + '.disabled'; res.json({ hasBackup: fs_1.default.existsSync(backupPath) }); }); app.securityStrategy.addAdminWriteMiddleware(`${constants_1.SERVERROUTESPREFIX}/settings`); app.put(`${constants_1.SERVERROUTESPREFIX}/settings`, (req, res) => { const settings = req.body; const updatedSettings = structuredClone(app.config.settings); (0, lodash_1.forIn)(settings.interfaces, (enabled, name) => { const interfaces = updatedSettings.interfaces || (updatedSettings.interfaces = {}); interfaces[name] = enabled; }); if (!(0, lodash_1.isUndefined)(settings.options.mdns)) { updatedSettings.mdns = settings.options.mdns; } if (!(0, lodash_1.isUndefined)(settings.options.ssl)) { updatedSettings.ssl = settings.options.ssl; } if (!(0, lodash_1.isUndefined)(settings.options.wsCompression)) { updatedSettings.wsCompression = settings.options.wsCompression; } if (!(0, lodash_1.isUndefined)(settings.options.wsPingInterval)) { updatedSettings.wsPingInterval = settings.options.wsPingInterval; } if (!(0, lodash_1.isUndefined)(settings.options.accessLogging)) { updatedSettings.accessLogging = settings.options.accessLogging; } if (!(0, lodash_1.isUndefined)(settings.options.enablePluginLogging)) { updatedSettings.enablePluginLogging = settings.options.enablePluginLogging; } if (!(0, lodash_1.isUndefined)(settings.options.trustProxy)) { updatedSettings.trustProxy = settings.options.trustProxy; } if (!(0, lodash_1.isUndefined)(settings.port)) { updatedSettings.port = Number(settings.port); } if (!(0, lodash_1.isUndefined)(settings.sslport)) { updatedSettings.sslport = Number(settings.sslport); } if (!(0, lodash_1.isUndefined)(settings.loggingDirectory)) { updatedSettings.loggingDirectory = settings.loggingDirectory; } if (!(0, lodash_1.isUndefined)(settings.pruneContextsMinutes)) { updatedSettings.pruneContextsMinutes = Number(settings.pruneContextsMinutes); } if (!(0, lodash_1.isUndefined)(settings.keepMostRecentLogsOnly)) { updatedSettings.keepMostRecentLogsOnly = settings.keepMostRecentLogsOnly; } if (!(0, lodash_1.isUndefined)(settings.logCountToKeep)) { updatedSettings.logCountToKeep = Number(settings.logCountToKeep); } (0, lodash_1.forIn)(settings.courseApi, (enabled, name) => { const courseApi = updatedSettings.courseApi || (updatedSettings.courseApi = {}); courseApi[name] = enabled; }); (0, config_1.writeSettingsFile)(app, updatedSettings, (err) => { if (err) { res.status(500).send('Unable to save to settings file'); } else { app.config.settings = updatedSettings; res.type('text/plain').send('Settings changed'); } }); }); app.get(`${constants_1.SERVERROUTESPREFIX}/vessel`, (req, res) => { const de = app.config.baseDeltaEditor; const communication = de.getSelfValue('communication'); const draft = de.getSelfValue('design.draft'); const length = de.getSelfValue('design.length'); const type = de.getSelfValue('design.aisShipType'); const json = { name: app.config.vesselName, mmsi: app.config.vesselMMSI, uuid: app.config.vesselUUID, draft: draft && draft.maximum, length: length && length.overall, beam: de.getSelfValue('design.beam'), height: de.getSelfValue('design.airHeight'), gpsFromBow: de.getSelfValue('sensors.gps.fromBow'), gpsFromCenter: de.getSelfValue('sensors.gps.fromCenter'), aisShipType: type && type.id, callsignVhf: communication && communication.callsignVhf }; res.json(json); }); function writeOldDefaults(req, res) { let self; // eslint-disable-next-line @typescript-eslint/no-explicit-any let data; try { data = (0, config_1.readDefaultsFile)(app); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e) { if (e.code && e.code === 'ENOENT') { data = {}; } else { console.error(e); res.status(500).send('Unable to read defaults file'); } } self = (0, lodash_1.get)(data, 'vessels.self'); if ((0, lodash_1.isUndefined)(self)) { self = (0, lodash_1.set)(data, 'vessels.self', {}); } const newVessel = req.body; function setString(skPath, value) { (0, lodash_1.set)(data.vessels.self, skPath, value && value.length > 0 ? value : undefined); } function setNumber(skPath, rmPath, value) { if ((0, lodash_1.isNumber)(value) || (value && value.length > 0)) { (0, lodash_1.set)(data.vessels.self, skPath, Number(value)); } else { (0, lodash_1.unset)(data.vessels.self, rmPath); } } setString('name', newVessel.name); setString('mmsi', newVessel.mmsi); if (newVessel.uuid && !self.mmsi) { setString('uuid', newVessel.uuid); } else { delete self.uuid; } setNumber('design.draft.value.maximum', 'design.draft', newVessel.draft); setNumber('design.length.value.overall', 'design.length', newVessel.length); setNumber('design.beam.value', 'design.beam', newVessel.beam); setNumber('design.airHeight.value', 'design.airHeight', newVessel.height); setNumber('sensors.gps.fromBow.value', 'sensors.gps.fromBow', newVessel.gpsFromBow); setNumber('sensors.gps.fromCenter.value', 'sensors.gps.fromCenter', newVessel.gpsFromCenter); if (newVessel.aisShipType) { (0, lodash_1.set)(data.vessels.self, 'design.aisShipType.value', { name: (0, path_metadata_1.getAISShipTypeName)(newVessel.aisShipType), id: Number(newVessel.aisShipType) }); } else { delete self.design.aisShipType; } if (newVessel.callsignVhf) { setString('communication.callsignVhf', newVessel.callsignVhf); } else { delete self.communication; } // eslint-disable-next-line @typescript-eslint/no-explicit-any (0, config_1.writeDefaultsFile)(app, data, (err) => { if (err) { res.status(500).send('Unable to save to defaults file'); } else { res.type('text/plain').send('Vessel changed'); } }); } app.securityStrategy.addAdminWriteMiddleware(`${constants_1.SERVERROUTESPREFIX}/vessel`); app.put(`${constants_1.SERVERROUTESPREFIX}/vessel`, (req, res) => { const de = app.config.baseDeltaEditor; const vessel = req.body; de.setSelfValue('name', vessel.name); app.config.vesselName = vessel.name; de.setSelfValue('mmsi', vessel.mmsi); app.config.vesselMMSI = vessel.mmsi; if (vessel.uuid && !vessel.mmsi) { de.setSelfValue('uuid', vessel.uuid); app.config.vesselUUID = vessel.uuid; } else { de.removeSelfValue('uuid'); delete app.config.vesselUUID; } function makeNumber(num) { return !(0, lodash_1.isUndefined)(num) && ((0, lodash_1.isNumber)(num) || num.length) ? Number(num) : undefined; } de.setSelfValue('design.draft', !(0, lodash_1.isUndefined)(vessel.draft) ? { maximum: Number(vessel.draft) } : undefined); de.setSelfValue('design.length', !(0, lodash_1.isUndefined)(vessel.length) ? { overall: Number(vessel.length) } : undefined); de.setSelfValue('design.beam', makeNumber(vessel.beam)); de.setSelfValue('design.airHeight', makeNumber(vessel.height)); de.setSelfValue('sensors.gps.fromBow', makeNumber(vessel.gpsFromBow)); de.setSelfValue('sensors.gps.fromCenter', makeNumber(vessel.gpsFromCenter)); de.setSelfValue('design.aisShipType', !(0, lodash_1.isUndefined)(vessel.aisShipType) ? { name: (0, path_metadata_1.getAISShipTypeName)(vessel.aisShipType), id: Number(vessel.aisShipType) } : undefined); de.setSelfValue('communication', !(0, lodash_1.isUndefined)(vessel.callsignVhf) && vessel.callsignVhf.length ? { callsignVhf: vessel.callsignVhf } : undefined); app.emit('serverevent', { type: 'VESSEL_INFO', data: { name: app.config.vesselName, mmsi: app.config.vesselMMSI, uuid: app.config.vesselUUID } }); (0, config_1.sendBaseDeltas)(app); if (app.config.hasOldDefaults) { writeOldDefaults(req, res); } else { (0, config_1.writeBaseDeltasFile)(app) .then(() => { res.type('text/plain').send('Vessel changed'); }) .catch(() => { res.status(500).send('Unable to save to defaults file'); }); } }); app.get(`${constants_1.SERVERROUTESPREFIX}/availablePaths`, (req, res) => { res.json(app.streambundle.getAvailablePaths()); }); app.get(`${constants_1.SERVERROUTESPREFIX}/paths`, (_req, res) => { res.json(path_metadata_1.metadataRegistry.getAllMetadata()); }); app.securityStrategy.addAdminMiddleware(`${constants_1.SERVERROUTESPREFIX}/multiSourcePaths`); app.get(`${constants_1.SERVERROUTESPREFIX}/multiSourcePaths`, (req, res) => { res.json(app.deltaCache.getMultiSourcePaths()); }); app.securityStrategy.addAdminMiddleware(`${constants_1.SERVERROUTESPREFIX}/reconciledGroups`); // Reconciled priority groups for the admin UI. Saved groups are // authoritative (composition driven by priorityGroups, not by // current cache flux); unsaved sources fall through to connected- // components discovery. app.get(`${constants_1.SERVERROUTESPREFIX}/reconciledGroups`, (req, res) => { res.json(app.deltaCache.getReconciledGroups()); }); app.securityStrategy.addAdminMiddleware(`${constants_1.SERVERROUTESPREFIX}/livePreferredSources`); // The currently-winning source per path according to the priority // engine — distinct from priorityOverrides (the saved configuration). // Used by the admin-ui to label and dedup against the actual live // winner instead of the rank-1 source from config. app.get(`${constants_1.SERVERROUTESPREFIX}/livePreferredSources`, (_req, res) => { res.json(app.deltaCache.getLivePreferredSources()); }); app.securityStrategy.addAdminMiddleware(`${constants_1.SERVERROUTESPREFIX}/deviceIdentities`); app.get(`${constants_1.SERVERROUTESPREFIX}/deviceIdentities`, (_req, res) => { res.json((0, deviceIdentities_1.buildDeviceIdentities)(app.signalk.sources)); }); app.securityStrategy.addAdminMiddleware(`${constants_1.SERVERROUTESPREFIX}/eventsRoutingData`); app.get(`${constants_1.SERVERROUTESPREFIX}/eventsRoutingData`, (req, res) => { res.json(app.wrappedEmitter.getEventRoutingData()); }); // Generic source removal for non-N2K providers (NMEA0183 talkers, // plugins, derived sources). N2K sources go through n2kRemoveSource // because that endpoint also walks bus-address state. Without this // endpoint, the priority-group trash icon for a still-publishing // 0183 or plugin source can't actually evict it from the cache — // the reconciler keeps re-promoting it as a newcomer on the next // delta. app.securityStrategy.addAdminMiddleware(`${constants_1.SERVERROUTESPREFIX}/removeSource`); // Diagnostic: how visible is a sourceRef across the server's caches? // Called by the priority-page trash flow to verify that an eviction // actually removed every leaf, and on demand from a developer // browser to locate which tree is holding a zombie source. app.securityStrategy.addAdminMiddleware(`${constants_1.SERVERROUTESPREFIX}/cacheI