mirror of
https://github.com/ryan4yin/nix-config.git
synced 2026-11-13 12:13:46 +01:00
428 lines
13 KiB
Python
Executable File
428 lines
13 KiB
Python
Executable File
#!/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())
|