mirror of
https://github.com/ryan4yin/nix-config.git
synced 2026-11-13 12:13:46 +01:00
feat(homelab): enable Wake-on-LAN on the mini PCs (#509)
This commit is contained in:
1 parent
7ea9fd3197
commit
be3e27c49b
11 files changed
+731
-73
No files matched your search
@@ -246,11 +246,12 @@ youko mode="switch":
|
||||
aquamarine mode="switch":
|
||||
colmena apply {{mode}} --build-on-target --on '@aquamarine' --verbose --show-trace
|
||||
|
||||
# Wake every homelab host that has Wake-on-LAN armed.
|
||||
# Wake homelab hosts and wait until they answer. Defaults to all; accepts host
|
||||
# names, with `aqua` as an alias for aquamarine. Usage: just wake [host ...]
|
||||
[linux]
|
||||
[group('homelab')]
|
||||
wake-homelab:
|
||||
python3 {{absolute_path("scripts/wake-homelab.py")}}
|
||||
wake *hosts:
|
||||
python3 {{absolute_path("scripts/wake.py")}} {{hosts}}
|
||||
|
||||
############################################################################
|
||||
#
|
||||
|
||||
@@ -31,8 +31,8 @@ This repository is home to the Nix code that builds all of my systems:
|
||||
cluster and the self-hosted services.
|
||||
|
||||
See [./hosts](./hosts) for the host inventory, [./outputs](./outputs) for how the flake outputs are
|
||||
composed, [./BACKUP.md](./BACKUP.md) for the backup design, and [./AGENTS.md](./AGENTS.md) for the
|
||||
repository conventions.
|
||||
composed, [./docs](./docs) for the BIOS and Wake-on-LAN runbooks, [./BACKUP.md](./BACKUP.md) for the
|
||||
backup design, and [./AGENTS.md](./AGENTS.md) for the repository conventions.
|
||||
|
||||
## Why NixOS & Flakes?
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Documentation
|
||||
|
||||
- [BIOS settings](./bios.md)
|
||||
- [Wake-on-LAN](./wake-on-lan.md)
|
||||
- [NixOS modernization options](./nixos-modernization-options.md)
|
||||
@@ -0,0 +1,58 @@
|
||||
# BIOS Settings
|
||||
|
||||
Manual firmware settings are not managed by Nix. Record values that affect power use, remote wake,
|
||||
and boot behavior here so they survive BIOS updates and hardware swaps. The related Wake-on-LAN
|
||||
workflow and verification commands are in [Wake-on-LAN](./wake-on-lan.md).
|
||||
|
||||
Enter firmware setup with `Del` unless a host section says otherwise.
|
||||
|
||||
Last reviewed: 2026-11-02.
|
||||
|
||||
## `12kingdoms` mini PCs
|
||||
|
||||
Hosts: `shoryu` (MoreFine S500+), `shushou` (MinisForum UM560), `youko` (Beelink GTR5).
|
||||
|
||||
### Common settings
|
||||
|
||||
- `Advanced > CPU Configuration > PPC Adjustment (P-State)` = `PState 2` (full-range adjustment
|
||||
mode).
|
||||
- `Advanced > AMD CBS > FCH Common Options > AC Power Loss Options`:
|
||||
- `AC Power Loss` = `Always On`
|
||||
- `SATA Controller` = `Disabled`
|
||||
- Disable unused onboard devices where the firmware exposes them (for example `HDD` and WLAN
|
||||
options); none are in use on these hosts. `youko`'s two 4TB HDDs are behind a USB JMicron JMS567
|
||||
enclosure, so disabling the motherboard SATA controller does not affect them.
|
||||
|
||||
### Wake-on-LAN
|
||||
|
||||
The firmware path differs by model:
|
||||
|
||||
| Host | Model | Setting |
|
||||
| --------- | ---------------- | -------------------------------------- |
|
||||
| `shoryu` | MoreFine S500+ | `Advanced > Network Stack` = `Enabled` |
|
||||
| `shushou` | MinisForum UM560 | `Advanced > Resume on LAN` = `Enabled` |
|
||||
| `youko` | Beelink GTR5 | `Advanced > Network Stack` = `Enabled` |
|
||||
|
||||
The firmware side only exposes the wake path. NixOS arming and verification are documented in
|
||||
[Wake-on-LAN](./wake-on-lan.md#nixos-configuration).
|
||||
|
||||
## `aquamarine`
|
||||
|
||||
Board: Colorful CVN Z890 ARK FROZEN V20 (BIOS 1013). Settings changed on 2026-11-01.
|
||||
|
||||
| Page | Setting | Value |
|
||||
| ---------------- | ------------------------ | ---------------------------------------------- |
|
||||
| Home | Performance mode | Power-saving mode |
|
||||
| Home | Cooler | Intel default settings (lowers PL1/PL2 limits) |
|
||||
| Home | HD audio | Disabled |
|
||||
| Advanced > Power | RGB lighting | Disabled |
|
||||
| Advanced > Power | Restore on AC power loss | Power on |
|
||||
| OC > Frequency | Intel 200S Boost | Disabled |
|
||||
| OC > Frequency | Intel Default Settings | Enabled |
|
||||
|
||||
Wake-on-LAN is also required for the idle-suspend path:
|
||||
|
||||
- `Advanced > Power Setting > Wake By Lan` = `Enabled`.
|
||||
- EUP must not cut standby power to the NIC.
|
||||
- NixOS arms the RTL8125BG through `systemd.network.links`; see
|
||||
[Wake-on-LAN](./wake-on-lan.md#verify).
|
||||
@@ -0,0 +1,61 @@
|
||||
# Wake-on-LAN
|
||||
|
||||
Wake-on-LAN is available on `aquamarine` and the three `12kingdoms` mini PCs (`shoryu`, `shushou`,
|
||||
and `youko`). Firmware prerequisites are recorded in [BIOS settings](./bios.md).
|
||||
|
||||
## NixOS configuration
|
||||
|
||||
The three mini PCs share [`lib/genVmHostModule.nix`](../lib/genVmHostModule.nix), which adds a
|
||||
`systemd.network.links` entry that arms `WakeOnLan = "magic"` on their physical `eno1` NIC after
|
||||
every boot. `aquamarine` has the equivalent entry for its `en*` NIC in
|
||||
[`hosts/idols-aquamarine/default.nix`](../hosts/idols-aquamarine/default.nix).
|
||||
|
||||
The MAC addresses used by the wake command live in [`scripts/wake.py`](../scripts/wake.py).
|
||||
|
||||
## Verify
|
||||
|
||||
After a boot, confirm the physical NIC reports `Wake-on: g`:
|
||||
|
||||
| Host | Command |
|
||||
| ------------ | --------------------------------------------------------- |
|
||||
| `12kingdoms` | `ethtool eno1 \| grep -E 'Supports Wake-on\|Wake-on'` |
|
||||
| `aquamarine` | `ethtool enp133s0 \| grep -E 'Supports Wake-on\|Wake-on'` |
|
||||
|
||||
## Wake hosts
|
||||
|
||||
Wake every Wake-on-LAN-capable homelab host from another L2 host:
|
||||
|
||||
```sh
|
||||
just wake
|
||||
```
|
||||
|
||||
Pass one or more host names to wake only those machines; `aqua` is an alias for `aquamarine`:
|
||||
|
||||
```sh
|
||||
just wake youko
|
||||
just wake aqua shoryu
|
||||
```
|
||||
|
||||
The command retries the magic packet every few seconds and exits when every selected host answers
|
||||
ICMP **and** accepts an SSH connection. The SSH check prevents a host that is still shutting down or
|
||||
partway through boot from being mistaken for online. It prints a progress line every five seconds
|
||||
while waiting. Run `just wake --help` for all options:
|
||||
|
||||
```text
|
||||
--timeout SECONDS Stop waiting after SECONDS; 0 waits forever (default: 180)
|
||||
--retry-interval SECONDS Time between magic-packet bursts (default: 5)
|
||||
--probe-interval SECONDS Time between reachability checks (default: 1)
|
||||
--progress-interval SECONDS How often to print progress (default: 5)
|
||||
--packets N Magic packets per broadcast address and burst (default: 3)
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
just wake aqua --timeout 0
|
||||
just wake youko --timeout 60
|
||||
just wake --retry-interval 2 --progress-interval 2
|
||||
```
|
||||
|
||||
Add a host to the MAC list only after its firmware setting and NixOS link config are in place. Test
|
||||
wake-up from a powered-off state, not from a reboot.
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
This directory contains all host-specific configurations for my NixOS and macOS systems.
|
||||
|
||||
Manual firmware settings are recorded in [`docs/bios.md`](../docs/bios.md), and the shared
|
||||
Wake-on-LAN workflow is in [`docs/wake-on-lan.md`](../docs/wake-on-lan.md).
|
||||
|
||||
## Current Host Inventory
|
||||
|
||||
### Physical Machines
|
||||
|
||||
@@ -6,6 +6,9 @@ that name was decommissioned; this is a different, physical machine that reuses
|
||||
|
||||
## Hardware
|
||||
|
||||
Manual BIOS and power-saving settings are recorded in
|
||||
[`docs/bios.md`](../../docs/bios.md#aquamarine).
|
||||
|
||||
| Part | Model | Notes |
|
||||
| ------ | ----------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Board | Colorful CVN Z890 ARK FROZEN V20 (BIOS 1013) | moved from `ai` on 2026-10-13, so its EFI vars come too |
|
||||
@@ -288,33 +291,14 @@ The install only touches the target disk; the staging host keeps booting from it
|
||||
the disk back, restore the `hosts/idols-ai/disko-fs-data.nix` module this change deleted, and
|
||||
re-enroll that machine's TPM2 slot — see `../idols-ai/README.md`.
|
||||
|
||||
## Wake-on-LAN
|
||||
|
||||
BIOS: `Advanced > Power Setting` must have `Wake By Lan` enabled, and EUP must not cut standby power
|
||||
to the NIC. NixOS arms the RTL8125 through `systemd.network.links` using `OriginalName=en*`.
|
||||
|
||||
After a boot, verify the link reports `Wake-on: g`:
|
||||
|
||||
```bash
|
||||
ethtool enp133s0 | grep -E 'Supports Wake-on|Wake-on'
|
||||
```
|
||||
|
||||
Wake every homelab host with Wake-on-LAN armed from another L2 host:
|
||||
|
||||
```bash
|
||||
just wake-homelab
|
||||
```
|
||||
|
||||
The command currently wakes only aquamarine; add a host to `scripts/wake-homelab.py` after its WoL
|
||||
link config and BIOS setting are in place. Test from a powered-off state, not from a reboot.
|
||||
|
||||
## Idle suspend
|
||||
|
||||
`autosuspend` checks every minute and suspends into S3 after 60 minutes without an active user
|
||||
session, meaningful CPU load, or an established connection to one of the request-serving ports:
|
||||
`18080` (ASR), `63081` (llama-swap/Strata), `63083`/`63084` (MagPie), and `65002` (direct Strata).
|
||||
It calls `systemctl suspend`, so systemd sleep inhibitors are also honored. WoL is armed by the
|
||||
`systemd.network.links` configuration above; no separate WoL service is needed.
|
||||
It calls `systemctl suspend`, so systemd sleep inhibitors are also honored. Wake-on-LAN is armed by
|
||||
the host's `systemd.network.links` configuration; see
|
||||
[`docs/wake-on-lan.md`](../../docs/wake-on-lan.md). No separate WoL service is needed.
|
||||
|
||||
The daemon resets its idle timer after resume, so a wake does not immediately go back to sleep. To
|
||||
keep the host awake during maintenance:
|
||||
|
||||
@@ -80,6 +80,14 @@ in
|
||||
networking.useNetworkd = true;
|
||||
systemd.network.enable = true;
|
||||
|
||||
# Arm the physical NIC for Wake-on-LAN after every boot. systemd.link matches
|
||||
# the kernel-assigned name; `Name=` is not a valid [Match] key. The firmware
|
||||
# side is recorded in ../docs/bios.md.
|
||||
systemd.network.links."40-wake-on-lan" = {
|
||||
matchConfig.OriginalName = iface;
|
||||
linkConfig.WakeOnLan = "magic";
|
||||
};
|
||||
|
||||
# Linux bridge for the VMs: the microVM taps and the libvirt domains attach to
|
||||
# br0, and the host keeps its own address on it (the physical NIC is just a
|
||||
# bridge port).
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import unittest
|
||||
|
||||
from scripts.wake import (
|
||||
Host,
|
||||
HostSelectionError,
|
||||
Reporter,
|
||||
WakeConfig,
|
||||
WakeSession,
|
||||
magic_packet,
|
||||
resolve_hosts,
|
||||
)
|
||||
|
||||
|
||||
class FakeClock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 0.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
self.now += seconds
|
||||
|
||||
|
||||
class RecordingSender:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, int]] = []
|
||||
|
||||
def __call__(self, host: Host, packets_per_broadcast: int) -> None:
|
||||
self.calls.append((host.name, packets_per_broadcast))
|
||||
|
||||
|
||||
class SequenceProbe:
|
||||
def __init__(self, online_on_call: int) -> None:
|
||||
self._online_on_call = online_on_call
|
||||
self._calls = 0
|
||||
|
||||
def __call__(self, _host: Host) -> bool:
|
||||
self._calls += 1
|
||||
return self._calls >= self._online_on_call
|
||||
|
||||
|
||||
class MagicPacketTests(unittest.TestCase):
|
||||
def test_builds_standard_packet(self) -> None:
|
||||
mac = "8c:32:23:36:81:84"
|
||||
packet = magic_packet(mac)
|
||||
|
||||
self.assertEqual(len(packet), 102)
|
||||
self.assertEqual(packet[:6], b"\xff" * 6)
|
||||
self.assertEqual(packet[6:], bytes.fromhex(mac.replace(":", "")) * 16)
|
||||
|
||||
def test_rejects_invalid_mac(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
magic_packet("not-a-mac")
|
||||
|
||||
|
||||
class ResolveHostsTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.hosts = {
|
||||
"alpha": Host("alpha", "00:00:00:00:00:01", "192.0.2.1"),
|
||||
"beta": Host("beta", "00:00:00:00:00:02", "192.0.2.2"),
|
||||
}
|
||||
|
||||
def test_defaults_to_all_hosts(self) -> None:
|
||||
self.assertEqual(resolve_hosts([], hosts=self.hosts), list(self.hosts.values()))
|
||||
|
||||
def test_resolves_alias_and_removes_duplicates(self) -> None:
|
||||
selected = resolve_hosts(
|
||||
["a", "alpha", "beta"],
|
||||
hosts=self.hosts,
|
||||
aliases={"a": "alpha"},
|
||||
)
|
||||
self.assertEqual(selected, [self.hosts["alpha"], self.hosts["beta"]])
|
||||
|
||||
def test_rejects_unknown_host(self) -> None:
|
||||
with self.assertRaises(HostSelectionError):
|
||||
resolve_hosts(["missing"], hosts=self.hosts, aliases={})
|
||||
|
||||
|
||||
class WakeSessionTests(unittest.TestCase):
|
||||
def test_retries_until_host_is_online(self) -> None:
|
||||
clock = FakeClock()
|
||||
sender = RecordingSender()
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
reporter = Reporter(stream=stdout, error_stream=stderr, clock=clock)
|
||||
host = Host("test", "00:00:00:00:00:01", "192.0.2.1")
|
||||
config = WakeConfig(
|
||||
timeout=30,
|
||||
retry_interval=5,
|
||||
probe_interval=1,
|
||||
progress_interval=5,
|
||||
packets_per_broadcast=2,
|
||||
)
|
||||
session = WakeSession(
|
||||
[host],
|
||||
config,
|
||||
reporter,
|
||||
sender=sender,
|
||||
probe=SequenceProbe(online_on_call=10),
|
||||
clock=clock,
|
||||
sleep=clock.sleep,
|
||||
)
|
||||
|
||||
self.assertEqual(session.run(), 0)
|
||||
self.assertEqual(sender.calls, [("test", 2), ("test", 2)])
|
||||
output = stdout.getvalue()
|
||||
self.assertIn("attempt 2", output)
|
||||
self.assertIn("still waiting for: test (attempt 2, 192.0.2.1)", output)
|
||||
self.assertIn("test is online", output)
|
||||
|
||||
def test_exits_immediately_when_host_is_already_online(self) -> None:
|
||||
clock = FakeClock()
|
||||
sender = RecordingSender()
|
||||
reporter = Reporter(
|
||||
stream=io.StringIO(), error_stream=io.StringIO(), clock=clock
|
||||
)
|
||||
host = Host("test", "00:00:00:00:00:01", "192.0.2.1")
|
||||
session = WakeSession(
|
||||
[host],
|
||||
WakeConfig(),
|
||||
reporter,
|
||||
sender=sender,
|
||||
probe=lambda _host: True,
|
||||
clock=clock,
|
||||
sleep=clock.sleep,
|
||||
)
|
||||
|
||||
self.assertEqual(session.run(), 0)
|
||||
self.assertEqual(sender.calls, [])
|
||||
|
||||
def test_times_out_when_host_never_comes_online(self) -> None:
|
||||
clock = FakeClock()
|
||||
sender = RecordingSender()
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
reporter = Reporter(stream=stdout, error_stream=stderr, clock=clock)
|
||||
host = Host("test", "00:00:00:00:00:01", "192.0.2.1")
|
||||
session = WakeSession(
|
||||
[host],
|
||||
WakeConfig(timeout=3, retry_interval=5),
|
||||
reporter,
|
||||
sender=sender,
|
||||
probe=lambda _host: False,
|
||||
clock=clock,
|
||||
sleep=clock.sleep,
|
||||
)
|
||||
|
||||
self.assertEqual(session.run(), 1)
|
||||
self.assertIn("timed out waiting for: test", stderr.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Wake every homelab host whose NixOS config arms Wake-on-LAN."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import sys
|
||||
|
||||
# Keep this list limited to hosts that actually set Wake-on: g in NixOS.
|
||||
# Add a host here only after its WoL service and BIOS setting are in place.
|
||||
HOSTS = {
|
||||
"aquamarine": "8c:32:23:36:81:84",
|
||||
}
|
||||
BROADCASTS = ("192.168.5.255", "255.255.255.255")
|
||||
PORT = 9
|
||||
PACKETS = 3
|
||||
|
||||
|
||||
def magic_packet(mac: str) -> bytes:
|
||||
octets = bytes.fromhex(mac.replace(":", ""))
|
||||
if len(octets) != 6:
|
||||
raise ValueError(f"invalid MAC: {mac}")
|
||||
return b"\xff" * 6 + octets * 16
|
||||
|
||||
|
||||
def main() -> int:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
failed = False
|
||||
|
||||
for host, mac in HOSTS.items():
|
||||
packet = magic_packet(mac)
|
||||
print(f"waking {host} ({mac})")
|
||||
for _ in range(PACKETS):
|
||||
for broadcast in BROADCASTS:
|
||||
try:
|
||||
sock.sendto(packet, (broadcast, PORT))
|
||||
except OSError as exc:
|
||||
print(f" {broadcast}:{PORT}: {exc}", file=sys.stderr)
|
||||
failed = True
|
||||
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+427
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Wake homelab hosts over Wake-on-LAN and wait until they are reachable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from typing import Self, TextIO
|
||||
|
||||
BROADCAST_ADDRESSES = ("192.168.5.255", "255.255.255.255")
|
||||
WOL_PORT = 9
|
||||
SSH_PORT = 22
|
||||
PING_WAIT_SECONDS = 1
|
||||
PING_TIMEOUT_SECONDS = PING_WAIT_SECONDS + 1
|
||||
SSH_TIMEOUT_SECONDS = 1.0
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 180.0
|
||||
DEFAULT_RETRY_INTERVAL_SECONDS = 5.0
|
||||
DEFAULT_PROBE_INTERVAL_SECONDS = 1.0
|
||||
DEFAULT_PROGRESS_INTERVAL_SECONDS = 5.0
|
||||
DEFAULT_PACKETS_PER_BROADCAST = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Host:
|
||||
"""A Wake-on-LAN-capable host."""
|
||||
|
||||
name: str
|
||||
mac: str
|
||||
ip: str
|
||||
|
||||
|
||||
# Keep this list limited to hosts that actually set Wake-on: g in NixOS.
|
||||
# Add a host here only after its firmware setting and NixOS link config exist.
|
||||
HOSTS: Mapping[str, Host] = {
|
||||
"aquamarine": Host("aquamarine", "8c:32:23:36:81:84", "192.168.5.101"),
|
||||
"shoryu": Host("shoryu", "70:70:fc:01:1b:56", "192.168.5.181"),
|
||||
"shushou": Host("shushou", "58:47:ca:70:0d:72", "192.168.5.182"),
|
||||
"youko": Host("youko", "70:70:fc:01:00:44", "192.168.5.183"),
|
||||
}
|
||||
|
||||
ALIASES: Mapping[str, str] = {
|
||||
"aqua": "aquamarine",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WakeConfig:
|
||||
"""Timing and packet settings for one wake run."""
|
||||
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS
|
||||
retry_interval: float = DEFAULT_RETRY_INTERVAL_SECONDS
|
||||
probe_interval: float = DEFAULT_PROBE_INTERVAL_SECONDS
|
||||
progress_interval: float = DEFAULT_PROGRESS_INTERVAL_SECONDS
|
||||
packets_per_broadcast: int = DEFAULT_PACKETS_PER_BROADCAST
|
||||
|
||||
|
||||
class HostSelectionError(ValueError):
|
||||
"""Raised when a requested host name is unknown."""
|
||||
|
||||
|
||||
class Reporter:
|
||||
"""Print timestamped progress messages."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
stream: TextIO | None = None,
|
||||
error_stream: TextIO | None = None,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self._stream = stream if stream is not None else sys.stdout
|
||||
self._error_stream = error_stream if error_stream is not None else sys.stderr
|
||||
self._clock = clock
|
||||
self._started_at = clock()
|
||||
|
||||
def elapsed(self) -> float:
|
||||
return self._clock() - self._started_at
|
||||
|
||||
def info(self, message: str) -> None:
|
||||
print(f"[{self.elapsed():5.1f}s] {message}", file=self._stream, flush=True)
|
||||
|
||||
def error(self, message: str) -> None:
|
||||
print(
|
||||
f"[{self.elapsed():5.1f}s] {message}", file=self._error_stream, flush=True
|
||||
)
|
||||
|
||||
|
||||
class MagicPacketSender:
|
||||
"""Send Wake-on-LAN magic packets over a UDP broadcast socket."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
broadcasts: Sequence[str] = BROADCAST_ADDRESSES,
|
||||
port: int = WOL_PORT,
|
||||
) -> None:
|
||||
self._broadcasts = tuple(broadcasts)
|
||||
self._port = port
|
||||
self._socket: socket.socket | None = None
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc_info: object) -> None:
|
||||
if self._socket is not None:
|
||||
self._socket.close()
|
||||
self._socket = None
|
||||
|
||||
def send(self, host: Host, packets_per_broadcast: int) -> None:
|
||||
"""Send one magic-packet burst."""
|
||||
if self._socket is None:
|
||||
raise RuntimeError("sender must be used as a context manager")
|
||||
|
||||
packet = magic_packet(host.mac)
|
||||
for _ in range(packets_per_broadcast):
|
||||
for broadcast in self._broadcasts:
|
||||
self._socket.sendto(packet, (broadcast, self._port))
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _HostState:
|
||||
host: Host
|
||||
attempts: int = 0
|
||||
last_sent_at: float = 0.0
|
||||
|
||||
|
||||
class WakeSession:
|
||||
"""Retry magic packets until all hosts are online or the timeout expires."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hosts: Sequence[Host],
|
||||
config: WakeConfig,
|
||||
reporter: Reporter,
|
||||
*,
|
||||
sender: Callable[[Host, int], None],
|
||||
probe: Callable[[Host], bool] | None = None,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
) -> None:
|
||||
self._states = {host.name: _HostState(host) for host in hosts}
|
||||
self._config = config
|
||||
self._reporter = reporter
|
||||
self._sender = sender
|
||||
self._probe = probe or host_online
|
||||
self._clock = clock
|
||||
self._sleep = sleep
|
||||
self._send_failed = False
|
||||
self._last_progress_at = 0.0
|
||||
|
||||
def run(self) -> int:
|
||||
self._probe_pending(initial=True)
|
||||
if not self._states:
|
||||
return 0
|
||||
|
||||
for name in sorted(self._states):
|
||||
self._send(self._states[name])
|
||||
self._reporter.info(
|
||||
f"waking {self._describe_pending()}; retrying every "
|
||||
f"{self._config.retry_interval:g}s until online"
|
||||
)
|
||||
self._last_progress_at = self._clock()
|
||||
|
||||
while self._states:
|
||||
if self._timed_out():
|
||||
names = ", ".join(sorted(self._states))
|
||||
self._reporter.error(f"timed out waiting for: {names}")
|
||||
return 1
|
||||
|
||||
self._probe_pending(initial=False)
|
||||
if not self._states:
|
||||
break
|
||||
|
||||
self._retry_pending()
|
||||
self._report_progress()
|
||||
self._sleep(self._config.probe_interval)
|
||||
|
||||
return 1 if self._send_failed else 0
|
||||
|
||||
def _timed_out(self) -> bool:
|
||||
return (
|
||||
self._config.timeout > 0
|
||||
and self._reporter.elapsed() >= self._config.timeout
|
||||
)
|
||||
|
||||
def _probe_pending(self, *, initial: bool) -> None:
|
||||
states = list(self._states.values())
|
||||
if not states:
|
||||
return
|
||||
|
||||
with ThreadPoolExecutor(max_workers=len(states)) as executor:
|
||||
futures = {
|
||||
executor.submit(self._probe, state.host): state for state in states
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
state = futures[future]
|
||||
if not future.result():
|
||||
continue
|
||||
|
||||
self._states.pop(state.host.name, None)
|
||||
if initial:
|
||||
self._reporter.info(f"{state.host.name} is already online")
|
||||
else:
|
||||
self._reporter.info(
|
||||
f"{state.host.name} is online after {state.attempts} magic-packet bursts"
|
||||
)
|
||||
|
||||
def _send(self, state: _HostState) -> None:
|
||||
state.attempts += 1
|
||||
try:
|
||||
self._sender(state.host, self._config.packets_per_broadcast)
|
||||
except OSError as exc:
|
||||
self._send_failed = True
|
||||
self._reporter.error(
|
||||
f"failed to send magic packets to {state.host.name}: {exc}"
|
||||
)
|
||||
return
|
||||
|
||||
state.last_sent_at = self._clock()
|
||||
|
||||
def _retry_pending(self) -> None:
|
||||
now = self._clock()
|
||||
for name in sorted(self._states):
|
||||
state = self._states[name]
|
||||
if now - state.last_sent_at >= self._config.retry_interval:
|
||||
self._send(state)
|
||||
|
||||
def _report_progress(self) -> None:
|
||||
now = self._clock()
|
||||
if now - self._last_progress_at < self._config.progress_interval:
|
||||
return
|
||||
|
||||
self._reporter.info(f"still waiting for: {self._describe_pending()}")
|
||||
self._last_progress_at = now
|
||||
|
||||
def _describe_pending(self) -> str:
|
||||
return ", ".join(
|
||||
f"{name} (attempt {state.attempts}, {state.host.ip})"
|
||||
for name, state in sorted(self._states.items())
|
||||
)
|
||||
|
||||
|
||||
def magic_packet(mac: str) -> bytes:
|
||||
"""Build the standard 102-byte Wake-on-LAN magic packet."""
|
||||
try:
|
||||
mac_bytes = bytes.fromhex(mac.replace(":", ""))
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"invalid MAC address: {mac!r}") from exc
|
||||
|
||||
if len(mac_bytes) != 6:
|
||||
raise ValueError(f"invalid MAC address: {mac!r}")
|
||||
|
||||
return b"\xff" * 6 + mac_bytes * 16
|
||||
|
||||
|
||||
def resolve_hosts(
|
||||
names: Sequence[str],
|
||||
*,
|
||||
hosts: Mapping[str, Host] = HOSTS,
|
||||
aliases: Mapping[str, str] = ALIASES,
|
||||
) -> list[Host]:
|
||||
"""Resolve CLI host names and aliases, preserving order and removing duplicates."""
|
||||
if not names:
|
||||
return list(hosts.values())
|
||||
|
||||
selected: dict[str, Host] = {}
|
||||
for requested_name in names:
|
||||
canonical_name = aliases.get(requested_name, requested_name)
|
||||
try:
|
||||
host = hosts[canonical_name]
|
||||
except KeyError as exc:
|
||||
known = ", ".join(sorted(hosts))
|
||||
alias_help = ", ".join(
|
||||
f"{alias} -> {target}" for alias, target in aliases.items()
|
||||
)
|
||||
raise HostSelectionError(
|
||||
f"unknown host {requested_name!r}; known hosts: {known}; aliases: {alias_help}"
|
||||
) from exc
|
||||
selected[host.name] = host
|
||||
|
||||
return list(selected.values())
|
||||
|
||||
|
||||
def host_online(host: Host) -> bool:
|
||||
"""Return whether a host answers ICMP and has reached a usable SSH state.
|
||||
|
||||
Requiring SSH prevents a host that is still shutting down, or partway through boot, from being
|
||||
mistaken for online just because it still answers ping.
|
||||
"""
|
||||
return _ping(host.ip) and _ssh_port_open(host.ip)
|
||||
|
||||
|
||||
def _ping(ip: str) -> bool:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ping", "-c", "1", "-W", str(PING_WAIT_SECONDS), ip],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=PING_TIMEOUT_SECONDS + 1,
|
||||
check=False,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _ssh_port_open(ip: str) -> bool:
|
||||
try:
|
||||
with socket.create_connection((ip, SSH_PORT), timeout=SSH_TIMEOUT_SECONDS):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _positive_float(value: str) -> float:
|
||||
number = float(value)
|
||||
if number <= 0:
|
||||
raise argparse.ArgumentTypeError("must be greater than zero")
|
||||
return number
|
||||
|
||||
|
||||
def _non_negative_float(value: str) -> float:
|
||||
number = float(value)
|
||||
if number < 0:
|
||||
raise argparse.ArgumentTypeError("must be zero or greater")
|
||||
return number
|
||||
|
||||
|
||||
def _positive_int(value: str) -> int:
|
||||
number = int(value)
|
||||
if number <= 0:
|
||||
raise argparse.ArgumentTypeError("must be greater than zero")
|
||||
return number
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="just wake",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
description=(
|
||||
"Wake selected homelab hosts, keep retrying the magic packet, and exit when every "
|
||||
"selected host answers. With no host arguments, wake every WoL-capable host."
|
||||
),
|
||||
epilog=(
|
||||
"Examples:\n"
|
||||
" just wake\n"
|
||||
" just wake aqua youko\n"
|
||||
" just wake shoryu --timeout 0 --progress-interval 2"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"hosts",
|
||||
nargs="*",
|
||||
metavar="HOST",
|
||||
help="hosts to wake (default: all); use 'aqua' as an alias for aquamarine",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=_non_negative_float,
|
||||
default=DEFAULT_TIMEOUT_SECONDS,
|
||||
metavar="SECONDS",
|
||||
help="stop waiting after this many seconds (default: 180; 0 waits forever)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--retry-interval",
|
||||
type=_positive_float,
|
||||
default=DEFAULT_RETRY_INTERVAL_SECONDS,
|
||||
metavar="SECONDS",
|
||||
help="time between magic-packet bursts for hosts still offline (default: 5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--probe-interval",
|
||||
type=_positive_float,
|
||||
default=DEFAULT_PROBE_INTERVAL_SECONDS,
|
||||
metavar="SECONDS",
|
||||
help="time between reachability checks (default: 1)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--progress-interval",
|
||||
type=_positive_float,
|
||||
default=DEFAULT_PROGRESS_INTERVAL_SECONDS,
|
||||
metavar="SECONDS",
|
||||
help="how often to print a waiting-for progress line (default: 5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--packets",
|
||||
type=_positive_int,
|
||||
default=DEFAULT_PACKETS_PER_BROADCAST,
|
||||
metavar="N",
|
||||
help="magic packets per broadcast address and burst (default: 3)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
hosts = resolve_hosts(args.hosts)
|
||||
except HostSelectionError as exc:
|
||||
parser.error(str(exc))
|
||||
|
||||
config = WakeConfig(
|
||||
timeout=args.timeout,
|
||||
retry_interval=args.retry_interval,
|
||||
probe_interval=args.probe_interval,
|
||||
progress_interval=args.progress_interval,
|
||||
packets_per_broadcast=args.packets,
|
||||
)
|
||||
|
||||
reporter = Reporter()
|
||||
with MagicPacketSender() as sender:
|
||||
return WakeSession(hosts, config, reporter, sender=sender.send).run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user