-
-
Notifications
You must be signed in to change notification settings - Fork 318
Add network wake functionality to external integrations (hardened follow-up of #2848) #2864
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a585e5d
e7fd19a
f7f4212
a5df4b3
25e29b0
21d5330
0c0ef5d
7b7f8f3
ca0fd33
1d9c787
6f4dab9
1e5e7cf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { Text } from 'preact-i18n'; | ||
|
|
||
| const NetworkWakeSummary = ({ networkWake }) => { | ||
| if (!networkWake) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <div class="mb-4"> | ||
| <h4> | ||
| <i class="fe fe-power mr-1" /> | ||
| <Text id="integration.externalIntegration.install.networkWakeTitle" /> | ||
| </h4> | ||
|
|
||
| <p class="text-muted small mb-0"> | ||
| <Text id="integration.externalIntegration.install.networkWakeText" /> | ||
| </p> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default NetworkWakeSummary; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,6 +43,7 @@ const MANIFEST_FIELDS = [ | |
| 'config_schema', | ||
| 'containers', | ||
| 'location', | ||
| 'network_wake', | ||
| 'network_discovery', | ||
| 'actions', | ||
| 'transports', | ||
|
|
@@ -850,6 +851,9 @@ function validateManifest(manifest) { | |
| if (manifest.location !== undefined && typeof manifest.location !== 'boolean') { | ||
| errors.push('location: must be a boolean'); | ||
| } | ||
| if (manifest.network_wake !== undefined && typeof manifest.network_wake !== 'boolean') { | ||
| errors.push('network_wake: must be a boolean'); | ||
| } | ||
|
Comment on lines
+854
to
+856
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: rg -n -C 6 'network_wake' server/test/lib/external-integration/externalIntegration.validateManifest.test.jsRepository: GladysAssistant/Gladys Length of output: 819 🏁 Script executed: #!/bin/bash
set -e
ast-grep outline server/lib/external-integration/externalIntegration.validateManifest.js
ast-grep outline server/test/lib/external-integration/externalIntegration.validateManifest.test.js
printf '\n--- validation implementation ---\n'
sed -n '820,870p' server/lib/external-integration/externalIntegration.validateManifest.js
printf '\n--- test fixture and network_wake cases ---\n'
rg -n -C 12 'TEST_MANIFEST|network_wake' server/test/lib/external-integration/externalIntegration.validateManifest.test.js
printf '\n--- all relevant test assertions ---\n'
rg -n -C 5 "must be a boolean|validateManifest\\(" server/test/lib/external-integration/externalIntegration.validateManifest.test.jsRepository: GladysAssistant/Gladys Length of output: 50378 🏁 Script executed: #!/bin/bash
set -e
test_utils="$(fd -t f -i 'testUtils.test.js' server/test server)"
printf '%s\n' "$test_utils"
for file in $test_utils; do
printf '\n--- %s: network_wake and fixture declaration ---\n' "$file"
rg -n -C 8 'TEST_MANIFEST|network_wake' "$file"
done
printf '\n--- exact network_wake test block ---\n'
sed -n '1068,1110p' server/test/lib/external-integration/externalIntegration.validateManifest.test.js
printf '\n--- manifest field declaration ---\n'
sed -n '25,55p' server/lib/external-integration/externalIntegration.validateManifest.jsRepository: GladysAssistant/Gladys Length of output: 7522 Add a test for Existing tests cover omitted 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| if (manifest.network_discovery !== undefined) { | ||
| if ( | ||
| !Array.isArray(manifest.network_discovery) || | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| const dgram = require('dgram'); | ||
| const net = require('net'); | ||
|
|
||
| const { BadParameters, ForbiddenError, TooManyRequests } = require('../../utils/coreErrors'); | ||
| const { NETWORK_WAKE_MIN_INTERVAL_MS } = require('./constants'); | ||
|
|
||
| const DEFAULT_SOURCE_PORT = 0; | ||
| const DEFAULT_PORT = 9; | ||
| const DEFAULT_ADDRESS = '255.255.255.255'; | ||
|
|
||
| /** | ||
| * @description Normalize a MAC address to a Buffer. | ||
| * @param {string} mac - MAC address in format xx:xx:xx:xx:xx:xx. | ||
| * @returns {Buffer<ArrayBuffer>} - MAC address as a Buffer. | ||
| * @example | ||
| * normalizeMac('00:11:22:33:44:55'); | ||
| */ | ||
| function normalizeMac(mac) { | ||
| if (typeof mac !== 'string') { | ||
| throw new BadParameters('MAC address must be a string'); | ||
| } | ||
|
|
||
| const normalized = mac.replace(/[:-]/g, ''); | ||
|
|
||
| if (!/^[0-9a-fA-F]{12}$/.test(normalized)) { | ||
| throw new BadParameters('Invalid MAC address'); | ||
| } | ||
|
|
||
| return Buffer.from(normalized, 'hex'); | ||
| } | ||
|
|
||
| /** | ||
| * @description Build a Wake-on-LAN magic packet. | ||
| * @param {string} mac - MAC address in format xx:xx:xx:xx:xx:xx. | ||
| * @returns {Buffer<ArrayBuffer>} Magic packet as a Buffer. | ||
| * @example | ||
| * buildMagicPacket('00:11:22:33:44:55'); | ||
| */ | ||
| function buildMagicPacket(mac) { | ||
| const macBuffer = normalizeMac(mac); | ||
|
|
||
| return Buffer.concat([Buffer.alloc(6, 0xff), ...Array.from({ length: 16 }, () => macBuffer)]); | ||
| } | ||
|
|
||
| /** | ||
| * @description Send a Wake-on-LAN magic packet to a target device. | ||
| * @param {object} service - The external integration service (plain object). | ||
| * @param {string} [service.id] - The service id, key of the per-integration rate limit. | ||
| * @param {object} service.manifest - The external integration manifest. | ||
| * @param {boolean} [service.manifest.network_wake] - Whether Wake-on-LAN is allowed for this integration. | ||
| * @param {object} options - Wake-on-LAN options. | ||
| * @param {string} options.mac - Target MAC address. | ||
| * @param {string} [options.address] - Destination/broadcast address. | ||
| * @param {number} [options.port] - Destination UDP port. | ||
| * @param {number} [options.sourcePort] - Source UDP port. | ||
| * @returns {Promise<void>} Promise that resolves when the magic packet is sent. | ||
| * @example | ||
| * await gladys.externalIntegration.wakeOnLan(service, { mac: '00:11:22:33:44:55' }); | ||
| */ | ||
| async function wakeOnLan(service, options) { | ||
| // authorization contract first, like the other host API primitives: | ||
| // an undeclared access is a 403 whatever the payload looks like | ||
| if (!service.manifest || service.manifest.network_wake !== true) { | ||
| throw new ForbiddenError('Wake-on-LAN is not allowed for this integration'); | ||
| } | ||
|
|
||
| if (!options || typeof options !== 'object' || Array.isArray(options)) { | ||
| throw new BadParameters('Invalid Wake-on-LAN options'); | ||
| } | ||
|
|
||
| const { mac, address = DEFAULT_ADDRESS, port = DEFAULT_PORT, sourcePort = DEFAULT_SOURCE_PORT } = options; | ||
|
|
||
| if (!net.isIPv4(address)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Left open in the PR description; flagging for the human review of the Host API philosophy (RFC1918 + limited/directed broadcast vs any IPv4). Not a merge blocker for this follow-up. |
||
| throw new BadParameters('Invalid IPv4 address'); | ||
| } | ||
|
|
||
| if (!Number.isInteger(port) || port < 1 || port > 65535) { | ||
| throw new BadParameters('port: must be an integer between 1 and 65535'); | ||
| } | ||
|
|
||
| if (!Number.isInteger(sourcePort) || sourcePort < 0 || sourcePort > 65535) { | ||
| throw new BadParameters('sourcePort: must be an integer between 0 and 65535'); | ||
| } | ||
|
|
||
| const payload = buildMagicPacket(mac); | ||
|
|
||
| // the core emits on behalf of a third party: bound the emission rate so | ||
| // the endpoint cannot be used to flood from the core's network namespace | ||
| const lastWakeAt = this.networkWakeTimes.get(service.id); | ||
| const now = Date.now(); | ||
| if (lastWakeAt !== undefined && now - lastWakeAt < NETWORK_WAKE_MIN_INTERVAL_MS) { | ||
| throw new TooManyRequests( | ||
| `RATE_LIMIT_EXCEEDED: max 1 wake per ${NETWORK_WAKE_MIN_INTERVAL_MS / 1000} seconds`, | ||
| Math.ceil((lastWakeAt + NETWORK_WAKE_MIN_INTERVAL_MS - now) / 1000), | ||
| ); | ||
| } | ||
| this.networkWakeTimes.set(service.id, now); | ||
|
|
||
| const socket = dgram.createSocket({ | ||
| type: 'udp4', | ||
| reuseAddr: true, | ||
| }); | ||
|
|
||
| await new Promise((resolve, reject) => { | ||
| let settled = false; | ||
|
|
||
| const fail = (error) => { | ||
| if (settled) { | ||
| return; | ||
| } | ||
|
|
||
| settled = true; | ||
|
|
||
| socket.close(() => { | ||
| reject(error); | ||
| }); | ||
| }; | ||
|
|
||
| socket.once('error', fail); | ||
|
|
||
| socket.bind(sourcePort, () => { | ||
| try { | ||
| socket.setBroadcast(true); | ||
|
|
||
| socket.send(payload, port, address, (error) => { | ||
| if (settled) { | ||
| return; | ||
| } | ||
|
|
||
| if (error) { | ||
| fail(error); | ||
| return; | ||
| } | ||
|
|
||
| settled = true; | ||
|
|
||
| socket.close(() => { | ||
| resolve(); | ||
| }); | ||
| }); | ||
| } catch (error) { | ||
| fail(error); | ||
| } | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| module.exports = { | ||
| wakeOnLan, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -105,6 +105,7 @@ const { installFromStore } = require('./store/store.installFromStore'); | |
| const { installFromRepoUrl } = require('./store/store.installFromRepoUrl'); | ||
| const { EVENTS } = require('../../utils/constants'); | ||
| const { eventFunctionWrapper } = require('../../utils/functionsWrapper'); | ||
| const { wakeOnLan } = require('./externalIntegration.wakeOnLan'); | ||
|
|
||
| /** | ||
| * @description External integration supervisor: complete lifecycle of the | ||
|
|
@@ -158,6 +159,8 @@ const ExternalIntegration = function ExternalIntegration( | |
| this.networkDiscoveryScans = new Set(); | ||
| // serviceId -> timestamp of the last active broadcast scan (1/10s) | ||
| this.networkDiscoveryActiveScanTimes = new Map(); | ||
| // serviceId -> timestamp of the last Wake-on-LAN emission (1/2s) | ||
| this.networkWakeTimes = new Map(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Please add |
||
| // supervision timers | ||
| this.startupTimers = new Map(); | ||
| this.restartTimers = new Map(); | ||
|
|
@@ -296,5 +299,6 @@ ExternalIntegration.prototype.getDocsMarkdown = getDocsMarkdown; | |
| ExternalIntegration.prototype.fetchManifestFromRepo = fetchManifestFromRepo; | ||
| ExternalIntegration.prototype.installFromStore = installFromStore; | ||
| ExternalIntegration.prototype.installFromRepoUrl = installFromRepoUrl; | ||
| ExternalIntegration.prototype.wakeOnLan = wakeOnLan; | ||
|
|
||
| module.exports = ExternalIntegration; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| const { expect } = require('chai'); | ||
|
|
||
| const getRoutes = require('../../api/routes'); | ||
| const { buildSupervisor } = require('../lib/external-integration/testUtils.test'); | ||
|
|
||
| describe('API routes', () => { | ||
| it('should register the Wake-on-LAN external integration route', () => { | ||
| const { externalIntegration } = buildSupervisor(); | ||
|
|
||
| const mockedGladys = { | ||
| externalIntegration, | ||
| service: { | ||
| getServices: () => [], | ||
| }, | ||
| }; | ||
|
|
||
| const routes = getRoutes(mockedGladys); | ||
|
|
||
| const route = routes['post /api/integration/v1/network/wake']; | ||
|
|
||
| expect(route).to.not.equal(undefined); | ||
| expect(route.authenticated).to.equal(false); | ||
| expect(route.externalIntegrationAuth).to.equal(true); | ||
| expect(route.controller).to.be.a('function'); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Complete the documented network-wake contract in both references. The external-integration specification should state the accepted IPv4/address rules, validation rules for
portandsourcePort, and includetimeBeforeNextin the429 RATE_LIMIT_EXCEEDEDresponse. The controller API description should also document thenetwork_wake: truepermission gate and the 1 wake per 2 seconds per-integration limit so integration authors can implement validation and retry behavior consistently.📍 Affects 2 files
docs/specs/external-integrations.md#L696-L704(this comment)server/api/controllers/integrationHost.controller.js#L134-L147🤖 Prompt for AI Agents
Source: Coding guidelines