feat: convert wake-on-lan into a plugin (B20)

This commit is contained in:
LukeGus committed 2026-09-24 15:49:55 -05:00
1 parent d4ec444558
commit 2ac7779eda
50 files changed
+17844 -199

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { createWakeOnLanService } from "./service.js";
import { registerWakeOnLanRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
const service = createWakeOnLanService(ctx);
ctx.services.provide("wake-on-lan.send", service);
registerWakeOnLanRoutes(ctx.http.router<Router>(), ctx, service);
ctx.log.info("Wake-on-LAN routes mounted at /plugin-api/wake-on-lan");
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
@@ -0,0 +1,49 @@
import dgram from "dgram";
const MAC_REGEX = /^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/;
function parseMac(mac: string): Buffer {
return Buffer.from(mac.replace(/[:-]/g, ""), "hex");
}
export function buildMagicPacket(mac: string): Buffer {
const macBytes = parseMac(mac);
const packet = Buffer.alloc(102);
packet.fill(0xff, 0, 6);
for (let i = 0; i < 16; i++) {
macBytes.copy(packet, 6 + i * 6);
}
return packet;
}
export function isValidMac(mac: string): boolean {
return MAC_REGEX.test(mac);
}
export function sendMagicPacket(
mac: string,
broadcastAddress = "255.255.255.255",
): Promise<void> {
return new Promise((resolve, reject) => {
if (!isValidMac(mac)) {
return reject(new Error("Invalid MAC address"));
}
const packet = buildMagicPacket(mac);
const socket = dgram.createSocket("udp4");
socket.once("error", (err) => {
socket.close();
reject(err);
});
socket.bind(() => {
socket.setBroadcast(true);
socket.send(packet, 0, packet.length, 9, broadcastAddress, (err) => {
socket.close();
if (err) reject(err);
else resolve();
});
});
});
}
+47
View File
@@ -0,0 +1,47 @@
import type { Request, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { WakeOnLanV1 } from "./service.js";
export function registerWakeOnLanRoutes(
router: Router,
ctx: PluginContext,
service: WakeOnLanV1,
): void {
router.use(ctx.rbac.require("send") as never);
/**
* @openapi
* /plugin-api/wake-on-lan/host/{id}/wake:
* post:
* summary: Send a Wake-on-LAN magic packet to a host
* tags:
* - Wake-on-LAN
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Packet sent
* 400:
* description: No valid MAC address configured
* 404:
* description: Host not found
*/
router.post("/host/:id/wake", async (req: Request, res: Response) => {
const hostId = Number.parseInt(String(req.params.id), 10);
if (!Number.isInteger(hostId)) {
return res.status(400).json({ error: "Invalid host id" });
}
try {
await service.wake(hostId);
res.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const status = message === "Host not found" ? 404 : 400;
res.status(status).json({ error: message });
}
});
}
@@ -0,0 +1,33 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { isValidMac, sendMagicPacket } from "./magic-packet.js";
export interface WakeOnLanV1 {
wake: (hostId: number) => Promise<void>;
}
export function createWakeOnLanService(ctx: PluginContext): WakeOnLanV1 {
return {
async wake(hostId: number): Promise<void> {
const host = await ctx.hosts.get(hostId);
if (!host) {
throw new Error("Host not found");
}
const macAddress = await ctx.settings.getHost<string>(
hostId,
"macAddress",
);
if (!macAddress || !isValidMac(macAddress)) {
throw new Error("No valid MAC address configured");
}
const broadcastAddress = await ctx.settings.getHost<string>(
hostId,
"broadcastAddress",
);
await ctx.capabilities.require("network:broadcast");
await sendMagicPacket(macAddress, broadcastAddress || undefined);
ctx.log.info(`Wake-on-LAN packet sent to host ${hostId}`);
},
};
}
@@ -0,0 +1,28 @@
import { toast } from "sonner";
import type { PluginApiClient, TermixApp } from "@termix/plugin-sdk/frontend";
export function hasMacAddress(host: Record<string, unknown>): boolean {
const settings = host.pluginSettings as
Record<string, Record<string, unknown>> | undefined;
const macAddress = settings?.["wake-on-lan"]?.macAddress;
return typeof macAddress === "string" && macAddress.trim().length > 0;
}
function errorMessage(error: unknown, fallback: string): string {
const message = (error as { response?: { data?: { error?: string } } })
?.response?.data?.error;
return message ?? fallback;
}
export function wakeHost(
api: PluginApiClient,
t: TermixApp["t"],
hostId: string,
): void {
void api
.post(`/host/${hostId}/wake`)
.then(() => toast.success(t("hostAction.sent")))
.catch((error: unknown) => {
toast.error(errorMessage(error, t("hostAction.failed")));
});
}
@@ -0,0 +1,15 @@
import { Zap } from "lucide-react";
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { hasMacAddress, wakeHost } from "./host-action.js";
export function activate(app: TermixApp): void {
app.registerHostAction({
id: "wake-on-lan",
titleKey: "hostAction.title",
icon: Zap,
kind: "open",
order: 50,
when: (host) => hasMacAddress(host as Record<string, unknown>),
run: (host) => wakeHost(app.api, app.t, String(host.id)),
});
}