boot-beacon¶
Behaviors
Emit BEACON-prefixed marker lines to the journal (and thus the serial/EFI
console) as a host finishes booting, answering the two questions that matter
during an unattended reboot or a remote reinstall:
- What address do I SSH to?
- Did this box rejoin the tailnet?
A deploy script can grep them out of a noisy console log; a human can watch
them scroll past on IPMI/serial during a blind reboot.
The problem¶
When you reboot or reinstall a headless machine you can't reach, you have no way to know it came back up, and no way to know where to reach it — DHCP may have handed it a new address, a new NIC may have become primary, or the tailnet join may have silently failed. The only channel you have is the serial console or an out-of-band framebuffer.
This module is deliberately local-only: no daemon, no outbound call, nothing to configure on the receiving side. It just prints. That is the whole point — it works when the console is the only channel left, which is exactly the recovery path for headless VPN gateways and exit nodes.
Key insight / traps¶
-
Read the IP from the default route's
prefsrc, not from "any global address".prefsrcis the source address the kernel actually uses for outbound traffic — i.e. the correct "reach me here" address on a multi-homed box. Grabbing the first global-scope IPv4 can hand you a management/second-NIC address nobody can SSH to. The module usesprefsrcfirst and only falls back to the first global IPv4, then toip=unknown, so it never fails. -
The units must never wedge
multi-user.target. Both areType = oneshotwithRemainAfterExit = true, and the tailnet beacon exits successfully even on timeout. An informational beacon that could block the boot would defeat its own purpose. The timeout (tailscaleTimeoutSec, default 300s) bounds only the tailnet wait; the plain readiness line fires as soon asnetwork-online.targetis reached. -
RemainAfterExitmeans you read the marker after the fact. The units showactive (exited)and won't re-emit until the next boot. Read them withjournalctl -u boot-beacon/-u tailscale-beacon, or watch the console live during the reboot. -
The tailnet beacon honours a pinned tailscale. It resolves the binary through
config.services.tailscale.package, so an overridden/pinned Tailscale is used rather than whatever is onPATH.
Usage¶
Import the module and enable it:
{
imports = [ ./behaviors/boot-beacon ];
behaviors.bootBeacon.enable = true;
# optional; default 300
# behaviors.bootBeacon.tailscaleTimeoutSec = 120;
}
The tailscale-beacon unit only materializes when
services.tailscale.enable = true; on a host without Tailscale you just get the
ready-for-ssh beacon.
Output¶
BEACON ready-for-ssh host=your-host ip=192.0.2.17
BEACON tailscale-up host=your-host ts_ip=100.x.y.z
or, if the tailnet join didn't complete in time:
Options¶
| Option | Type | Default | Meaning |
|---|---|---|---|
behaviors.bootBeacon.enable |
bool | false |
Enable the beacons. |
behaviors.bootBeacon.tailscaleTimeoutSec |
int | 300 |
Seconds to wait for tailscale to reach Running before emitting the timeout beacon (still exits 0). |
Caveats¶
- Beacons are plaintext on the console/journal by design. They reveal a hostname and an IP — fine for a recovery aid, but don't treat the console as private.
- IPv4-only as written. If you're IPv6-only, adapt the
ip -4/tailscale ip -4calls.
Source¶
behaviors/boot-beacon/default.nix
{
config,
lib,
pkgs,
...
}:
let
cfg = config.behaviors.bootBeacon;
in
{
options.behaviors.bootBeacon = {
enable = lib.mkEnableOption "boot + tailscale-up console beacons";
tailscaleTimeoutSec = lib.mkOption {
type = lib.types.int;
default = 300;
description = ''
Seconds to wait for tailscale to reach the Running state before
giving up. The beacon exits *successfully* on timeout — it is
informational and must never wedge multi-user.target.
'';
};
};
config = lib.mkIf cfg.enable {
# Plain readiness beacon: fires as soon as the network is up. Answers
# "what address do I SSH to?" on a machine you just rebooted blind.
systemd.services.boot-beacon = {
description = "Beacon: host has an IP and is ready for SSH";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
# Prefer the default route's prefsrc: the source address the kernel
# actually uses for outbound traffic, i.e. the correct "reach me here"
# IP on a multi-homed box. Fall back to the first global IPv4, then
# to "unknown" — never fail.
ip=$(${pkgs.iproute2}/bin/ip -4 -j route show default \
| ${pkgs.jq}/bin/jq -r 'first(.[] | select(.prefsrc)).prefsrc // empty')
if [ -z "$ip" ]; then
ip=$(${pkgs.iproute2}/bin/ip -4 -o addr show scope global \
| ${pkgs.gawk}/bin/awk '{print $4}' | cut -d/ -f1 | head -1)
fi
echo "BEACON ready-for-ssh host=${config.networking.hostName} ip=''${ip:-unknown}"
'';
};
# Tailnet beacon: only exists when tailscale is enabled. Answers
# "did this box rejoin the tailnet?" after an unattended reboot/reinstall.
# Resolved through config.services.tailscale.package so a pinned override
# is honoured.
systemd.services.tailscale-beacon = lib.mkIf config.services.tailscale.enable {
description = "Beacon: host joined tailscale";
wantedBy = [ "multi-user.target" ];
after = [
"tailscaled.service"
"network-online.target"
];
wants = [
"tailscaled.service"
"network-online.target"
];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
deadline=$(( $(date +%s) + ${toString cfg.tailscaleTimeoutSec} ))
while [ "$(date +%s)" -lt "$deadline" ]; do
state=$(${config.services.tailscale.package}/bin/tailscale status --json 2>/dev/null \
| ${pkgs.jq}/bin/jq -r '.BackendState // "unknown"')
if [ "$state" = "Running" ]; then
ts_ip=$(${config.services.tailscale.package}/bin/tailscale ip -4 2>/dev/null | head -1)
echo "BEACON tailscale-up host=${config.networking.hostName} ts_ip=''${ts_ip:-unknown}"
exit 0
fi
sleep 5
done
# Exit 0 even on timeout: informational unit, must not block boot.
echo "BEACON tailscale-timeout host=${config.networking.hostName} state=$state"
'';
};
};
}