Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70f3e98a09 |
No files matched your search
+6
-1
@@ -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)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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,30 +164,53 @@ 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.
|
||||||
* Access is tried in a 1 second interval and the url is loaded if successfull.
|
* Access is tried in a 1 second interval and the url is loaded if successfull.
|
||||||
* @param {string} testurl
|
* @param {string} testurl
|
||||||
*/
|
*/
|
||||||
async function loadUrlAsync(testurl) {
|
async function loadUrlAsync(testurl) {
|
||||||
const request = ++navigationRequest;
|
const request = ++navigationRequest;
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user