signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
1,058 lines (1,057 loc) • 88 kB
JavaScript
"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