diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce98cae5e..8fecda662 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -192,8 +192,13 @@ jobs: runs-on: ubuntu-latest needs: docker_image steps: + - name: Checkout + uses: actions/checkout@v3 - name: Setup flyctl uses: superfly/flyctl-actions/setup-flyctl@master + - name: Set TAG for build-arg + id: vars + run: echo ::set-output name=tag::$(echo ${GITHUB_REF#refs/*/}) - name: Deploy demo.signalk.org at fly.io working-directory: ./fly_io/demo_signalk_org run: flyctl deploy --remote-only --build-arg SK_VERSION=${{ steps.vars.outputs.tag }} diff --git a/.github/workflows/require_pr_label.yml b/.github/workflows/require_pr_label.yml index dd0b5a39c..743f038c3 100644 --- a/.github/workflows/require_pr_label.yml +++ b/.github/workflows/require_pr_label.yml @@ -6,7 +6,7 @@ jobs: label: runs-on: ubuntu-latest steps: - - uses: mheap/github-action-required-labels@v1 + - uses: mheap/github-action-required-labels@v5 with: mode: exactly count: 1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 97b4e4d70..5a38f71c6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: - node-version: [16.x] + node-version: [16.x, 18.x, 20.x] steps: - uses: actions/checkout@v3 diff --git a/docs/book.toml b/docs/book.toml index 57c15ab1a..751888aec 100644 --- a/docs/book.toml +++ b/docs/book.toml @@ -10,4 +10,7 @@ src = "src" build-dir = "built" create-missing = false +[output.html] +edit-url-template = "https://github.com/SignalK/signalk-server/tree/master/docs/src" + diff --git a/docs/src/installation/raspberry_pi_installation.md b/docs/src/installation/raspberry_pi_installation.md index e6c2a8c8a..9c0ed0d90 100644 --- a/docs/src/installation/raspberry_pi_installation.md +++ b/docs/src/installation/raspberry_pi_installation.md @@ -28,18 +28,16 @@ Once the OS installation has been completed, you are ready to commence. 1. Update the list of install packages. ``` - $ sudo apt update + sudo apt update ``` -1. Install NodeJS and npm. - ``` - $ curl -sL https://deb.nodesource.com/setup_18.x | sudo -E bash - - $ sudo apt-get install -y nodejs - ``` +1. Install NodeJS 18 and npm. + + Follow [instructions for Ubuntu and Debian based distributions like Raspberry Pi OS at NodeSource Distributions](https://github.com/nodesource/distributions#installation-instructions). 1. Ensure that we're using the latest version of npm. ``` - $ sudo npm install -g npm@latest + sudo npm install -g npm@latest ``` Use the following command to check the versions of NodeJS and npm installed. @@ -51,25 +49,25 @@ Once the OS installation has been completed, you are ready to commence. 1. Install a Bonjour (mDNS) service for Linux called Avahi, which allows Apps and other network devices to Discover the Signal K server. ``` - $ sudo apt install libnss-mdns avahi-utils libavahi-compat-libdnssd-dev + sudo apt install libnss-mdns avahi-utils libavahi-compat-libdnssd-dev ``` ## Install Signal K Server ``` -$ sudo npm install -g signalk-server +sudo npm install -g signalk-server ``` You can test that installation was successful by starting the server using some sample data. ``` -$ signalk-server --sample-nmea0183-data +signalk-server --sample-nmea0183-data ``` You should see the terminal output "signalk-server running at 0.0.0.0:3000" as shown below... ``` -$ signalk-server --sample-nmea0183-data +signalk-server --sample-nmea0183-data Using sample data from /usr/lib/node_modules/signalk-server/samples/plaka.log signalk-server running at 0.0.0.0:3000 ``` @@ -101,7 +99,7 @@ Now that you have Signal K server installed, you will want to generate a setting and configure your RPi to start the server automatically. To do this run the setup script by entering the following command and follow the prompts. ``` -$ sudo signalk-server-setup +sudo signalk-server-setup ``` You can re-run this command at any time in the future to change the settings. @@ -114,20 +112,20 @@ Signal K server will now be started automatically when your RPi boots up. If you want to temporarily stop the Signal K server, you can do so by entering the following commands: ``` -$ sudo systemctl stop signalk.service -$ sudo systemctl stop signalk.socket +sudo systemctl stop signalk.service +sudo systemctl stop signalk.socket ``` To start Signal K server again enter the following commands: ``` -$ sudo systemctl start signalk.service -$ sudo systemctl start signalk.socket +sudo systemctl start signalk.service +sudo systemctl start signalk.socket ``` To stop Signal K server from starting automatically enter the following commands: ``` -$ sudo systemctl disable signalk.service -$ sudo systemctl disable signalk.socket +sudo systemctl disable signalk.service +sudo systemctl disable signalk.socket ``` diff --git a/docs/src/setup/SK_file_stream_N2K.png b/docs/src/setup/SK_file_stream_N2K.png new file mode 100644 index 000000000..f528d93a3 Binary files /dev/null and b/docs/src/setup/SK_file_stream_N2K.png differ diff --git a/docs/src/setup/configuration.md b/docs/src/setup/configuration.md index 2116a5e22..22929918a 100644 --- a/docs/src/setup/configuration.md +++ b/docs/src/setup/configuration.md @@ -42,10 +42,26 @@ The options presented will vary based on the data type chosen. Please refer to the [Canboat PGN database](https://canboat.github.io/canboat/canboat.html) to see what PGNs are supported. - **_NMEA0183_**: The processing of NMEA0183 sentences is done by [nmea0183-signalk](https://github.com/SignalK/signalk-parser-nmea0183) +**Connection type "File Stream"** + +Sample files are available which can be set up as input for the server. + +Use below command to get the path to a NMEA 2000 file with navigation data and AIS targets. + +``` +sudo find / -name "aava-n2k.data" +``` +Set up according to picture. + +![SK_N2K_file](./SK_file_stream_N2K.png) + +To get the path for the sample file, data type NMEA 0183, use below command. +``` +sudo find / -name "plaka.log" +``` ### Install Plugins and Webapps Signal K server functionality can be extended through the use of plugins and webapps. diff --git a/docs/src/setup/seatalk/seatalk.md b/docs/src/setup/seatalk/seatalk.md index 00d5c249f..4bbe9e2ba 100644 --- a/docs/src/setup/seatalk/seatalk.md +++ b/docs/src/setup/seatalk/seatalk.md @@ -2,6 +2,8 @@ ### Introduction +Please note that this setup will [not, for the moment, run on a Raspberry Pi 5](https://github.com/SignalK/signalk-server/issues/1658) !! + The Signal K Server supports a variety of data connection types including _Seatalk (GPIO)_ which provides the ability to receive Raymarine Seatalk 1 (ST1) data, via simple DIY hardware connected to a Raspberry Pi GPIO, and convert it to Signal K deltas. This information can then be forwarded by the Signal K Server to a NMEA 0183 or NMEA 2000 network using appropriate hardware and plugins. A guide to SeaTalk can be found [here](http://boatprojects.blogspot.com/2012/12/beginners-guide-to-raymarines-seatalk.html). @@ -10,11 +12,15 @@ _Inspired by [Read SeaTalk1 from the Raspberry Pi GPIO using pigpio](https://git ### Hardware -![ST1_opto_SK](./seatalk_circuit_1.jpg) - Using an optocoupler as the hardware interface is recommended as it creates electrical isolation from hazardous voltages and avoids ground loops. -The circuit above uses the [PC817 optocoupler](https://www.amazon.com/ARCELI-Optocoupler-Isolation-Converter-Photoelectric/dp/B07M78S8LB/ref=sr_1_2?dchild=1&keywords=pc817+optocoupler&qid=1593516071&sr=8-2) but any equivlent product can be used. The LED in the circuit will flicker when there is ST1 traffic. +The circuit below uses the [PC817 optocoupler board](https://www.amazon.com/ARCELI-Optocoupler-Isolation-Converter-Photoelectric/dp/B07M78S8LB/ref=sr_1_2?dchild=1&keywords=pc817+optocoupler&qid=1593516071&sr=8-2) but any equivlent product can be used. The LED in the circuit will flicker when there is ST1 traffic. + +![ST1_opto_SK](./seatalk_circuit_3.jpg) + +If you are building the interface yourself use the below circuit instead. If you don't want any flickering just drop the LED at the input. + +![ST1_opto_SK](./seatalk_circuit_4.jpg) A simpler, non-electrically isolated, solution is detailed below, using a low signal NPN transistor which inverts and shifts the voltage from 12V DC to 3.3V DC. @@ -70,8 +76,7 @@ _Example Data Connection:_ ![GPIO](./gpio.png) -- Set _Invert Signal_ based on the hardware interface you have used _(e.g. Select **Yes** if using the hardware setup above. Select **No** if using a hardware interface that does not invert the ST1 signal)_. - +- Set _Invert Signal_ based on the hardware interface you have used _(e.g. Select **No** if using the optocoupler hardware setup above. Select **Yes** if using a hardware interface that inverts the ST1 signal)_. - Click **Apply** to save your data connection settings. diff --git a/docs/src/setup/seatalk/seatalk_circuit_1.jpg b/docs/src/setup/seatalk/seatalk_circuit_1.jpg deleted file mode 100644 index c050e4e5e..000000000 Binary files a/docs/src/setup/seatalk/seatalk_circuit_1.jpg and /dev/null differ diff --git a/docs/src/setup/seatalk/seatalk_circuit_3.jpg b/docs/src/setup/seatalk/seatalk_circuit_3.jpg new file mode 100644 index 000000000..4230b8855 Binary files /dev/null and b/docs/src/setup/seatalk/seatalk_circuit_3.jpg differ diff --git a/docs/src/setup/seatalk/seatalk_circuit_4.jpg b/docs/src/setup/seatalk/seatalk_circuit_4.jpg new file mode 100644 index 000000000..30ee31092 Binary files /dev/null and b/docs/src/setup/seatalk/seatalk_circuit_4.jpg differ diff --git a/package.json b/package.json index fa7e2cad0..c49f45ac5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "signalk-server", - "version": "2.2.0", + "version": "2.5.0", "description": "An implementation of a [Signal K](http://signalk.org) server for boats.", "main": "index.js", "scripts": { @@ -72,13 +72,13 @@ ], "dependencies": { "@signalk/course-provider": "^1.0.0", - "@signalk/n2k-signalk": "^2.0.0", + "@signalk/n2k-signalk": "^3.0.0", "@signalk/nmea0183-signalk": "^3.0.0", "@signalk/resources-provider": "^1.0.0", - "@signalk/server-admin-ui": "2.1.x", - "@signalk/server-api": "2.1.x", + "@signalk/server-admin-ui": "2.4.x", + "@signalk/server-api": "2.3.x", "@signalk/signalk-schema": "^1.7.1", - "@signalk/streams": "^3.2.0", + "@signalk/streams": "^4.1.0", "api-schema-builder": "^2.0.11", "baconjs": "^1.0.1", "bcryptjs": "^2.4.3", @@ -93,7 +93,6 @@ "cookie-parser": "^1.4.3", "cors": "^2.5.2", "debug": "^4.3.3", - "devcert": "^1.2.2", "dnssd2": "1.0.0", "errorhandler": "^1.3.0", "express": "^4.10.4", @@ -117,6 +116,7 @@ "ncp": "^2.0.0", "node-fetch": "^2.6.0", "primus": "^7.0.0", + "selfsigned": "^2.4.1", "semver": "^7.5.4", "split": "^1.0.0", "stat-mode": "^1.0.0", diff --git a/packages/server-admin-ui/package.json b/packages/server-admin-ui/package.json index adfbd1ae9..77c302a33 100644 --- a/packages/server-admin-ui/package.json +++ b/packages/server-admin-ui/package.json @@ -1,6 +1,6 @@ { "name": "@signalk/server-admin-ui", - "version": "2.1.0", + "version": "2.4.0", "description": "Signal K server admin webapp", "author": "Scott Bender, Teppo Kurki", "contributors": [ diff --git a/packages/server-admin-ui/src/components/Sidebar/Sidebar.js b/packages/server-admin-ui/src/components/Sidebar/Sidebar.js index cab93cd2d..cdbc6f4e0 100644 --- a/packages/server-admin-ui/src/components/Sidebar/Sidebar.js +++ b/packages/server-admin-ui/src/components/Sidebar/Sidebar.js @@ -102,7 +102,6 @@ class Sidebar extends Component { // nav dropdown const navDropdown = (item, key) => { - console.log('****', item) return (
  • { - const knownParams = ['subscribe', 'sendCachedValues'] + const knownParams = ['subscribe', 'sendCachedValues', 'events'] const queryParam = knownParams .map((p, i) => [i, params[p]]) .filter((x) => x[1] !== undefined) diff --git a/packages/server-admin-ui/src/views/security/Settings.js b/packages/server-admin-ui/src/views/security/Settings.js index cae4b3c92..1d4ca7bf4 100644 --- a/packages/server-admin-ui/src/views/security/Settings.js +++ b/packages/server-admin-ui/src/views/security/Settings.js @@ -210,13 +210,14 @@ class Settings extends Component { {' '} @@ -232,7 +233,8 @@ class Settings extends Component { value={this.state.allowedCorsOrigins} /> - Use comma delimited list, example: + Use either * or a comma delimited list of origins, + example: http://host1.name.com:3000,http://host2.name.com:3000 diff --git a/packages/server-api/package.json b/packages/server-api/package.json index f3a482272..ca18fc07d 100644 --- a/packages/server-api/package.json +++ b/packages/server-api/package.json @@ -1,6 +1,6 @@ { "name": "@signalk/server-api", - "version": "2.1.0", + "version": "2.3.0", "description": "signalk-server Typescript API for plugins etc with relevant implementation classes", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/streams/autodetect.js b/packages/streams/autodetect.js index 3c002417e..5acca252a 100644 --- a/packages/streams/autodetect.js +++ b/packages/streams/autodetect.js @@ -113,6 +113,9 @@ Splitter.prototype._transform = function (msg, encoding, _done) { default: try { const parsed = JSON.parse(msg.data) + const timestamp = new Date(Number(msg.timestamp)) + parsed.updates && + parsed.updates.forEach((update) => (update.timestamp = timestamp)) this.push(parsed) this.demuxEmitData(parsed) } catch (e) { @@ -137,10 +140,12 @@ function ToTimestamped(deMultiplexer, options) { } require('util').inherits(ToTimestamped, Transform) +// runs only once, self-assigns the actual transform functions +// on first call ToTimestamped.prototype._transform = function (msg, encoding, done) { const line = msg.toString() this.multiplexedFormat = - line.length > 16 && line.charAt(13) === ';' && line.charAt(15) === ';' + line.length > 16 && line.charAt(13) === ';' && line.split(';').length >= 3 if (this.multiplexedFormat) { if (this.options.noThrottle) { this.deMultiplexer.toTimestamped.pipe(this.deMultiplexer.splitter) @@ -176,7 +181,11 @@ ToTimestamped.prototype.handleMixed = function (msg, encoding, done) { ToTimestamped.prototype.handleMultiplexed = function (msg, encoding, done) { const line = msg.toString() const parts = line.split(';') - this.push({ timestamp: parts[0], discriminator: parts[1], data: parts[2] }) + this.push({ + timestamp: parts[0], + discriminator: parts[1], + data: parts.slice(2).join(';'), + }) done() } diff --git a/packages/streams/canboatjs.js b/packages/streams/canboatjs.js index 8e18f3955..209c28446 100644 --- a/packages/streams/canboatjs.js +++ b/packages/streams/canboatjs.js @@ -29,10 +29,12 @@ function CanboatJs(options) { this.fromPgn.on('warning', (pgn, warning) => { debug(`[warning] ${pgn.pgn} ${warning}`) + options.app.emit(`canboatjs:warning`, warning) }) this.fromPgn.on('error', (pgn, err) => { console.error(pgn.input, err.message) + options.app.emit(`canboatjs:error`, err) }) this.app = options.app @@ -48,12 +50,16 @@ CanboatJs.prototype._transform = function (chunk, encoding, done) { pgnData.timestamp = new Date(Number(chunk.timestamp)).toISOString() this.push(pgnData) this.app.emit(this.analyzerOutEvent, pgnData) + } else { + this.app.emit('canboatjs:unparsed:object', chunk) } } else { const pgnData = this.fromPgn.parse(chunk) if (pgnData) { this.push(pgnData) this.app.emit(this.analyzerOutEvent, pgnData) + } else { + this.app.emit('canboatjs:unparsed:data', chunk) } } done() diff --git a/packages/streams/package.json b/packages/streams/package.json index 2c87f135d..952d8dbec 100644 --- a/packages/streams/package.json +++ b/packages/streams/package.json @@ -1,6 +1,6 @@ { "name": "@signalk/streams", - "version": "3.2.0", + "version": "4.1.0", "description": "Utilities for handling streams of Signal K data", "main": "index.js", "scripts": { @@ -22,9 +22,9 @@ }, "homepage": "https://github.com/SignalK/signalk-server-node#readme", "dependencies": { - "@canboat/canboatjs": "^1.4.0", + "@canboat/canboatjs": "^2.0.0", "@signalk/client": "^2.3.0", - "@signalk/n2k-signalk": "^2.0.0", + "@signalk/n2k-signalk": "^3.0.0", "@signalk/nmea0183-signalk": "^3.0.0", "@signalk/nmea0183-utilities": "^0.8.0", "@signalk/signalk-schema": "^1.5.0", diff --git a/src/cors.ts b/src/cors.ts index 8d8e3859f..2ca5deba4 100644 --- a/src/cors.ts +++ b/src/cors.ts @@ -9,15 +9,29 @@ export function setupCors( ) { const corsDebug = createDebug('signalk-server:cors') + const corsOptions: CorsOptions = { + credentials: true + } + const corsOrigins = allowedCorsOrigins ? allowedCorsOrigins .split(',') .map((s: string) => s.trim().replace(/\/*$/, '')) : [] - corsDebug(`corsOrigins:${corsOrigins.toString()}`) - const corsOptions: CorsOptions = { - credentials: true, - origin: corsOrigins + + // default wildcard cors configuration does not work + // with credentials:include client requests, so add + // our own wildcard rule that will match all origins + // but respond with that origin, not the default * + if (allowedCorsOrigins?.startsWith('*')) { + corsOptions.origin = (origin: string | undefined, cb) => cb(null, origin) + corsDebug('Allowing all origins') + } else if (corsOrigins.length > 0) { + // set origin only if corsOrigins are set so that + // we get the default cors module functionality + // for simple requests by default + corsOptions.origin = corsOrigins + corsDebug(`corsOrigins:${corsOrigins.toString()}`) } app.use(cors(corsOptions)) @@ -44,7 +58,11 @@ export const handleAdminUICORSOrigin = ( securityConfig.allowedCorsOrigins.length > 0 ) { allowedCorsOrigins = securityConfig.allowedCorsOrigins?.split(',') - if (allowedCorsOrigins.indexOf(securityConfig.adminUIOrigin) === -1) { + const adminUIOriginUrl = new URL(securityConfig.adminUIOrigin) + if ( + allowedCorsOrigins.indexOf(securityConfig.adminUIOrigin) === -1 && + adminUIOriginUrl.hostname !== 'localhost' + ) { allowedCorsOrigins.push(securityConfig.adminUIOrigin) } } diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 000000000..9d6ac6730 --- /dev/null +++ b/src/events.ts @@ -0,0 +1,266 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { EventEmitter } from 'node:events' +import { createDebug } from './debug' +import { Debugger } from 'debug' +import { Brand } from '@signalk/server-api' + +export function startEvents( + app: any, + spark: any, + onEvent: (data: any) => void, + eventsFromQuery = '' +) { + const events = eventsFromQuery.split(',') + events.forEach((event) => { + app.on(event, (data: any) => onEvent({ event, data })) + spark.onDisconnects.push(() => app.removeListener(event, onEvent)) + }) +} + +export function startServerEvents(app: any, spark: any, onServerEvent: any) { + app.on('serverevent', onServerEvent) + spark.onDisconnects.push(() => { + app.removeListener('serverevent', onServerEvent) + }) + try { + spark.write({ + type: 'VESSEL_INFO', + data: { + name: app.config.vesselName, + mmsi: app.config.vesselMMSI, + uuid: app.config.vesselUUID + } + }) + } catch (e: any) { + if (e.code !== 'ENOENT') { + console.error(e) + } + } + Object.keys(app.lastServerEvents).forEach((propName) => { + spark.write(app.lastServerEvents[propName]) + }) + spark.write({ + type: 'DEBUG_SETTINGS', + data: app.logging.getDebugSettings() + }) + if (app.securityStrategy.canAuthorizeWS()) { + spark.write({ + type: 'RECEIVE_LOGIN_STATUS', + data: app.securityStrategy.getLoginStatus(spark.request) + }) + } + spark.write({ + type: 'SOURCEPRIORITIES', + data: app.config.settings.sourcePriorities || {} + }) +} + +type Handler = (...args: any[]) => void +interface EventMap { + [k: string]: Handler | Handler[] | undefined +} + +function safeApply( + handler: (this: T, ..._args: A) => void, + context: T, + args: A +): void { + try { + Reflect.apply(handler, context, args) + } catch (err) { + // Throw error after timeout so as not to interrupt the stack + setTimeout(() => { + throw err + }) + } +} + +function arrayClone(arr: T[]): T[] { + const n = arr.length + const copy = new Array(n) + for (let i = 0; i < n; i += 1) { + copy[i] = arr[i] + } + return copy +} + +export type EventName = Brand +export type EmitterId = Brand +export type ListenerId = Brand +export type EventsActorId = EmitterId & ListenerId + +export interface WrappedEmitter { + getEmittedCount: () => number + getEventRoutingData: () => { + events: { + event: string + emitters: any + listeners: any + }[] + } + + emit: (this: any, eventName: string, ...args: any[]) => boolean + addListener: ( + eventName: EventName, + listener: (...args: any[]) => void + ) => EventEmitter + + bindMethodsById: (eventsId: EventsActorId) => { + emit: (this: any, eventName: string, ...args: any[]) => boolean + addListener: ( + eventName: EventName, + listener: (...args: any[]) => void + ) => void + on: (eventName: EventName, listener: (...args: any[]) => void) => void + } +} + +export interface WithWrappedEmitter { + wrappedEmitter: WrappedEmitter +} + +export function wrapEmitter(targetEmitter: EventEmitter): WrappedEmitter { + const targetAddListener = targetEmitter.addListener.bind(targetEmitter) + + const eventDebugs: { [key: string]: Debugger } = {} + const eventsData: { + [eventName: EventName]: { + emitters: { + [emitterId: EmitterId]: number + } + listeners: { + [listenerId: ListenerId]: boolean + } + } + } = {} + + let emittedCount = 0 + + function safeEmit(this: any, eventName: string, ...args: any[]): boolean { + if (eventName !== 'serverlog') { + let eventDebug = eventDebugs[eventName] + if (!eventDebug) { + eventDebugs[eventName] = eventDebug = createDebug( + `signalk-server:events:${eventName}` + ) + } + if (eventDebug.enabled) { + //there is ever only one rest argument, outputting args results in a 1 element array + eventDebug(args[0]) + } + } + + // from https://github.com/MetaMask/safe-event-emitter/blob/main/index.t + let doError = eventName === 'error' + + const events: EventMap = (targetEmitter as any)._events + if (events !== undefined) { + doError = doError && events.error === undefined + } else if (!doError) { + return false + } + + // If there is no 'error' event listener then throw. + if (doError) { + let er + if (args.length > 0) { + ;[er] = args + } + if (er instanceof Error) { + // Note: The comments on the `throw` lines are intentional, they show + // up in Node's output if this results in an unhandled exception. + throw er // Unhandled 'error' event + } + // At least give some kind of context to the user + const err = new Error(`Unhandled error.${er ? ` (${er.message})` : ''}`) + + ;(err as any).context = er + throw err // Unhandled 'error' event + } + + const handler = events[eventName] + + if (handler === undefined) { + return false + } + + emittedCount++ + if (typeof handler === 'function') { + safeApply(handler, this, args) + } else { + const len = handler.length + const listeners = arrayClone(handler) + for (let i = 0; i < len; i += 1) { + safeApply(listeners[i], this, args) + } + } + + return true + } + + function emitWithEmitterId( + emitterId: EmitterId, + eventName: EventName, + ...args: any[] + ) { + const emittersForEvent = ( + eventsData[eventName] ?? + (eventsData[eventName] = { emitters: {}, listeners: {} }) + ).emitters + if (!emittersForEvent[emitterId]) { + emittersForEvent[emitterId] = 0 + } + emittersForEvent[emitterId]++ + safeEmit(`${emitterId}:${eventName}`, ...args) + return safeEmit(eventName, ...args) + } + + const addListenerWithId = function ( + listenerId: ListenerId, + eventName: EventName, + listener: (...args: any[]) => void + ) { + const listenersForEvent = ( + eventsData[eventName] ?? + (eventsData[eventName] = { emitters: {}, listeners: {} }) + ).listeners + if (!listenersForEvent[listenerId]) { + listenersForEvent[listenerId] = true + } + return targetAddListener(eventName, listener) + } + + return { + getEmittedCount: () => emittedCount, + getEventRoutingData: () => ({ + events: Object.entries(eventsData).map(([event, data]) => ({ + event, + ...data + })) + }), + + emit: function (this: any, eventName: string, ...args: any[]): boolean { + return emitWithEmitterId( + 'NO_EMITTER_ID' as EmitterId, + eventName as EventName, + ...args + ) + }, + addListener: (eventName: EventName, listener: (...args: any[]) => void) => + addListenerWithId('NO_LISTENER_ID' as ListenerId, eventName, listener), + + bindMethodsById: (actorId: EventsActorId) => { + const addListener = ( + eventName: EventName, + listener: (...args: any[]) => void + ) => addListenerWithId(actorId, eventName, listener) + return { + emit: function (this: any, eventName: string, ...args: any[]): boolean { + return emitWithEmitterId(actorId, eventName as EventName, ...args) + }, + addListener, + on: addListener + } + } + } +} diff --git a/src/index.ts b/src/index.ts index b7f3f575e..843e66544 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,7 +32,6 @@ import { Update } from '@signalk/server-api' import { FullSignalK, getSourceId } from '@signalk/signalk-schema' -import { Debugger } from 'debug' import express, { IRouter, Request, Response } from 'express' import http from 'http' import https from 'https' @@ -60,6 +59,8 @@ import SubscriptionManager from './subscriptionmanager' import { PluginId, PluginManager } from './interfaces/plugins' import { OpenApiDescription, OpenApiRecord } from './api/swagger' import { WithProviderStatistics } from './deltastats' +import { pipedProviders } from './pipedproviders' +import { EventsActorId, WithWrappedEmitter, wrapEmitter } from './events' const debug = createDebug('signalk-server') const { StreamBundle } = require('./streambundle') @@ -76,7 +77,8 @@ class Server { PluginManager & WithSecurityStrategy & IRouter & - WithProviderStatistics + WithProviderStatistics & + WithWrappedEmitter constructor(opts: ServerOptions) { const FILEUPLOADSIZELIMIT = process.env.FILEUPLOADSIZELIMIT || '10mb' const bodyParser = require('body-parser') @@ -316,23 +318,10 @@ class Server { const self = this const app = this.app - const eventDebugs: { [key: string]: Debugger } = {} - const expressAppEmit = app.emit.bind(app) - app.emit = (eventName: string, ...args: any[]) => { - if (eventName !== 'serverlog') { - let eventDebug = eventDebugs[eventName] - if (!eventDebug) { - eventDebugs[eventName] = eventDebug = createDebug( - `signalk-server:events:${eventName}` - ) - } - if (eventDebug.enabled) { - eventDebug(args) - } - } - expressAppEmit(eventName, ...args) - return true - } + app.wrappedEmitter = wrapEmitter(app) + app.emit = app.wrappedEmitter.emit + app.on = app.wrappedEmitter.addListener as any + app.addListener = app.wrappedEmitter.addListener as any this.app.intervals = [] @@ -432,7 +421,7 @@ class Server { await startApis(app) startInterfaces(app) startMdns(app) - app.providers = require('./pipedproviders')(app).start() + app.providers = pipedProviders(app as any).start() const primaryPort = getPrimaryPort(app) debug(`primary port:${primaryPort}`) @@ -599,7 +588,7 @@ function startMdns(app: ServerApp & WithConfig) { } } -function startInterfaces(app: ServerApp & WithConfig) { +function startInterfaces(app: ServerApp & WithConfig & WithWrappedEmitter) { debug('Interfaces config:' + JSON.stringify(app.config.settings.interfaces)) const availableInterfaces = require('./interfaces') _.forIn(availableInterfaces, (theInterface: any, name: string) => { @@ -609,14 +598,34 @@ function startInterfaces(app: ServerApp & WithConfig) { (app.config.settings.interfaces || {})[name] ) { debug(`Loading interface '${name}'`) - app.interfaces[name] = theInterface(app) - if (app.interfaces[name] && _.isFunction(app.interfaces[name].start)) { + const boundEventMethods = app.wrappedEmitter.bindMethodsById( + `interface:${name}` as EventsActorId + ) + + const appCopy = { + ...app, + ...boundEventMethods + } + const handler = { + set(obj: any, prop: any, value: any) { + ;(app as any)[prop] = value + return true + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + get(target: any, prop: string | symbol, receiver: any) { + return (app as any)[prop] + } + } + const _interface = (appCopy.interfaces[name] = theInterface( + new Proxy(appCopy, handler) + )) + if (_interface && _.isFunction(_interface.start)) { if ( - _.isUndefined(app.interfaces[name].forceInactive) || - !app.interfaces[name].forceInactive + _.isUndefined(_interface.forceInactive) || + !_interface.forceInactive ) { debug(`Starting interface '${name}'`) - app.interfaces[name].data = app.interfaces[name].start() + _interface.data = _interface.start() } else { debug(`Not starting interface '${name}' by forceInactive`) } diff --git a/src/interfaces/plugins.ts b/src/interfaces/plugins.ts index 69afdad56..bc6fb8e86 100644 --- a/src/interfaces/plugins.ts +++ b/src/interfaces/plugins.ts @@ -17,6 +17,7 @@ */ import { Brand, + PointDestination, PropertyValues, PropertyValuesCallback, ResourceProvider, @@ -36,20 +37,21 @@ import express, { Request, Response } from 'express' import fs from 'fs' import _ from 'lodash' import path from 'path' -import { ResourcesApi } from '../api/resources' import { AutopilotApi } from '../api/autopilot' import { CourseApi } from '../api/course' +import { ResourcesApi } from '../api/resources' import { SERVERROUTESPREFIX } from '../constants' import { createDebug } from '../debug' import { listAllSerialPorts } from '../serialports' const debug = createDebug('signalk-server:interfaces:plugins') -import { modulesWithKeyword } from '../modules' import { OpenApiDescription, OpenApiRecord } from '../api/swagger' import { CONNECTION_WRITE_EVENT_NAME, ConnectionWriteEvent } from '../deltastats' +import { EventsActorId } from '../events' +import { modulesWithKeyword } from '../modules' const put = require('../put') const _putPath = put.putPath @@ -190,6 +192,11 @@ module.exports = (theApp: any) => { .then(([schema, uiSchema]) => { const status = providerStatus.find((p: any) => p.id === plugin.name) const statusMessage = status ? status.message : '' + if (schema === undefined) { + console.error( + `Error: plugin ${plugin.id} is missing configuration schema` + ) + } resolve({ id: plugin.id, name: plugin.name, @@ -197,7 +204,7 @@ module.exports = (theApp: any) => { keywords: plugin.keywords, version: plugin.version, description: plugin.description, - schema, + schema: schema || {}, statusMessage, uiSchema, state: plugin.state, @@ -593,6 +600,11 @@ module.exports = (theApp: any) => { } appCopy.handleMessage = handleMessageWrapper(app, plugin.id) + const boundEventMethods = (app as any).wrappedEmitter.bindMethodsById( + `plugin:${plugin.id}` as EventsActorId + ) + _.assign(appCopy, boundEventMethods) + appCopy.savePluginOptions = (configuration, cb) => { savePluginOptions( plugin.id, diff --git a/src/interfaces/ws.js b/src/interfaces/ws.js index f89e3e4a7..a1a0c5b02 100644 --- a/src/interfaces/ws.js +++ b/src/interfaces/ws.js @@ -26,6 +26,7 @@ const { const { putPath } = require('../put') import { createDebug } from '../debug' import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken' +import { startEvents, startServerEvents } from '../events' const debug = createDebug('signalk-server:interfaces:ws') const debugConnection = createDebug('signalk-server:interfaces:ws:connections') const Primus = require('primus') @@ -678,7 +679,20 @@ function handleRealtimeConnection(app, spark, onChange) { if (spark.query.serverevents === 'all') { spark.hasServerEvents = true - startServerEvents(app, spark) + startServerEvents( + app, + spark, + wrapWithverifyWS(app.securityStrategy, spark, spark.write.bind(spark)) + ) + } + + if (spark.query.events) { + startEvents( + app, + spark, + wrapWithverifyWS(app.securityStrategy, spark, spark.write.bind(spark)), + spark.query.events + ) } } @@ -698,49 +712,6 @@ function sendLatestDeltas(app, deltaCache, selfContext, spark) { }) } -function startServerEvents(app, spark) { - const onServerEvent = wrapWithverifyWS( - app.securityStrategy, - spark, - spark.write.bind(spark) - ) - app.on('serverevent', onServerEvent) - spark.onDisconnects.push(() => { - app.removeListener('serverevent', onServerEvent) - }) - try { - spark.write({ - type: 'VESSEL_INFO', - data: { - name: app.config.vesselName, - mmsi: app.config.vesselMMSI, - uuid: app.config.vesselUUID - } - }) - } catch (e) { - if (e.code !== 'ENOENT') { - console.error(e) - } - } - Object.keys(app.lastServerEvents).forEach((propName) => { - spark.write(app.lastServerEvents[propName]) - }) - spark.write({ - type: 'DEBUG_SETTINGS', - data: app.logging.getDebugSettings() - }) - if (app.securityStrategy.canAuthorizeWS()) { - spark.write({ - type: 'RECEIVE_LOGIN_STATUS', - data: app.securityStrategy.getLoginStatus(spark.request) - }) - } - spark.write({ - type: 'SOURCEPRIORITIES', - data: app.config.settings.sourcePriorities || {} - }) -} - function startServerLog(app, spark) { const onServerLogEvent = wrapWithverifyWS( app.securityStrategy, diff --git a/src/pipedproviders.ts b/src/pipedproviders.ts index 4ccf22ea6..e39cac208 100644 --- a/src/pipedproviders.ts +++ b/src/pipedproviders.ts @@ -15,10 +15,11 @@ */ import { PropertyValues, PropertyValuesCallback } from '@signalk/server-api' -import { createDebug } from './debug' import _ from 'lodash' import { Duplex, Writable } from 'stream' import { SignalKMessageHub, WithConfig } from './app' +import { createDebug } from './debug' +import { EventsActorId, WithWrappedEmitter } from './events' class DevNull extends Writable { constructor() { @@ -59,9 +60,10 @@ interface PipedProviderConfig { class PipedProvider {} -module.exports = function ( +export function pipedProviders( app: SignalKMessageHub & - WithConfig & { + WithConfig & + WithWrappedEmitter & { propertyValues: PropertyValues setProviderError: (providerId: string, msg: string) => void } @@ -78,11 +80,15 @@ module.exports = function ( }) // eslint-disable-next-line @typescript-eslint/no-explicit-any const onPropertyValues = (name: string, cb: (value: any) => void) => - app.propertyValues.onPropertyValues(name, cb) + propertyValues.onPropertyValues(name, cb) + const boundEventMethods = app.wrappedEmitter.bindMethodsById( + `connection:${providerConfig.id}` as EventsActorId + ) const appFacade = { emitPropertyValue, onPropertyValues, ...sanitizedApp, + ...boundEventMethods, toJSON: () => 'appFacade' } diff --git a/src/security.ts b/src/security.ts index fb45f0eaa..0154c24e2 100644 --- a/src/security.ts +++ b/src/security.ts @@ -27,7 +27,7 @@ import { } from 'fs' import _ from 'lodash' import path from 'path' -import { certificateFor } from 'devcert' +import { generate } from 'selfsigned' import { Mode } from 'stat-mode' import { WithConfig } from './app' import { createDebug } from './debug' @@ -191,6 +191,8 @@ export interface SecurityStrategy { source: any, path: string ) => boolean + + addAdminMiddleware: (path: string) => void } export class InvalidTokenError extends Error { @@ -356,18 +358,20 @@ export function createCertificateOptions( ) { const location = app.config.configPath ? app.config.configPath : './settings' debug(`Creating certificate files in ${location}`) - certificateFor(['localhost']) - .then(({ key, cert }) => { - writeFileSync(keyFile, key) + generate( + [{ name: 'commonName', value: 'localhost' }], + { days: 3650 }, + function (err, pems) { + writeFileSync(keyFile, pems.private) chmodSync(keyFile, '600') - writeFileSync(certFile, cert) + writeFileSync(certFile, pems.cert) chmodSync(certFile, '600') cb(null, { - key: key, - cert: cert + key: pems.private, + cert: pems.cert }) - }) - .catch(console.error) + } + ) } export function requestAccess( diff --git a/src/serverroutes.ts b/src/serverroutes.ts index c7689cf85..88f45104a 100644 --- a/src/serverroutes.ts +++ b/src/serverroutes.ts @@ -50,6 +50,7 @@ import { } from './security' import { listAllSerialPorts } from './serialports' import { StreamBundle } from './types' +import { WithWrappedEmitter } from './events' const readdir = util.promisify(fs.readdir) const debug = createDebug('signalk-server:serverroutes') // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -73,7 +74,8 @@ interface App WithSecurityStrategy, ConfigApp, IRouter, - PluginManager { + PluginManager, + WithWrappedEmitter { webapps: Package[] logging: { rememberDebug: (r: boolean) => void @@ -835,6 +837,17 @@ module.exports = function ( } ) + app.securityStrategy.addAdminMiddleware( + `${SERVERROUTESPREFIX}/eventsRoutingData` + ) + app.get( + `${SERVERROUTESPREFIX}/eventsRoutingData`, + (req: Request, res: Response) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + res.json(app.wrappedEmitter.getEventRoutingData()) + } + ) + app.get( `${SERVERROUTESPREFIX}/serialports`, (req: Request, res: Response, next: NextFunction) => {