Compare commits

..
1 Commits
Author SHA1 Message Date
vollstock 70f3e98a09 Add an ipc to reset the Tooloop Control Centre watchdog. 2026-10-20 10:50:40 +02:00
5 changed files with 245 additions and 6 deletions

No files matched your search

+6 -1
View File
@@ -1,2 +1,7 @@
window.addEventListener('DOMContentLoaded', () => { const { contextBridge, ipcRenderer } = require('electron/renderer');
contextBridge.exposeInMainWorld('signage', {
watchdog: {
reset: (payload) => ipcRenderer.invoke('signage:watchdog:reset', payload)
}
}); });
+87
View File
@@ -0,0 +1,87 @@
const http = require('node:http');
const WATCHDOG_CHANNEL = 'signage:watchdog:reset';
const WATCHDOG_PATH = '/tooloop/api/v1.0/system/watchdog/reset';
const REQUEST_TIMEOUT_MS = 5000;
function isValidPayload(payload) {
return payload !== null &&
typeof payload === 'object' &&
!Array.isArray(payload) &&
Object.keys(payload).length === 1 &&
Object.hasOwn(payload, 'healthy') &&
typeof payload.healthy === 'boolean';
}
function isAuthorizedWatchdogEvent(event, mainWindow, isUrlAllowed) {
if (!mainWindow || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents) return false;
if (!event.senderFrame || event.senderFrame !== event.sender.mainFrame) return false;
return isUrlAllowed(event.senderFrame.url);
}
function sendWatchdogReset(payload, httpClient = http, timeoutMs = REQUEST_TIMEOUT_MS) {
if (!isValidPayload(payload)) {
return Promise.reject(new TypeError('Watchdog reset payload must contain only a boolean healthy field'));
}
const body = JSON.stringify({ healthy: payload.healthy });
return new Promise((resolve, reject) => {
let settled = false;
let timeout;
const finish = (callback, value) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
callback(value);
};
const request = httpClient.request({
hostname: 'localhost',
path: WATCHDOG_PATH,
method: 'POST',
headers: { 'Content-Type': 'application/json' }
}, (response) => {
response.resume();
const statusCode = response.statusCode;
if (!Number.isInteger(statusCode) || statusCode < 200 || statusCode >= 300) {
finish(reject, new Error(`Watchdog service returned HTTP ${statusCode ?? 'unknown'}`));
return;
}
finish(resolve);
});
request.on('error', (error) => finish(reject, error));
timeout = setTimeout(() => {
request.destroy(new Error(`Watchdog request timed out after ${timeoutMs} ms`));
}, timeoutMs);
request.end(body);
});
}
function registerWatchdogHandler(ipcMain, { isAuthorized, onError = () => {}, httpClient = http } = {}) {
ipcMain.handle(WATCHDOG_CHANNEL, async (event, payload) => {
try {
if (!isAuthorized || !isAuthorized(event)) {
throw new Error('Watchdog reset is not authorized for this renderer');
}
if (!isValidPayload(payload)) {
throw new TypeError('Watchdog reset payload must contain only a boolean healthy field');
}
await sendWatchdogReset(payload, httpClient);
} catch (error) {
onError(error);
throw error;
}
});
}
module.exports = {
WATCHDOG_CHANNEL,
WATCHDOG_PATH,
isAuthorizedWatchdogEvent,
isValidPayload,
registerWatchdogHandler,
sendWatchdogReset
};
+30 -5
View File
@@ -1,4 +1,4 @@
const { app, BrowserWindow, nativeTheme, Menu, MenuItem } = require('electron/main'); const { app, BrowserWindow, nativeTheme, Menu, MenuItem, ipcMain } = require('electron/main');
const path = require('node:path'); const path = require('node:path');
const { fileURLToPath } = require('node:url'); const { fileURLToPath } = require('node:url');
const fs = require('fs'); const fs = require('fs');
@@ -7,6 +7,7 @@ const { default: isReachable } = require('is-reachable');
const { setTimeout: delay } = require('node:timers/promises'); const { setTimeout: delay } = require('node:timers/promises');
const config = require('./js/config.js'); const config = require('./js/config.js');
const { Config } = require('./js/config.js'); const { Config } = require('./js/config.js');
const { isAuthorizedWatchdogEvent: isWatchdogEventAuthorized, registerWatchdogHandler } = require('./js/watchdog.js');
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -163,25 +164,48 @@ function createMenu() {
* @returns `true` if the url is allowed, `false` otherwise * @returns `true` if the url is allowed, `false` otherwise
*/ */
function validateDomain(event) { function validateDomain(event) {
if (isUrlAllowed(event.url)) return true;
event.preventDefault();
log.info("Navigation to " + event.url + " prevented");
return false;
}
function isUrlAllowed(urlString) {
if (config == undefined || config.data == undefined) return true; if (config == undefined || config.data == undefined) return true;
let url = new URL(event.url); let url;
try {
url = new URL(urlString);
} catch {
return false;
}
// allow local urls
if (['file:', 'file'].includes(url.protocol)) return true; if (['file:', 'file'].includes(url.protocol)) return true;
if ('whitelist' in config.data && if ('whitelist' in config.data &&
Array.isArray(config.data.whitelist) && Array.isArray(config.data.whitelist) &&
config.data.whitelist.length > 0 && config.data.whitelist.length > 0 &&
!config.data.whitelist.includes(url.hostname)) { !config.data.whitelist.includes(url.hostname)) {
event.preventDefault();
log.info("Navigation to " + event.url + " prevented");
return false; return false;
} }
return true; return true;
} }
function isAuthorizedWatchdogEvent(event) {
return isWatchdogEventAuthorized(event, win, isUrlAllowed);
}
function logWatchdogError(error) {
log.warn('Watchdog reset failed: ' + (error?.message || error));
}
function registerWatchdogBridge() {
registerWatchdogHandler(ipcMain, {
isAuthorized: isAuthorizedWatchdogEvent,
onError: logWatchdogError
});
}
/** /**
* Tests if the host of the url is reachable. * Tests if the host of the url is reachable.
@@ -299,6 +323,7 @@ app.whenReady().then(() => {
log.info('Starting Tooloop Kiosk Browser...'); log.info('Starting Tooloop Kiosk Browser...');
log.info('Logging to ' + log.transports.file.resolvePathFn()); log.info('Logging to ' + log.transports.file.resolvePathFn());
registerWatchdogBridge();
createMenu(); createMenu();
createMainWindow(); createMainWindow();
}); });
+1
View File
@@ -8,6 +8,7 @@
"version": "1.0.0", "version": "1.0.0",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"test": "node --test",
"start": "electron-forge start", "start": "electron-forge start",
"clean": "rm -fr out", "clean": "rm -fr out",
"package": "electron-forge package", "package": "electron-forge package",
+121
View File
@@ -0,0 +1,121 @@
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const test = require('node:test');
const {
WATCHDOG_CHANNEL,
WATCHDOG_PATH,
isAuthorizedWatchdogEvent,
registerWatchdogHandler,
sendWatchdogReset
} = require('../js/watchdog.js');
function createHttpClient({ statusCode = 204, hang = false } = {}) {
const calls = [];
const httpClient = {
request(options, onResponse) {
const request = new EventEmitter();
request.destroy = (error) => request.emit('error', error);
request.end = (body) => {
calls.push({ options, body });
if (!hang) {
queueMicrotask(() => onResponse({ statusCode, resume() {} }));
}
};
return request;
}
};
return { httpClient, calls };
}
test('sends healthy true as a fixed local POST', async () => {
const { httpClient, calls } = createHttpClient();
await sendWatchdogReset({ healthy: true }, httpClient);
assert.deepEqual(calls, [{
options: {
hostname: 'localhost',
path: WATCHDOG_PATH,
method: 'POST',
headers: { 'Content-Type': 'application/json' }
},
body: '{"healthy":true}'
}]);
});
test('sends healthy false in the request body', async () => {
const { httpClient, calls } = createHttpClient();
await sendWatchdogReset({ healthy: false }, httpClient);
assert.equal(calls[0].body, '{"healthy":false}');
});
test('rejects malformed payloads without making a request', async () => {
const { httpClient, calls } = createHttpClient();
for (const payload of [null, [], {}, { healthy: 1 }, { healthy: true, url: 'http://example.test' }]) {
await assert.rejects(sendWatchdogReset(payload, httpClient), /boolean healthy field/);
}
assert.equal(calls.length, 0);
});
test('rejects non-success HTTP responses without parsing their body', async () => {
const { httpClient } = createHttpClient({ statusCode: 503 });
await assert.rejects(sendWatchdogReset({ healthy: true }, httpClient), /HTTP 503/);
});
test('rejects responses without a numeric HTTP status', async () => {
const { httpClient } = createHttpClient({ statusCode: null });
await assert.rejects(sendWatchdogReset({ healthy: true }, httpClient), /HTTP unknown/);
});
test('rejects requests that exceed the timeout', async () => {
const { httpClient } = createHttpClient({ hang: true });
await assert.rejects(sendWatchdogReset({ healthy: true }, httpClient, 5), /timed out after 5 ms/);
});
test('rejects unauthorized renderers without making a request', async () => {
let registeredChannel;
let handler;
const { httpClient, calls } = createHttpClient();
const mainFrame = { url: 'https://allowed.example/' };
const mainWindow = { isDestroyed: () => false, webContents: { mainFrame } };
const ipcMain = {
handle(channel, registeredHandler) {
registeredChannel = channel;
handler = registeredHandler;
}
};
registerWatchdogHandler(ipcMain, {
isAuthorized: (event) => isAuthorizedWatchdogEvent(event, mainWindow, (url) => url === mainFrame.url),
httpClient
});
assert.equal(registeredChannel, WATCHDOG_CHANNEL);
const otherRenderer = { sender: { mainFrame }, senderFrame: mainFrame };
await assert.rejects(handler(otherRenderer, { healthy: true }), /not authorized/);
assert.equal(calls.length, 0);
});
test('rejects subframes and renderers on disallowed URLs', () => {
const mainFrame = { url: 'https://allowed.example/' };
const mainWindow = { isDestroyed: () => false, webContents: { mainFrame } };
const isUrlAllowed = (url) => url === mainFrame.url;
const blockedFrame = { url: 'https://blocked.example/' };
const blockedWindow = { isDestroyed: () => false, webContents: { mainFrame: blockedFrame } };
assert.equal(isAuthorizedWatchdogEvent({
sender: mainWindow.webContents,
senderFrame: { url: mainFrame.url }
}, mainWindow, isUrlAllowed), false);
assert.equal(isAuthorizedWatchdogEvent({
sender: blockedWindow.webContents,
senderFrame: blockedFrame
}, blockedWindow, isUrlAllowed), false);
});