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

@@ -0,0 +1,113 @@
/**
* Moves each host's Wake-on-LAN settings out of ssh_data and into the
* wake-on-lan plugin's host settings: mac_address becomes macAddress and
* wol_broadcast_address becomes broadcastAddress.
*
* The columns stay in the database, unused: core's drizzle migrations run
* before this does, so dropping them there would lose the data first.
*
* Idempotent: a host that already has any wake-on-lan row is skipped, so
* this is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
} from "../../database/repositories/factory.js";
import { selectRows } from "./raw-rows.js";
const PLUGIN_ID = "wake-on-lan";
interface LegacyHostRow {
id: number;
mac_address: string | null;
wol_broadcast_address: string | null;
}
/** The plugin's host settings for one legacy row, defaults left out. */
export function wakeOnLanSettingsFromRow(
row: LegacyHostRow,
): Record<string, unknown> {
const values: Record<string, unknown> = {};
if (row.mac_address) values.macAddress = row.mac_address;
if (row.wol_broadcast_address) {
values.broadcastAddress = row.wol_broadcast_address;
}
return values;
}
export interface WakeOnLanSettingsMigrationResult {
hostsMoved: number;
hostsSkipped: number;
}
export async function runWakeOnLanSettingsMigration(): Promise<WakeOnLanSettingsMigrationResult> {
const result: WakeOnLanSettingsMigrationResult = {
hostsMoved: 0,
hostsSkipped: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
let rows: LegacyHostRow[];
try {
// Raw SQL: schema.ts no longer declares these columns.
rows = await selectRows<LegacyHostRow>(
sql`SELECT id, mac_address, wol_broadcast_address FROM ssh_data`,
);
} catch {
// A fresh install never had the columns.
return result;
}
for (const row of rows) {
const values = wakeOnLanSettingsFromRow(row);
if (Object.keys(values).length === 0) continue;
const scopeId = String(row.id);
try {
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
if (existing.length > 0) {
result.hostsSkipped++;
continue;
}
for (const [key, value] of Object.entries(values)) {
await pluginSettings.set(
PLUGIN_ID,
"host",
scopeId,
key,
JSON.stringify(value),
);
}
result.hostsMoved++;
} catch (error) {
databaseLogger.warn(
"Wake-on-LAN host settings migration failed for a host",
{
operation: "wake_on_lan_settings_migration",
hostId: row.id,
error: error instanceof Error ? error.message : String(error),
},
);
}
}
if (result.hostsMoved > 0) {
databaseLogger.info(
`Moved Wake-on-LAN settings for ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "wake_on_lan_settings_migration" },
);
}
return result;
}
-49
View File
@@ -1,49 +0,0 @@
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 sendWakeOnLan(
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();
});
});
});
}