Skip to content

Commit 2000b3c

Browse files
committed
Wake-on-LAN: wake sleeping clients before scheduled backups (#326)
Per-client setting on the Edit form: enable toggle, MAC address (prefilled from the agent, which now reports its primary interface MAC in system info), broadcast address (prefilled from the client's IP — needed for Dockerized servers, where the LAN's directed broadcast routes out of the container but 255.255.255.255 does not), and a wake timeout. Only works when the BBS server is on the same network as the client. Due schedules for offline WoL-enabled clients now queue normally instead of becoming missed-schedule notifications. A scheduler step sends a magic-packet burst each minute (plain UDP via stream sockets — no extension or helper changes) while the job waits; the waking agent picks the job up through normal polling. If the client doesn't heartbeat within the timeout, the job fails with a clear error through the standard notification path, as does a WoL job with no MAC on file.
1 parent a9ee5ba commit 2000b3c

9 files changed

Lines changed: 264 additions & 1 deletion

File tree

agent/bbs-agent.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,34 @@ def set_borg_source(source):
392392
logger.warning("Failed to save borg source: {}".format(e))
393393

394394

395+
def get_primary_mac():
396+
"""Best-effort MAC address of the primary network interface — the
397+
server uses it to prefill the client's Wake-on-LAN MAC field.
398+
Linux: interface of the default route. Elsewhere (and as fallback):
399+
uuid.getnode(), skipped when it returns a random node rather than a
400+
real hardware address (multicast bit set)."""
401+
try:
402+
if not IS_WINDOWS and platform.system().lower() == "linux":
403+
with open("/proc/net/route") as f:
404+
for line in f.readlines()[1:]:
405+
parts = line.split()
406+
if len(parts) > 1 and parts[1] == "00000000": # default route
407+
with open("/sys/class/net/{}/address".format(parts[0])) as af:
408+
mac = af.read().strip().lower()
409+
if mac and mac != "00:00:00:00:00:00":
410+
return mac
411+
except Exception:
412+
pass
413+
try:
414+
import uuid
415+
node = uuid.getnode()
416+
if not (node >> 40) & 0x01: # multicast bit set = randomly generated
417+
return ":".join("{:02x}".format((node >> i) & 0xFF) for i in range(40, -8, -8))
418+
except Exception:
419+
pass
420+
return None
421+
422+
395423
def get_system_info():
396424
"""Gather system information for registration."""
397425
info = {
@@ -418,6 +446,7 @@ def get_system_info():
418446

419447
# Platform and architecture info (for borg binary matching)
420448
info["platform"] = platform.system().lower() # linux, darwin, freebsd, windows
449+
info["mac_address"] = get_primary_mac()
421450
arch = platform.machine()
422451
if arch in ("aarch64", "arm64"):
423452
info["architecture"] = "arm64"

migrations/092_wake_on_lan.sql

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- Wake-on-LAN client setting (#326). The server sends a magic packet to
2+
-- wake a sleeping client when backup work is queued for it. mac_address
3+
-- is agent-reported (prefills the WoL MAC field); wol_mac is the value
4+
-- the admin confirmed/entered. Only works when the BBS server is on the
5+
-- same network as the client.
6+
ALTER TABLE agents
7+
ADD COLUMN mac_address VARCHAR(17) DEFAULT NULL AFTER ip_address,
8+
ADD COLUMN wol_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER server_host_override,
9+
ADD COLUMN wol_mac VARCHAR(17) DEFAULT NULL AFTER wol_enabled,
10+
ADD COLUMN wol_broadcast VARCHAR(45) DEFAULT NULL AFTER wol_mac,
11+
ADD COLUMN wol_timeout_minutes INT NOT NULL DEFAULT 5 AFTER wol_broadcast;

scheduler.php

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,83 @@
230230
echo date('Y-m-d H:i:s') . " Auto-failed: job #{$zj['id']} ({$zj['task_type']}) — running >24h on online agent \"{$zj['agent_name']}\"\n";
231231
}
232232

233+
// Step 2b-wol: Wake-on-LAN — queued backup work for sleeping clients (#326).
234+
// SchedulerService queues due backups for offline agents when WoL is enabled
235+
// (instead of marking the schedule missed). This step sends a magic packet
236+
// burst every minute until the agent's heartbeat brings it online (the job
237+
// then dispatches normally) or the per-client timeout expires, at which
238+
// point the job fails with a clear error. Only works when the BBS server
239+
// is on the same network as the client.
240+
$wolJobs = $db->fetchAll("
241+
SELECT bj.id, bj.queued_at, bj.status_message, bj.backup_plan_id,
242+
a.id AS agent_id, a.name AS agent_name, a.ip_address,
243+
a.wol_mac, a.mac_address, a.wol_broadcast, a.wol_timeout_minutes
244+
FROM backup_jobs bj
245+
JOIN agents a ON a.id = bj.agent_id
246+
WHERE bj.status = 'queued' AND bj.task_type = 'backup'
247+
AND a.wol_enabled = 1 AND a.status = 'offline'
248+
");
249+
foreach ($wolJobs as $wj) {
250+
$wolFail = function (string $error) use ($db, $wj, &$notificationService) {
251+
$db->update('backup_jobs', [
252+
'status' => 'failed',
253+
'completed_at' => date('Y-m-d H:i:s'),
254+
'error_log' => $error,
255+
], 'id = ?', [$wj['id']]);
256+
$db->insert('server_log', [
257+
'agent_id' => $wj['agent_id'],
258+
'backup_job_id' => $wj['id'],
259+
'level' => 'error',
260+
'message' => "Job #{$wj['id']} failed — {$error} (client \"{$wj['agent_name']}\")",
261+
]);
262+
$notificationService = $notificationService ?? new NotificationService();
263+
$notificationService->notify(
264+
'backup_failed',
265+
$wj['agent_id'],
266+
$wj['backup_plan_id'] ? (int) $wj['backup_plan_id'] : null,
267+
"Backup failed on client \"{$wj['agent_name']}\"{$error}",
268+
'critical'
269+
);
270+
echo date('Y-m-d H:i:s') . " Failed: job #{$wj['id']}{$error}\n";
271+
};
272+
273+
$mac = $wj['wol_mac'] ?: $wj['mac_address'];
274+
if (empty($mac)) {
275+
$wolFail('Wake-on-LAN is enabled but no MAC address is configured for this client');
276+
continue;
277+
}
278+
279+
$timeoutSecs = max(1, (int) $wj['wol_timeout_minutes']) * 60;
280+
$elapsed = time() - strtotime($wj['queued_at']);
281+
if ($elapsed > $timeoutSecs) {
282+
$wolFail("Client did not wake within {$wj['wol_timeout_minutes']} minute(s) after Wake-on-LAN");
283+
continue;
284+
}
285+
286+
$broadcast = $wj['wol_broadcast']
287+
?: \BBS\Services\WakeOnLanService::defaultBroadcast($wj['ip_address'])
288+
?: '255.255.255.255';
289+
$sent = \BBS\Services\WakeOnLanService::send($mac, $broadcast);
290+
291+
$remaining = (int) ceil(($timeoutSecs - $elapsed) / 60);
292+
$db->update('backup_jobs', [
293+
'status_message' => $sent
294+
? "Wake-on-LAN sent — waiting for client to wake ({$remaining}m left)"
295+
: "Wake-on-LAN send failed — retrying ({$remaining}m left)",
296+
], 'id = ?', [$wj['id']]);
297+
298+
if (empty($wj['status_message'])) {
299+
// First attempt for this job — log once, not every minute
300+
$db->insert('server_log', [
301+
'agent_id' => $wj['agent_id'],
302+
'backup_job_id' => $wj['id'],
303+
'level' => 'info',
304+
'message' => "Wake-on-LAN magic packet sent to {$mac} via {$broadcast} for client \"{$wj['agent_name']}\"",
305+
]);
306+
}
307+
echo date('Y-m-d H:i:s') . " WoL: magic packet " . ($sent ? 'sent' : 'send FAILED') . " to {$mac} via {$broadcast} (job #{$wj['id']}, {$remaining}m left)\n";
308+
}
309+
233310
// Step 2c: Fail stale management tasks (update_borg, update_agent) after 7 days
234311
// unpicked. These are excluded from Step 2 so they don't fail the moment the
235312
// client's laptop goes to sleep, but we still need a safety valve — if an agent

schema.sql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ CREATE TABLE agents (
9898
name VARCHAR(100) NOT NULL,
9999
hostname VARCHAR(255) DEFAULT NULL,
100100
ip_address VARCHAR(45) DEFAULT NULL,
101+
mac_address VARCHAR(17) DEFAULT NULL,
101102
api_key VARCHAR(64) DEFAULT NULL,
102103
api_key_hash CHAR(64) DEFAULT NULL,
103104
api_key_encrypted TEXT DEFAULT NULL,
@@ -116,6 +117,10 @@ CREATE TABLE agents (
116117
ssh_private_key_encrypted TEXT DEFAULT NULL,
117118
ssh_home_dir VARCHAR(255) DEFAULT NULL,
118119
server_host_override VARCHAR(255) DEFAULT NULL,
120+
wol_enabled TINYINT(1) NOT NULL DEFAULT 0,
121+
wol_mac VARCHAR(17) DEFAULT NULL,
122+
wol_broadcast VARCHAR(45) DEFAULT NULL,
123+
wol_timeout_minutes INT NOT NULL DEFAULT 5,
119124
ssh_port_override INT DEFAULT NULL,
120125
status ENUM('setup', 'online', 'offline', 'error') NOT NULL DEFAULT 'setup',
121126
last_heartbeat DATETIME DEFAULT NULL,

src/Controllers/Api/AgentApiController.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ public function register(): void
116116
$data = [];
117117
if (!empty($input['hostname'])) $data['hostname'] = substr($input['hostname'], 0, 255);
118118
if (!empty($input['ip_address'])) $data['ip_address'] = substr($input['ip_address'], 0, 45);
119+
if (!empty($input['mac_address']) && preg_match('/^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$/', $input['mac_address'])) {
120+
$data['mac_address'] = strtolower($input['mac_address']);
121+
}
119122
if (!empty($input['os_info'])) $data['os_info'] = substr($input['os_info'], 0, 255);
120123
if (!empty($input['borg_version'])) $data['borg_version'] = substr($input['borg_version'], 0, 20);
121124
if (!empty($input['agent_version'])) $data['agent_version'] = substr($input['agent_version'], 0, 20);
@@ -1040,6 +1043,9 @@ public function info(): void
10401043
if (!empty($input['agent_version'])) $data['agent_version'] = substr($input['agent_version'], 0, 20);
10411044
if (!empty($input['hostname'])) $data['hostname'] = substr($input['hostname'], 0, 255);
10421045
if (!empty($input['ip_address'])) $data['ip_address'] = substr($input['ip_address'], 0, 45);
1046+
if (!empty($input['mac_address']) && preg_match('/^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$/', $input['mac_address'])) {
1047+
$data['mac_address'] = strtolower($input['mac_address']);
1048+
}
10431049
if (!empty($input['borg_install_method'])) $data['borg_install_method'] = substr($input['borg_install_method'], 0, 20);
10441050
if (!empty($input['borg_source'])) $data['borg_source'] = substr($input['borg_source'], 0, 20);
10451051
if (!empty($input['borg_binary_path'])) $data['borg_binary_path'] = substr($input['borg_binary_path'], 0, 255);

src/Controllers/ClientController.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1682,6 +1682,23 @@ public function update(int $id): void
16821682
$port = trim($_POST['ssh_port_override']);
16831683
$data['ssh_port_override'] = ($port !== '' && (int) $port > 0 && (int) $port <= 65535) ? (int) $port : null;
16841684
}
1685+
if ($this->isAdmin() && array_key_exists('wol_enabled', $_POST)) {
1686+
$data['wol_enabled'] = !empty($_POST['wol_enabled']) ? 1 : 0;
1687+
1688+
$mac = \BBS\Services\WakeOnLanService::normalizeMac(trim($_POST['wol_mac'] ?? ''));
1689+
$data['wol_mac'] = $mac;
1690+
if ($data['wol_enabled'] && $mac === null && trim($_POST['wol_mac'] ?? '') !== '') {
1691+
$this->flash('danger', 'Invalid MAC address — use the form aa:bb:cc:dd:ee:ff.');
1692+
$this->redirect("/clients/{$id}");
1693+
return;
1694+
}
1695+
1696+
$broadcast = trim($_POST['wol_broadcast'] ?? '');
1697+
$data['wol_broadcast'] = ($broadcast !== '' && filter_var($broadcast, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) ? $broadcast : null;
1698+
1699+
$timeout = (int) ($_POST['wol_timeout_minutes'] ?? 5);
1700+
$data['wol_timeout_minutes'] = min(60, max(1, $timeout));
1701+
}
16851702

16861703
if (!empty($data)) {
16871704
$this->db->update('agents', $data, 'id = ?', [$id]);

src/Services/SchedulerService.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ public function run(): array
4040
AND s.next_run IS NOT NULL
4141
AND s.next_run <= ?
4242
AND bp.enabled = 1
43-
AND a.status IN ('online', 'setup')
43+
AND (a.status IN ('online', 'setup')
44+
OR (a.status = 'offline' AND a.wol_enabled = 1))
4445
", [$now]);
4546

4647
$created = [];
@@ -111,6 +112,7 @@ public function run(): array
111112
AND s.next_run <= ?
112113
AND bp.enabled = 1
113114
AND a.status = 'offline'
115+
AND a.wol_enabled = 0
114116
", [$now]);
115117

116118
foreach ($overdueSchedules as $sched) {

src/Services/WakeOnLanService.php

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
namespace BBS\Services;
4+
5+
/**
6+
* Sends Wake-on-LAN magic packets to sleeping clients (#326).
7+
*
8+
* A magic packet is 6 bytes of 0xFF followed by the target's MAC address
9+
* repeated 16 times, sent as a UDP broadcast (conventionally port 9).
10+
* Only works when the BBS server is on the same network as the client.
11+
* Uses PHP's stream wrappers (with the so_broadcast socket option) so no
12+
* sockets extension is required.
13+
*/
14+
class WakeOnLanService
15+
{
16+
/**
17+
* Normalize a MAC address to aa:bb:cc:dd:ee:ff. Accepts :, - or .
18+
* separators, or 12 bare hex digits. Returns null if invalid.
19+
*/
20+
public static function normalizeMac(string $mac): ?string
21+
{
22+
$hex = strtolower(preg_replace('/[^0-9a-fA-F]/', '', $mac));
23+
if (strlen($hex) !== 12) {
24+
return null;
25+
}
26+
return implode(':', str_split($hex, 2));
27+
}
28+
29+
/**
30+
* Guess the LAN's directed broadcast address from a client IP,
31+
* assuming a /24 (192.168.1.50 -> 192.168.1.255). Editable in the
32+
* client settings when the network is subnetted differently.
33+
*/
34+
public static function defaultBroadcast(?string $ip): ?string
35+
{
36+
if (empty($ip) || !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
37+
return null;
38+
}
39+
$parts = explode('.', $ip);
40+
$parts[3] = '255';
41+
return implode('.', $parts);
42+
}
43+
44+
/**
45+
* Send a burst of magic packets. Returns true if at least one packet
46+
* was handed to the network stack.
47+
*/
48+
public static function send(string $mac, string $broadcast, int $port = 9, int $count = 3): bool
49+
{
50+
$mac = self::normalizeMac($mac);
51+
if ($mac === null || empty($broadcast)) {
52+
return false;
53+
}
54+
55+
$macBytes = '';
56+
foreach (explode(':', $mac) as $octet) {
57+
$macBytes .= chr((int) hexdec($octet));
58+
}
59+
$packet = str_repeat("\xff", 6) . str_repeat($macBytes, 16);
60+
61+
$ctx = stream_context_create(['socket' => ['so_broadcast' => true]]);
62+
$sock = @stream_socket_client(
63+
"udp://{$broadcast}:{$port}",
64+
$errno,
65+
$errstr,
66+
2,
67+
STREAM_CLIENT_CONNECT,
68+
$ctx
69+
);
70+
if (!$sock) {
71+
return false;
72+
}
73+
74+
$sent = false;
75+
for ($i = 0; $i < $count; $i++) {
76+
if (@fwrite($sock, $packet) === strlen($packet)) {
77+
$sent = true;
78+
}
79+
}
80+
fclose($sock);
81+
return $sent;
82+
}
83+
}

src/Views/clients/detail.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,39 @@
200200
<label class="form-label fw-semibold small">SSH Port Override</label>
201201
<input type="number" class="form-control form-control-sm" name="ssh_port_override" value="<?= htmlspecialchars($agent['ssh_port_override'] ?? '') ?>" placeholder="e.g. 22222" min="1" max="65535">
202202
</div>
203+
<hr class="my-3">
204+
<div class="form-check mb-2">
205+
<input type="hidden" name="wol_enabled" value="0">
206+
<input class="form-check-input" type="checkbox" name="wol_enabled" id="wolEnabled" value="1" <?= !empty($agent['wol_enabled']) ? 'checked' : '' ?>>
207+
<label class="form-check-label fw-semibold small" for="wolEnabled">Wake-on-LAN</label>
208+
<div class="form-text">Wake this client with a magic packet when a backup is due. Only works when the BBS server is on the same network as the client.</div>
209+
</div>
210+
<div class="mb-2">
211+
<label class="form-label fw-semibold small">MAC Address</label>
212+
<input type="text" class="form-control form-control-sm" name="wol_mac"
213+
value="<?= htmlspecialchars($agent['wol_mac'] ?: ($agent['mac_address'] ?? '')) ?>"
214+
placeholder="aa:bb:cc:dd:ee:ff" pattern="[0-9a-fA-F:.\-]{12,17}">
215+
<?php if (empty($agent['wol_mac']) && !empty($agent['mac_address'])): ?>
216+
<div class="form-text">Detected from the agent.</div>
217+
<?php elseif (empty($agent['wol_mac']) && empty($agent['mac_address'])): ?>
218+
<div class="form-text">Auto-fills once the agent checks in while awake, or enter it manually.</div>
219+
<?php endif; ?>
220+
</div>
221+
<div class="row g-2">
222+
<div class="col-7">
223+
<label class="form-label fw-semibold small">Broadcast Address</label>
224+
<input type="text" class="form-control form-control-sm" name="wol_broadcast"
225+
value="<?= htmlspecialchars($agent['wol_broadcast'] ?? '') ?>"
226+
placeholder="<?= htmlspecialchars(\BBS\Services\WakeOnLanService::defaultBroadcast($agent['ip_address'] ?? null) ?? 'e.g. 192.168.1.255') ?>">
227+
</div>
228+
<div class="col-5">
229+
<label class="form-label fw-semibold small">Wake Timeout</label>
230+
<div class="input-group input-group-sm">
231+
<input type="number" class="form-control" name="wol_timeout_minutes" value="<?= (int) ($agent['wol_timeout_minutes'] ?? 5) ?>" min="1" max="60">
232+
<span class="input-group-text">min</span>
233+
</div>
234+
</div>
235+
</div>
203236
<?php endif; ?>
204237
</div>
205238
<div class="modal-footer">

0 commit comments

Comments
 (0)