Repository navigation
Configuration
Config is a single JSON file. It holds no secrets; the Discord webhook comes from an environment variable, so the file itself is safe to keep around.
The orchestrator looks for the config in this order: $UPS_ORCH_CONFIG, then
/etc/ups-orchestrator/config.json, then config.json next to the repo. State
(per-UPS bookkeeping) resolves the same way via $UPS_ORCH_STATE, then
/var/lib/ups-orchestrator/state.json.
Local forensic logs are controlled by:
-
$UPS_ORCH_SAMPLES: high-frequency UPS samples. -
$UPS_ORCH_EVENT_LOG: UPS event/transition/shutdown-gate records. -
$UPS_ORCH_NOTIFICATION_LOG: Discord delivery attempts and results. -
$UPS_ORCH_MAINTENANCE_STATE: the maintenance-window marker (default/var/lib/ups-orchestrator/maintenance.json) — see Deployment. -
$UPS_ORCH_BOOT_AUDIT_STATE: the per-boot marker that stops the boot audit reporting the same boot twice.
Each of those falls back to <repo>/<name> when the /var/lib directory does
not exist, so a development checkout never writes to system paths.
| Key | Default | Meaning |
|---|---|---|
discord_webhook_env |
UPS_DISCORD_WEBHOOK |
Env var the webhook URL is read from |
discord_webhook_url |
"" |
Fallback URL if the env var is unset (leave empty) |
discord_username |
UPS Orchestrator |
Name shown on the embeds |
discord_avatar_url |
"" |
Optional avatar for the webhook |
poll_seconds |
30 |
How often watch checks UPS state and the shutdown policy |
countdown_every_seconds |
60 |
On-battery countdown post cadence (0 turns it off) |
load_step |
on | Output-load collapse detection (a downstream device dying) |
shutdown |
disabled | Central opt-in policy for orchestrator-managed shutdowns |
nut_server |
localhost-only | Primary-side upsd exposure (see below) |
monitored_machines |
[] |
Machines enrolled via monitor add --method ... (native/serial/ssh/none, see below) |
upses |
— | Map of NUT device name → per-UPS settings |
shutdown.enabled is the master switch. It defaults to false, so no target
command runs unless you explicitly enable the policy and the relevant group.
-
require_power_outage: require the UPS to be on battery before targets run. -
min_on_battery_seconds: minimum outage duration before target commands can run. -
notify: send Discord attempt/result notifications for every target command. -
external: central thresholds forserialandremotetargets. -
internal: central thresholds forlocaltargets.
When battery and runtime readings are both available, both must be at or below their group thresholds. That prevents a high battery-percent threshold from shutting machines down while the UPS still reports healthy runtime.
!!! tip "A percentage may be the wrong trigger — check your runtime first"
The full gate is enabled and the group flag and on-battery and
min_on_battery_seconds elapsed and the group thresholds met. Which of
those is the binding one depends on your hardware, and it is worth
measuring rather than assuming.
A big server on a modest UPS can have only a few minutes of runtime, and
battery percent barely moves in that time — so a percentage gate fires far
too late, or never. Where that is the case, make the **timer** the binding
constraint: set `min_on_battery_seconds` to the delay you actually want and
set `battery_below: 100` so the charge test is always true.
Check the other direction too. If the UPS carrying your **network** gear has
a shorter runtime than the one carrying the machine you are shutting down,
then `ssh` and native NUT both stop working when the network UPS dies — both
need the LAN. That caps every network-dependent shutdown at the network
UPS's runtime, and only `serial` survives past it.
!!! warning "This gate applies regardless of shutdown_method — and both default off"
shutdown.enabled plus the relevant group flag (external for a
serial/ssh machine or a legacy remote/serial target, internal for
a local target) gate every automatic shutdown the orchestrator can
issue — completely independent of what a given machine's shutdown_method
is set to. A machine correctly configured with shutdown_method: "serial"
will still never fire while shutdown.enabled (or its group) is false.
Both default to false in code, and this page documents the defaults.
Do not flip shutdown.enabled unless you mean it — it is the difference
between a monitoring tool and something that powers machines off.
To find out what YOUR deployment is doing, do not read this page — read the
gate. `remote-shutdown --dry-run` prints a per-target verdict, and the reason
string distinguishes the two states exactly: `shutdown policy disabled` means
disarmed, anything else (e.g. `UPS is not on battery`) means **armed and
waiting**.
Two commands exist to inspect this without flipping anything:
- `ups-orchestrator remote-shutdown [ups] --dry-run` **bypasses nothing.**
It resolves every target this UPS would push to or fire locally and
prints, for each, the transport, the resolved command, and the gate's
current verdict — so a disabled policy shows up as a full listing
annotated `would fire: no — shutdown policy disabled`, not a blank
screen and not a lie.
- `ups-orchestrator shutdown rehearse <machine>` deliberately does **not**
consult this policy at all: its command is hard-coded to a non-shutdown
`logger` invocation, so gating it on `shutdown.enabled` would make it
unusable in exactly the state (`false`) it exists to be used in.
Primary-side upsd exposure. It holds no secrets — the secondary NUT
password never lives here.
| Key | Default | Meaning |
|---|---|---|
listen |
["127.0.0.1", "::1"] |
Addresses upsd binds. Localhost-only by default |
port |
3493 |
upsd TCP port |
secondary_user |
upsmon_secondary |
The upsd.users account secondaries authenticate as |
listen is localhost-only on a fresh config so upsd is never silently exposed
to the network. monitor add appends the primary's LAN address at enrollment
time (and applies the matching upsd.conf LISTEN + a full nut-server
restart — see Deployment). A LISTEN change needs a full
restart, not a reload.
A machine the orchestrator knows how to power down. Every entry carries exactly
one effective shutdown method — the single per-machine authority — set by
monitor add --method {none|native|serial|ssh}. The array ships empty;
entries are added by monitor add, not by hand.
| Field | Default | Meaning |
|---|---|---|
name |
— | Machine name (also the ssh alias if none given) |
shutdown_method |
none |
none | native | serial | ssh — the single effective shutdown authority for this machine. Equal weight with every other field below. |
ssh |
"" |
ssh_config Host alias. Required for shutdown_method: native (bootstraps the NUT secondary over this alias) and ssh (the push destination); ignored for serial/none. |
ups |
"" |
NUT UPS name the machine is powered by (e.g. cyberpower). monitor add --ups is required for every method except none — a push record with no ups could never be projected onto any UPS's low-battery event, so the CLI refuses it up front (rc 2) rather than accepting a dead-letter entry. --method none is the only method where --ups is optional at the CLI: omitted, the record carries no shutdown authority to project in the first place; given anyway, it is stored and makes monitor verify run the BL-02 advisory probe described below. |
powervalue |
1 |
1 = powered by this UPS (counts toward MINSUPPLIES); never 0 for a real feed. Only meaningful for native
|
os |
auto |
auto | arch | ubuntu | debian — picks the install/config path. Only consulted for native
|
shutdown_cmd |
/sbin/shutdown -h now |
Command run on trigger. Per-transport, not universal — see below. |
ip |
"" |
Resolved source IP added to the firewall saddr set. Written only by the native enrollment path |
serial_device |
"" |
shutdown_method: serial only — the console device, e.g. /dev/serial/by-id/usb-... (prefer the by-id path over a bare /dev/ttyUSB0, which can renumber across reboots) |
serial_baud |
(none — disarms if absent) |
shutdown_method: serial only. Never assumed. An absent or unparseable serial_baud disarms the machine at load rather than guessing a rate. See the baud call-out below. |
backup |
{enabled: false, kind: "remote"} |
LEGACY. Still parsed for back-compat; drop it from new entries — see below |
!!! danger "Baud is yours to declare — and a wrong value is invisible to the orchestrator"
There is no default worth trusting and no rate this document can tell you.
Declare the real baud of the machine you are enrolling — read it off the
far end with systemctl cat serial-getty@<tty>.service on that machine.
Never copy a value from another console: this deployment's own Dell runs at
115200, and an earlier revision of these docs claimed 9600, which would
have written garbage down the wire and reported success.
stty -F <device> <baud> only configures the local tty — it returns
success for 9600, 19200, 115200, and effectively any recognised rate on the
same physical line, and the byte write completes regardless of whether the
far end is listening at that speed. A wrong serial_baud sends garbage
down the wire and the orchestrator reports nothing wrong: rc 0, no
exception, no shutdown. What is detected and reported is a rejected
local line configuration (a malformed baud, or a value stty itself
refuses) — never a far-end speed mismatch. There is no substitute for the
operator confirming the real console speed (e.g. from the far end's own
getty/console configuration) before declaring it here.
-
A newly created record (one you write with an explicit
shutdown_method) uses exactly that value; an unrecognised or blank value coerces tonone(logged) rather than silently activating a transport. -
A legacy record with no explicit
shutdown_methodderives one from its old shape, evaluated in this order: a non-emptyups→native(checked first, so a Phase-1 native secondary withupsset and a stalebackup{enabled:false}still derivesnative, never a push); otherwise an enabled legacybackup→serialorsshper itskind; otherwisenone. -
Explicit
"shutdown_method": "none"always means off, even for a legacy shape that would otherwise have derived something — an explicit value always wins over derivation.
A derived push method (case 2's serial/ssh branch) is a dead letter unless
you also give the record an explicit ups: a push method is derived only
when ups is empty, and the machine is only ever projected onto a shutdown
attempt for a UPS whose name matches its own ups field — so a derived push
with no ups can never fire, is reported as such at load, and must be given
an explicit shutdown_method and a ups to actually work.
Deferred: a native-plus-deadman regime — where a backup block fires as a
last-resort trigger strictly below a native secondary's own LB point — was
scoped for this phase and dropped; it may return in a future phase.
"monitored_machines": [
{
"name": "mt",
"shutdown_method": "serial",
"serial_device": "/dev/serial/by-id/usb-FTDI_FT232R_USB_UART-if00-port0",
"serial_baud": 9600, // EXAMPLE ONLY — read yours off the far end
"ups": "cyberpower"
},
{
"name": "spark",
"shutdown_method": "native",
"ssh": "spark",
"ups": "cyberpower3",
"powervalue": 1,
"os": "ubuntu",
"ip": "192.168.1.141"
}
]backup is deliberately absent from both entries — see LEGACY below.
shutdown_cmd is not one field with one meaning: the same string runs under
two different privilege contexts depending on shutdown_method.
-
native—upsmonon the secondary runsSHUTDOWNCMDas root, so the default/sbin/shutdown -h nowis correct unmodified. -
serial/ssh— the command runs as whichever user the ssh connection or the far-end auto-login getty executes as. If that is not root, a bare/sbin/shutdown -h nowneeds an escalation prefix (e.g.sudo /sbin/shutdown -h now) — and the failure mode differs by transport. Over ssh, a permission failure is a non-zero rc you'll see. Over serial it is silent: the bytes are delivered, the write returns rc 0, and the box simply stays up. Declare an escalatedshutdown_cmdfor a push record unless the far end logs in as root, and see Deployment → mt far-end getty setup for the NOPASSWD configuration that makes this safe on the far end.
# Enroll — --method selects the authority. native is the default (back-compat
# with pre-Phase-2 invocations that omit --method); --ssh is required only for
# native and ssh. --ups is required for every method EXCEPT none (rc 2 if
# missing) — a push with no ups can never be projected onto a low-battery
# event, so the CLI refuses the dead-letter record up front.
sudo ups-orchestrator monitor add mt --method serial \
--serial-device /dev/serial/by-id/usb-FTDI_FT232R_USB_UART-if00-port0 \
--serial-baud 9600 --ups cyberpower # 9600 is an EXAMPLE — use the far end's real rate
sudo ups-orchestrator monitor add spark --method native --ssh spark --ups cyberpower3 --os ubuntu
sudo ups-orchestrator monitor add somebox --method ssh --ssh somebox --ups cyberpower
# --method none is the ONE method where --ups is optional — omit it entirely
# for "no shutdown authority at all", or pass it if this box is still worth
# probing for a stray live secondary (see the monitor verify table below).
sudo ups-orchestrator monitor add somebox --method none
# Method-aware lifecycle
ups-orchestrator monitor list # declared method, and the effective method when it differs;
# plus every Config.degraded notice, ERROR/ADVISORY labelled
!!! warning "`native` is not automatically the right default — check what FSD would hit"
The `native` examples on this page are valid syntax, not a recommendation. Native
shutdown rides on NUT's FSD, and `forceshutdown()` FSDs **every** `ST_PRIMARY` UPS
without reference to which one went critical — so it halts every enrolled secondary
at once, including any you need to keep running. On the reference deployment that
is why the machine originally enrolled `native` was moved to `ssh`, and why the
primary runs `MINSUPPLIES 0`.
Before choosing `native`, work out which machines an FSD would take down, and
whether the UPS carrying your **network** would still be up to deliver it. See
[Shutdown-Mechanisms](Shutdown-Mechanisms.md).
ups-orchestrator monitor verify mt # "will this machine actually shut down?" — see below
ups-orchestrator monitor verify mt --deep # make the far end prove it: for a serial record this
# asks the console to execute a probe and echo a token back,
# which is the only evidence that is not just "the local USB
# adapter exists". Sends Ctrl-U + Enter (discarding, NOT submitting, anything a human
# left half-typed there) and then one echo command.
sudo ups-orchestrator monitor remove mt # native: real remote NUT teardown + nft/saddr rewrite;
# serial/ssh/none: config entry + the same LOCAL nft rewrite
# (no remote host is contacted for a non-native record)
# Preview without touching anything
ups-orchestrator remote-shutdown cyberpower --dry-run
# Prints every resolved target (configured shutdown_targets[] + monitored_machines
# projections) with its transport, resolved command, and CURRENT gate verdict —
# e.g. "would fire: no — shutdown policy disabled" or
# "would fire: no — UPS is not close to empty (...)". Touches shutdowns_sent,
# the notifier, and the event log NOT AT ALL.
# Push a harmless, hard-coded, non-shutdown probe over the real transport
ups-orchestrator shutdown rehearse mt
# Sends `logger -t ups-orchestrator PHASE2_REHEARSAL` over mt's configured
# transport — NEVER the real shutdown_cmd, and never a local target. Ignores
# shutdown.enabled entirely (see the warning above) because the command itself
# cannot halt anything. Persists nothing.monitor verify's answer, by declared method:
| Declared method | What verify does | Result |
|---|---|---|
native |
Always runs the NUT secondary probe (verify_secondary) — this is the only evidence available on this box about an authority that lives on another one |
rc 0 if the secondary answers, rc 1 if not; if a load notice is present it is shown alongside, naming monitor remove <name> as the real disarm |
Any probe-eligible record whose ups is blank
|
No probe — there is no upsc <ups>@<primary> to run, so nothing here can establish whether that box is armed. This is the state a native-with-blank-ups advisory tells the operator to come and check, so it is a finding, not bad input: it prints cannot probe: … and names monitor remove <name>, the only command that actually disarms the remote secondary |
rc 1 — never rc 2 (which everywhere else here means "the command was called wrong") and never the reassuring no active shutdown authority
|
none with a non-empty ups
|
Also runs the secondary probe, as an advisory — this is exactly BL-02's signature (an operator may have hand-edited a former native record to none without actually tearing it down) |
If a secondary answers: reports it, rc 1; if not: rc 0. Falsely reporting "no active authority" here would be the whole problem |
The printed <name>: OK / <name>: FAIL word is derived from the exit code, so
the two can never disagree. For a declared native record the probe's own result
is the verdict; for every other probe reason the question is inverted — "is
there an authority here that this record does not declare?" — so a probe that
finds nothing is OK, not FAIL. (Before the IF-10 fix a none-with-ups
record whose probe failed printed FAIL and exited 0, and a script keying on the
rc read the opposite of the line it had just printed.)
| none, no ups | No probe | no active shutdown authority, rc 0 |
| serial/ssh declaring a non-empty ip | This shape is always disarmed at load (IW-05 — ip is written only by the native enrollment path, so a push record carrying one is almost certainly a former native secondary that was hand-edited rather than torn down): prints DISARMED (declared serial/ssh): … and still runs the secondary probe | rc 1 regardless of what the probe finds — the probe result is shown for diagnosis, but the disarm alone already forces rc 1 |
| serial/ssh, disarmed for any other reason (missing device/alias, an option-shaped ssh alias, unresolvable ups, dual-regime conflict, …) | No probe — there is no ip to suspect a stray secondary over | DISARMED (declared <method>): <reason> plus the remedy, rc 1 — deliberately never rendered as a plain "no active authority", which would be indistinguishable from a genuine none. Note the rc: an option-shaped alias on a declared-ssh record is rc 1, not rc 2, because the loader disarmed it and verify returns from its disarmed gate before the alias ever reaches a sink. rc 1 is the right answer — the operator is being told the machine will not fire, not that they called the command wrong |
| serial, armed | Without --deep: checks the recorded device's presence (a stat, injected in tests). With --deep: additionally asks the far end to execute a probe and echo a token back on the same line | Shallow: rc 0 if present and a character device with a usable declared baud, rc 1 otherwise. Deep: rc 0 only if the token came back; see below |
| serial, armed, --deep | Runs the serial liveness probe. This is the only check that produces evidence about the far end rather than about the local adapter — a USB serial adapter presents a character device whether or not anything is plugged into its other end, so the shallow OK is a statement about the dongle | rc 0 on SEEN (the far end executed something — proof of execution, not echo, because the token is written split and matched whole). rc 1 on NOT_SEEN or NO_TRANSPORT. NOT_SEEN is reported as an observation: a busy console, a getty that does not echo, and a genuinely wrong baud are indistinguishable from this end, so the output says so rather than claiming the line is dead |
| ssh, armed | Probes the alias's reachability (ssh <alias> true) | rc 0 if reachable, rc 1 otherwise |
| Config ups value is a non-empty NUT-metacharacter string, or a non-empty ssh alias is option-shaped, on a record that would be probed | Refuses to run the probe (never shells out with an unvalidated value). Both checks are keyed on the value being non-empty: a blank is not an injection, and BL-02's advisory probe is precisely a record with no alias — see the blank-ups row above for the blank cases | rc 2 |
| The primary's LAN IP cannot be resolved for a record that would be probed | Refuses to probe. verify resolves the primary through the same _resolve_primary_ip monitor add uses — --primary-ip, then the first non-loopback nut_server.listen entry, then a local ip -o route get <machine ip>. It does not fall back to 127.0.0.1: the probe runs upsc <ups>@<primary> on the secondary, so a loopback address interrogates that box's own upsd rather than this one — a false FAIL for an armed secondary, or a false OK if it happens to run its own upsd with a same-named UPS | rc 2, naming --primary-ip and nut_server.listen as the two remedies |
| Unknown machine name | — | rc 2 |
| rc | Meaning |
|---|---|
| 0 | The record is gone AND, where one was implied, the remote secondary was disarmed. |
| 2 | Called wrong — unknown machine, invalid literal. Nothing was changed. |
| 3 | A declared-native record's remote disarm failed. Hard stop: a secondary is known to be enrolled and is still armed, so the record is deliberately left in place rather than deleted out from under it. |
| 4 | The firewall rewrite failed on the native path. |
| 5 | The record was removed locally but the remote could NOT be disarmed. |
rc 5 is the one to script against. It is reachable for a record that declared
none while naming a UPS — the shape whose advisory says "'none' does not disarm an
already-enrolled native secondary". For that shape the command still attempts the
remote teardown, but a missing ssh alias or an unreachable box means it cannot
confirm the disarm. It reports what did not happen and prints the manual command.
The distinct code matters because removing the record also removes the advisory that was reporting the risk. A caller treating a partial removal as success ends up with a quieter system rather than a safer one, which is the failure this code exists to make un-missable.
monitor verify/monitor add/monitor remove share rc 2 for a config or
argument problem the command refuses to act on: an unknown machine, an
invalid --ups/--ssh/--primary-ip literal, or (on monitor add) a
missing method-required flag such as --ssh on native/ssh or --ups on
anything but none. Those specific checks are hand-written validation inside
_monitor_add/_monitor_verify — a plain return 2 from main(), not a
raised SystemExit. An invalid --method/--os/--powervalue choice
still exits 2 the ordinary argparse way (parser.error → SystemExit(2)),
which any caller sees as the same process exit code but is a different code
path than the hand-written checks.
A monitor add that would switch an existing declared-native record to
serial/ssh/none is refused — monitor remove <name> first, which is
the only command that runs the real remote NUT teardown. Leaving the switch
unrefused would silently orphan the old secondary's upsmon while also
activating a push, i.e. two live shutdown authorities on one machine.
!!! danger "--force and --force-remote-config authorise two DIFFERENT things — never conflate them"
monitor add has two independent force flags. Passing one never implies
the other, and confusing them lets an operator who only meant to clear a
local warning also clobber a third machine's NUT config:
- **`--force`** overrides only the **local** dual-regime refusal — the
guard that fires when a machine's active `shutdown_method` and an
enabled legacy `shutdown_target` on the same UPS would both govern it
(double-shutdown risk). It authorises nothing on any remote host.
- **`--force-remote-config`** authorises a `native` enrollment to
overwrite an **unmarked** remote `/etc/nut/upsmon.conf` (one carrying a
`MONITOR` line outside the orchestrator's own managed block — i.e. an
operator's hand-written config) or to demote a remote `/etc/nut/nut.conf`
away from `MODE=standalone`/`MODE=netserver` (a real NUT server on that
box). Without it, `monitor add` refuses to write and reports which
condition tripped the refusal, changing nothing on the remote box.
Example — enrolling over a remote that already runs its own NUT server,
with a pre-existing local double-shutdown conflict:
```bash
sudo ups-orchestrator monitor add mt --method native --ssh mt --ups cyberpower \
--force --force-remote-config
```
Passing only `--force` here clears the local conflict but still refuses
to touch `mt`'s existing NUT config; passing only `--force-remote-config`
authorises the remote overwrite but still refuses at the local
dual-regime guard. Both are required together only when both conditions
are actually present — neither flag does anything the other's condition
doesn't trigger.
shutdown_targets[] (per-UPS) and MonitoredMachine.backup (per-machine) are
LEGACY — still parsed so old config files keep loading, but neither is how
a new entry should be written. backup has never driven a shutdown:
nothing at runtime reads it — it is parsed and displayed only — so the
serial/SSH push a backup block appears to promise has always been
imaginary. New entries should drop it entirely (see the worked example above)
and declare shutdown_method instead. See Shutdown Targets
for the full legacy per-UPS shutdown_targets[] reference.
The secondary's NUT credential lives in /etc/ups-orchestrator.env as
UPS_NUT_SECONDARY_PASSWORD, never in config.json. It must match the
[upsmon_secondary] entry in /etc/nut/upsd.users on the primary (see
deploy/nut/upsd.users.snippet). config.json carries no secret.
A machine cannot be governed by two shutdown regimes at once: an active
shutdown_method (native/serial/ssh/none all count) and an enabled
legacy shutdown_target on the same UPS. monitor add refuses without
--force when it detects this locally at enrollment time. But a conflict
that reaches disk another way — a hand-edited file, or a config authored
before this rule existed — does not fail to load. Config.load disarms
whichever side of the conflict is disarmable and keeps running, logging an
Discord receives disarms, not advisories. An advisory-only degrade is logged and
shown on the four surfaces below, but is deliberately NOT pushed: the common advisory
is a standing, unchanging condition, so pushing it meant an alert on every service
restart and every config edit about something the operator had already read. That
mutes the channel which also carries on-battery and shutdown alerts. A disarm — an
authority that was going to fire and now will not — pushes every time.
ERROR-severity notice and surfacing it in monitor list, monitor verify,
the status view, and the web UI. The outcome differs by which authority
conflicts:
-
A
serial/sshmachine in conflict: its effective method is forced tonone(disarmed) — it will not be pushed. -
A
nonemachine in conflict: not applicable —nonecarries no authority to conflict over, but a conflicting legacy target naming it is still disabled. -
The conflicting legacy
shutdown_targetis always disabled and will not fire, regardless of which authority it collided with. -
A
nativemachine in conflict is the one case config cannot disarm: its authority is the remote box's ownupsmonreacting to this primary's FSD, configured entirely in that box's/etc— nothing a config parser does here reaches it. The conflicting legacy target is disabled, and the native secondary remains armed as the sole surviving authority. Editing config never disarms a native secondary. The only thing that does ismonitor remove <name>, which runs the real remote NUT teardown.
In every case the daemon keeps running and keeps polling every configured UPS.
Config.degraded is the machine-readable surface for every such notice; it is
empty on a healthy config.
The load only refuses outright (raises, refusing to start) for structural
monitoring-topology corruption, never for a shutdown-authority problem: an
unreadable config file, malformed JSON, a non-object JSON root, an absent or
empty upses section, a non-object entry inside upses, a upses mapping
that becomes empty after filtering out non-object entries, or two UPS names
that canonicalise to the same key. Every one of those would leave watch
polling zero or ambiguous UPSes while looking healthy, which is worse than
refusing to start. Every shutdown-authority misconfiguration — a bad baud, a
dual-regime conflict, an unresolvable ups, a stale ip on a push record —
degrades instead.
The upsd accept spliced into the operator's input base chain grants port 3493 to
exactly the ip of every surviving native record — nothing else. Two rules
keep that true, and both are enforced on the declared method:
-
_survivor_saddrsfilters toshutdown_method == "native"and to values that are valid IP literals. Aserial/ssh/nonerecord carrying a stale enrollmentipcontributes nothing, and a non-literal is dropped with a warning rather than spliced into a rulesetnft -floads as root. -
Both
monitor addandmonitor removerecompute and reload the set, for every method — not just fornative. The rewrite is local and idempotent (apply_nftshort-circuits when the rendered text is unchanged), so this costs nothing when there is nothing to do, and it is what revokes theipa record sheds when it stops being a native secondary. Skip it with--no-firewall.
Only the remote NUT teardown in monitor remove stays native-only: it
SSHes into another host and runs sudo systemctl, which has no business running
for a record this tool never enrolled natively.
A failed rewrite is fatal (rc 4) on the native path, where the accept is that
record's own. On a non-native add/remove it is logged and the config change
still lands: the record edit is the operator's actual request and the rewrite is
a repair sweep, so refusing would leave both the stale saddr and the wrong
record.
monitor add/monitor remove rewrite config.json through an atomic
temp-file-then-replace. That replace now preserves the destination file's
mode, owner/group, and POSIX ACL across the rewrite — the installer's
intended on-disk state is 0640 root:nut plus a read ACL for the run user
(deploy/install.sh), and a persist that does not preserve it can leave the
--user watch service unable to read its own config after a restart. If you
ever find /etc/ups-orchestrator/config.json at 0600 root:root with no ACL
entry for the run user, that is damage, not the intended shipped state —
re-run deploy/install.sh's permission step or restore the ACL by hand.
load_step flags an abrupt output-load collapse — the only in-band signature
NUT gives for a downstream device losing power while the UPS itself stays OL.
On by default; set enabled to false to silence it.
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
Master switch for the check |
drop_percent |
15 |
Load-point drop below the recent peak that trips the event |
window_polls |
4 |
Polls whose peak the current load is compared against |
cooldown_seconds |
600 |
Minimum gap between notifications (events are always logged) |
The drop is measured against the highest load in the last window_polls polls
rather than just the previous poll, so a collapse that straddles a poll boundary
(the UPS reporting an intermediate value mid-decay) still trips instead of
splitting into two sub-threshold steps. A trip logs a load_step_drop event
with the estimated watts delta and sends one notification per cooldown_seconds;
the alert embeds a 10-minute draw-history sparkline built from the recorder
samples. It is a hint, not a verdict — a heavy job finishing looks identical —
so pair it with a reachability check on the device.
Each key under upses is a NUT device name (whatever you called it in
ups.conf). A UPS has a label (used in the embeds), an optional devices
inventory (below), and a list of shutdown_targets; see
Shutdown Targets.
Before this existed, "what dies if this one fails" was answerable only from the
operator's memory — and the memory went stale the moment the hardware was
recabled. devices writes the answer down, in the file that already describes
the UPS.
It is inventory only. A devices entry is never a shutdown target and never
grows one: nothing dispatches to it, nothing gates on it, and it reaches neither
the firewall saddr set nor any command. It exists for the gear the
orchestrator has no authority over — routers, switches, a modem, someone else's
desktop — so that a UPS going down has a written blast radius.
| Field | Default | Meaning |
|---|---|---|
name |
— | What the thing is called. An entry with an empty or missing name is dropped at load, silently — it would name nothing |
kind |
other |
One of server | network | desktop | storage | other. Anything unrecognised falls back to other rather than failing the load; a typo costs you a category, not your monitoring |
note |
"" |
Free text for the consequence — "loses the WAN for everything", "not enrolled, it just dies" |
!!! warning "Do not repeat a monitored machine here"
Machines the orchestrator does shut down live in
monitored_machines and must not
be listed again in devices. This is not a style rule — it is why the two
cannot disagree. Config.ups_inventory(name) returns
(managed_names, devices) and reads managed_names from
monitored_machines, filtered to the machines whose ups is this UPS,
never from devices. Both operator surfaces render that one call, so
the shutdown list is always the real shutdown list. Duplicating a machine
into devices would not add authority; it would only print the name twice
and start the drift the split was built to prevent.
Two surfaces read it, so an inventory that is wrong is visibly wrong rather than quietly wrong:
-
ups-orchestrator statusgains aShutsrow (managed machines) and aPowersrow (unmanaged devices) on each UPS card. Both elide to the terminal width with a+N moresuffix — the card is drawn in a box that never wraps, and--watchassumes one logical line is one terminal row, so a long inventory truncates instead of blowing the border out. -
The daily Discord report gains
Shuts down on battery:andAlso powers:lines in each UPS's field, and its summary body counts the machines set to shut down automatically.
Worked example — one UPS that the orchestrator shuts a machine down on, plus the gear on the same UPS that it can only name:
{
"monitored_machines": [
{ "name": "fileserver", "shutdown_method": "ssh", "ssh": "fileserver", "ups": "ups1" }
],
"upses": {
"ups1": {
"label": "Rack UPS",
"devices": [
{ "name": "edge-router", "kind": "network", "note": "loses the WAN for everything" },
{ "name": "rack-switch", "kind": "network", "note": "uplink for the whole rack" }
],
"shutdown_targets": []
}
}
}fileserver appears once, in monitored_machines. status still prints it
on the ups1 card's Shuts row, because that row is derived, not copied:
Shuts fileserver
Powers edge-router, rack-switch
config.example.json ships the devices half of this and leaves
monitored_machines empty on purpose — install.sh copies that file into
/etc on a fresh box, and an example machine there would be a half-formed live
enrollment. devices is safe to ship precisely because it is inert.
{
"discord_webhook_env": "UPS_DISCORD_WEBHOOK",
"poll_seconds": 30,
"countdown_every_seconds": 60,
"load_step": { "enabled": true, "drop_percent": 15, "cooldown_seconds": 600, "window_polls": 4 },
"shutdown": {
"enabled": false,
"require_power_outage": true,
"min_on_battery_seconds": 120,
"notify": true,
"external": { "enabled": false, "battery_below": 15, "runtime_below": 300 },
"internal": { "enabled": false, "battery_below": 10, "runtime_below": 120 }
},
"upses": {
"rack": {
"label": "Rack UPS",
"devices": [{ "name": "edge-router", "kind": "network" }],
"shutdown_targets": []
},
"desk": { "label": "Desk UPS", "shutdown_targets": [] }
}
}Set it in the environment, never in the file:
export UPS_DISCORD_WEBHOOK="https://discord.com/api/webhooks/…"On a deployed box this lives in /etc/ups-orchestrator.env, which both the NUT
event path and the watch service read.
Use ups-orchestrator notify-test after editing the env file. It sends a test
embed and prints ok, configured, attempt count, and HTTP status without
printing the webhook URL.