Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/specs/external-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,8 +494,9 @@ Complete example (the PoC's):
| `actions` | array | no | **on-demand actions** displayed on the Configuration screen, max 10 (see below) |
| `transports` | array | no | supported channels, subset of `["local", "cloud"]`; both present → standard "Prefer local connection" toggle (see below) |
| `location` | boolean | no | `true` = requests access to the coordinates of the houses configured in Gladys (`GET /house`, see C.3). The home location is sensitive personal data: the request is shown on the install screen, and an undeclared access gets a `403` — **enforced server-side**, same authorization-contract pattern as `network_discovery` |
| `network_wake` | boolean | no | `true` = requests permission to send Wake-on-LAN magic packets through the Gladys core (`POST /network/wake`, see C.3). The request is shown on the install screen and an undeclared access gets a `403`, enforced server-side. |

No `permissions` field in v1: outbound network access is open and the install screen says so — we do not specify what we cannot enforce (see B.14). The field may appear in a future `manifest_version` when a real restriction exists. What does exist are **targeted, enforceable authorization contracts** — `containers`, `network_discovery`, `webhooks`, `location` — each declared in the manifest, shown to the user before install, and enforced server-side.
No `permissions` field in v1: outbound network access is open and the install screen says so — we do not specify what we cannot enforce (see B.14). The field may appear in a future `manifest_version` when a real restriction exists. What does exist are **targeted, enforceable authorization contracts** — `containers`, `network_discovery`, `webhooks`, `location`, `network_wake` — each declared in the manifest, shown to the user before install, and enforced server-side.

**Cover re-hosted by the indexer**: at each crawl, the indexer downloads the `cover_image`, validates it (JPEG/PNG magic bytes, 800×534, ≤ 150 KB) and publishes a copy on GitHub Pages; **that URL** is the one the index references (`cover_url`, see C.6). Three benefits: no dead link in the catalog, no user IP leak to a third-party server on every catalog display, and guaranteed weight/format. An absent or invalid cover does not reject the integration: it is indexed with a placeholder, and a warning (`level: "warning"`) is published in `rejected.json`.

Expand Down Expand Up @@ -692,6 +693,14 @@ Two more reserved keys cover the **degraded state** — the "it works, but not a

**`POST /api/integration/v1/container/:name/restart`** — body `{}` → `200 { "success": true }`. Typical use: the integration has rewritten one of the sub-container's config files via `/data` (see B.2) and restarts it to apply.

**`POST /api/integration/v1/network/wake`** — body `{ "mac": "64:e4:d5:b4:12:66", "address": "255.255.255.255", "port": 9, "sourcePort": 0 }` → `200 { "success": true }`. Sends a standard Wake-on-LAN magic packet from the Gladys core network namespace. **Requires `network_wake: true` in the manifest** (shown on the install screen); otherwise the core returns `403 FORBIDDEN`.
* mac is required. Accepted formats: 64:e4:d5:b4:12:66, 64-e4-d5-b4-12-66, or 64E4D5B41266.
* address is optional and defaults to 255.255.255.255.
* port is optional and defaults to UDP destination port 9.
* sourcePort is optional and defaults to 0 (ephemeral UDP source port chosen by the operating system).
* The core always builds the standard fixed 102-byte Wake-on-LAN magic packet (6 × 0xFF followed by the target MAC repeated 16 times). The integration cannot provide an arbitrary UDP payload, so this endpoint is not a general UDP proxy.
* A successful send returns 200 { "success": true }. This confirms that the packet was emitted by Gladys, not that the target device actually woke up.

### C.4 Integration WebSocket: protocol

Connection: same host/port as the host API (`ws://<gateway>:<port>/`, same HTTP server). Not authenticated within 5 s → connection terminated (existing behavior).
Expand Down
2 changes: 2 additions & 0 deletions front/src/config/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,8 @@
"installButton": "Installieren",
"networkDiscoveryTitle": "Anfragen zur Netzwerkerkennung",
"networkDiscoveryText": "Diese Integration bittet Gladys, die folgenden Netzwerkankündigungen für sie zu erfassen. Sie kann niemals etwas anderes erfassen.",
"networkWakeTitle": "Anfragen zur Wake-On-LAN",
"networkWakeText": "Diese Integration bittet um die Berechtigung, Wake-on-LAN-Magic-Pakete über Gladys im lokalen Netzwerk zu senden.",
"locationText": "Diese Integration fordert Zugriff auf die Koordinaten (Breiten-/Längengrad) der in Gladys konfigurierten Häuser an.",
"documentationLink": "Dokumentation",
"duplicateWarningTitle": "Eine andere Instanz ist bereits installiert",
Expand Down
2 changes: 2 additions & 0 deletions front/src/config/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,8 @@
"installButton": "Install",
"networkDiscoveryTitle": "Network discovery requests",
"networkDiscoveryText": "This integration asks Gladys to capture the following network announcements on its behalf. It will never be able to capture anything else.",
"networkWakeTitle": "Wake-on-LAN access",
"networkWakeText": "This integration requests permission to send Wake-on-LAN magic packets through Gladys on your local network.",
"locationText": "This integration requests access to the coordinates (latitude/longitude) of the houses configured in Gladys.",
"documentationLink": "Documentation",
"duplicateWarningTitle": "Another instance is already installed",
Expand Down
2 changes: 2 additions & 0 deletions front/src/config/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,8 @@
"installButton": "Installer",
"networkDiscoveryTitle": "Demandes de découverte réseau",
"networkDiscoveryText": "Cette intégration demande à Gladys de capturer pour elle les annonces réseau suivantes. Elle ne pourra jamais rien capturer d'autre.",
"networkWakeTitle": "Accès Wake-on-LAN",
"networkWakeText": "Cette intégration demande l’autorisation d’envoyer des paquets Wake-on-LAN via Gladys sur votre réseau local.",
"locationText": "Cette intégration demande l'accès aux coordonnées (latitude/longitude) des maisons configurées dans Gladys.",
"documentationLink": "Documentation",
"duplicateWarningTitle": "Une autre instance est déjà installée",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Text } from 'preact-i18n';

const NetworkWakeSummary = ({ networkWake }) => {
Comment thread
cursor[bot] marked this conversation as resolved.
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
Expand Up @@ -16,6 +16,7 @@ import DocsLink from '../components/DocsLink';
import { RequestStatus } from '../../../../../utils/consts';
import style from './style.css';
import integrationText from '../integrationText.css';
import NetworkWakeSummary from '../components/NetworkWakeSummary';

class ExternalIntegrationInstallPage extends Component {
getStoreIntegration = async () => {
Expand Down Expand Up @@ -263,6 +264,7 @@ class ExternalIntegrationInstallPage extends Component {
)}

<NetworkDiscoverySummary networkDiscovery={manifest.network_discovery} />
<NetworkWakeSummary networkWake={manifest.network_wake} />

{manifest.location === true && (
<div class="alert alert-info">
Expand Down
16 changes: 16 additions & 0 deletions server/api/controllers/integrationHost.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,21 @@ module.exports = function IntegrationHostController(gladys) {
res.json(results);
}

/**
* @api {post} /api/integration/v1/network/wake networkWake
* @apiName networkWake
* @apiGroup IntegrationHostApi
* @apiDescription Send a Wake-on-LAN magic packet from the Gladys core
* network namespace.
*/
async function networkWake(req, res) {
await gladys.externalIntegration.wakeOnLan(req.externalIntegrationService, req.body);

res.json({
success: true,
});
}

/**
* @api {post} /api/integration/v1/camera/image saveCameraImage
* @apiName saveCameraImage
Expand Down Expand Up @@ -311,6 +326,7 @@ module.exports = function IntegrationHostController(gladys) {
heartbeat: asyncMiddleware(heartbeat),
saveConnectionStatus: asyncMiddleware(saveConnectionStatus),
networkDiscoveryScan: asyncMiddleware(networkDiscoveryScan),
networkWake: asyncMiddleware(networkWake),
saveCameraImage: asyncMiddleware(saveCameraImage),
setDeviceTransports: asyncMiddleware(setDeviceTransports),
publishDiscoveredDevices: asyncMiddleware(publishDiscoveredDevices),
Expand Down
5 changes: 5 additions & 0 deletions server/api/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,11 @@ function getRoutes(gladys) {
externalIntegrationAuth: true,
controller: integrationHostController.networkDiscoveryScan,
},
'post /api/integration/v1/network/wake': {
authenticated: false,
externalIntegrationAuth: true,
controller: integrationHostController.networkWake,
},
'post /api/integration/v1/camera/image': {
authenticated: false,
externalIntegrationAuth: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const MANIFEST_FIELDS = [
'config_schema',
'containers',
'location',
'network_wake',
'network_discovery',
'actions',
'transports',
Expand Down Expand Up @@ -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');
}
if (manifest.network_discovery !== undefined) {
if (
!Array.isArray(manifest.network_discovery) ||
Expand Down
133 changes: 133 additions & 0 deletions server/lib/external-integration/externalIntegration.wakeOnLan.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
const dgram = require('dgram');
const net = require('net');

const { BadParameters, ForbiddenError } = require('../../utils/coreErrors');

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 {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) {
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)) {
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');
}
Comment thread
cursor[bot] marked this conversation as resolved.

if (!Number.isInteger(sourcePort) || sourcePort < 0 || sourcePort > 65535) {
throw new BadParameters('sourcePort: must be an integer between 0 and 65535');
}

if (!service.manifest || service.manifest.network_wake !== true) {
throw new ForbiddenError('Wake-on-LAN is not allowed for this integration');
}
Comment thread
cursor[bot] marked this conversation as resolved.
const payload = buildMagicPacket(mac);
Comment thread
cursor[bot] marked this conversation as resolved.

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);
}
});
Comment thread
vincentBesseau marked this conversation as resolved.
});
}

module.exports = {
wakeOnLan,
};
2 changes: 2 additions & 0 deletions server/lib/external-integration/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -296,5 +297,6 @@ ExternalIntegration.prototype.getDocsMarkdown = getDocsMarkdown;
ExternalIntegration.prototype.fetchManifestFromRepo = fetchManifestFromRepo;
ExternalIntegration.prototype.installFromStore = installFromStore;
ExternalIntegration.prototype.installFromRepoUrl = installFromRepoUrl;
ExternalIntegration.prototype.wakeOnLan = wakeOnLan;

module.exports = ExternalIntegration;
4 changes: 4 additions & 0 deletions server/lib/external-integration/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@
"type": "boolean",
"description": "Declare true to request access to the coordinates of the houses configured in Gladys (GET /house host API). The home location is sensitive personal data: the request is shown on the install screen as an authorization contract, and an integration that does not declare it gets a 403 (enforced server-side)."
},
"network_wake": {
"type": "boolean",
"description": "Declare true to request permission to send Wake-on-LAN magic packets through the Gladys core Host API. The request is shown on the install screen and is enforced server-side; an integration that does not declare it gets a 403."
},
"network_discovery": {
"type": "array",
"minItems": 1,
Expand Down
26 changes: 26 additions & 0 deletions server/test/api/routes.test.js
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');
});
});
Loading
Loading