From ffe3bc29cb7acb3c0743c5c5ec8874da02f26bb6 Mon Sep 17 00:00:00 2001 From: Ulises Chavarria Date: Wed, 1 Jul 2026 21:45:13 +0000 Subject: [PATCH 1/3] feat(server): add UPS support via NUT for the CyberPower PR1500LCDRT2U Adds a nut package entry (server/apt) and a linux-server/ups/ service dir: standalone NUT config for usbhid-ups on loopback, an idempotent sudo setup.sh that renders the upsmon password from a gitignored .env into /etc/nut, and a NOTIFYCMD hook pushing power events to ntfy. Low battery triggers a clean shutdown + UPS outlet power-off so the server restarts when wall power returns. Co-Authored-By: Claude Fable 5 --- linux-server/README.md | 20 ++++-- linux-server/apt_packages.md | 5 +- linux-server/post-install.md | 15 +++++ linux-server/ups/.env.example | 13 ++++ linux-server/ups/README.md | 78 ++++++++++++++++++++++ linux-server/ups/nut.conf | 1 + linux-server/ups/setup.sh | 95 +++++++++++++++++++++++++++ linux-server/ups/ups-notify.sh | 26 ++++++++ linux-server/ups/ups.conf | 10 +++ linux-server/ups/upsd.conf | 2 + linux-server/ups/upsd.users.template | 3 + linux-server/ups/upsmon.conf.template | 24 +++++++ packages.json | 8 +++ 13 files changed, 295 insertions(+), 5 deletions(-) create mode 100644 linux-server/ups/.env.example create mode 100644 linux-server/ups/README.md create mode 100644 linux-server/ups/nut.conf create mode 100755 linux-server/ups/setup.sh create mode 100755 linux-server/ups/ups-notify.sh create mode 100644 linux-server/ups/ups.conf create mode 100644 linux-server/ups/upsd.conf create mode 100644 linux-server/ups/upsd.users.template create mode 100644 linux-server/ups/upsmon.conf.template diff --git a/linux-server/README.md b/linux-server/README.md index e90f646..056040d 100644 --- a/linux-server/README.md +++ b/linux-server/README.md @@ -339,18 +339,30 @@ In NPM admin (`http://:81`): ``` 3. The status card on Homepage shows last-run time, status, and repo size; failures push to ntfy -19. pi-hole | [GitHub](https://github.com/pi-hole/pi-hole) | [Docs](https://docs.pi-hole.net) +19. UPS | [NUT](https://networkupstools.org/) | [Docs](https://networkupstools.org/docs/man/) + 1. Battery-backup monitoring for the CyberPower PR1500LCDRT2U over USB — ntfy alerts on power events, clean shutdown on low battery, auto-restart when wall power returns. Full runbook in [`ups/README.md`](ups/README.md) + 2. Deploy: + ```sh + sudo apt install nut # or rerun the root setup.sh --profile server + cd linux-server/ups + cp .env.example .env # set UPSMON_PASSWORD (openssl rand -hex 16) + ntfy + sudo bash setup.sh + upsc cyberpower ups.status # expect: OL + ``` + 3. Set BIOS **Restore on AC Power Loss → Power On** so the server boots unattended after an outage + +20. pi-hole | [GitHub](https://github.com/pi-hole/pi-hole) | [Docs](https://docs.pi-hole.net) 1. Network-wide DNS ad blocker — alternative to AdGuard Home 2. Not yet configured -20. Immich | [GitHub](https://github.com/immich-app/immich) | [Docs](https://immich.app/docs) +21. Immich | [GitHub](https://github.com/immich-app/immich) | [Docs](https://immich.app/docs) 1. Self-hosted photo and video backup — Google Photos alternative with mobile apps, face recognition, and timeline view 2. Not yet configured -21. Jellyfin | [GitHub](https://github.com/jellyfin/jellyfin) | [Docs](https://jellyfin.org/docs/) +22. Jellyfin | [GitHub](https://github.com/jellyfin/jellyfin) | [Docs](https://jellyfin.org/docs/) 1. Self-hosted media server — stream your own movies, TV shows, and music to any device 2. Not yet configured -22. Home Assistant | [GitHub](https://github.com/home-assistant/core) | [Docs](https://www.home-assistant.io/docs/) +23. Home Assistant | [GitHub](https://github.com/home-assistant/core) | [Docs](https://www.home-assistant.io/docs/) 1. Open source smart home hub — integrates with thousands of devices and services 2. Not yet configured diff --git a/linux-server/apt_packages.md b/linux-server/apt_packages.md index c974c6d..42df832 100644 --- a/linux-server/apt_packages.md +++ b/linux-server/apt_packages.md @@ -64,7 +64,10 @@ Tested on Ubuntu Server LTS. Most packages work on other Debian-based distros. 1. Web-based server admin UI — system metrics, journal logs, network config, storage, and service management 2. Enabled automatically via systemd socket activation on install; access at `https://:9090` 3. Login with your Linux username and password -19. sshpass | [apt](https://packages.ubuntu.com/search?keywords=sshpass) | [Homepage](https://sourceforge.net/projects/sshpass/) +19. nut | [apt](https://packages.ubuntu.com/search?keywords=nut) | [Homepage](https://networkupstools.org/) + 1. Network UPS Tools — monitors the CyberPower PR1500LCDRT2U over USB; ntfy alerts + clean shutdown on low battery + 2. Needs one-time config after install: `sudo bash linux-server/ups/setup.sh` (see [`ups/README.md`](ups/README.md)) +20. sshpass | [apt](https://packages.ubuntu.com/search?keywords=sshpass) | [Homepage](https://sourceforge.net/projects/sshpass/) 1. Non-interactive SSH password authentication — used by `SSH_and_GPG/add_remote_host.sh` ## Optional diff --git a/linux-server/post-install.md b/linux-server/post-install.md index aed52fe..4e8c38a 100644 --- a/linux-server/post-install.md +++ b/linux-server/post-install.md @@ -252,6 +252,21 @@ git remote set-url origin ssh://git@forgejo..ts.net:22//.ts.net +NTFY_TOPIC=server-ups +NTFY_TOKEN= diff --git a/linux-server/ups/README.md b/linux-server/ups/README.md new file mode 100644 index 0000000..43bd8a2 --- /dev/null +++ b/linux-server/ups/README.md @@ -0,0 +1,78 @@ +# UPS (NUT) + +[Network UPS Tools](https://networkupstools.org/) monitoring for the CyberPower +PR1500LCDRT2U connected over USB (`usbhid-ups` driver, standalone mode). On +power loss it pushes ntfy alerts; when the battery runs low it shuts the server +down cleanly and tells the UPS to cut its outlets so everything restarts when +wall power returns. + +## Setup + +1. Install NUT (the root `setup.sh --profile server` installs it via + `packages.json`, or): + ```sh + sudo apt install nut + ``` +2. Configure: + ```sh + cd linux-server/ups + cp .env.example .env + # set UPSMON_PASSWORD (openssl rand -hex 16) + the ntfy URL/topic + ``` +3. Deploy configs to `/etc/nut` and enable the services: + ```sh + sudo bash setup.sh # add --dry-run to preview + ``` +4. Verify: + ```sh + upsc cyberpower ups.status # expect: OL (on line power) + upsc cyberpower # full variable dump — charge, runtime, load + journalctl -u nut-monitor -f + ``` +5. Test a notification (safe — no shutdown involved): + ```sh + sudo -u nut NOTIFYTYPE=ONBATT /etc/nut/ups-notify.sh "test event" + ``` +6. In BIOS, set **Restore on AC Power Loss → Power On** so the machine boots + when the UPS re-energizes its outlets after an outage. + +## What happens during an outage + +1. Wall power drops → upsmon logs `ONBATT` and pushes an urgent ntfy alert. +2. Power returns before the battery runs low → `ONLINE` alert, nothing else. +3. Battery hits the low threshold → upsmon runs `SHUTDOWNCMD` + (`shutdown -h +0`), sets `/etc/killpower`, and the Debian shutdown hook + tells the UPS to power off its outlets after the OS halts. +4. Wall power returns → the UPS re-energizes, and with the BIOS set to + power-on the server boots unattended. + +## Tuning + +- **Low-battery threshold**: CyberPower firmware fires LOWBATT late (~10% + charge). For more headroom, uncomment in `ups.conf` and re-run `setup.sh`: + ``` + override.battery.charge.low = 25 # shut down at 25% charge + override.battery.runtime.low = 300 # or at 5 minutes runtime left + ``` +- Poll/timing knobs (`POLLFREQ`, `HOSTSYNC`, `DEADTIME`, `FINALDELAY`) are at + conventional values in `upsmon.conf.template`. +- Brief blips send an ONBATT + ONLINE alert pair. If that gets noisy, + `upssched` can debounce (only alert after N seconds on battery) — not wired + up; layer it in later if needed. +- Possible follow-up: a homepage status card fed from `upsc` output, like the + backup status card (`backup/docker-compose.yml` is the pattern). + +## Files + +| Repo file | Deployed to | Purpose | +|---|---|---| +| `nut.conf` | `/etc/nut/nut.conf` | `MODE=standalone` (turns NUT on) | +| `ups.conf` | `/etc/nut/ups.conf` | `usbhid-ups` driver for the CyberPower | +| `upsd.conf` | `/etc/nut/upsd.conf` | upsd listens on loopback only | +| `upsd.users.template` | `/etc/nut/upsd.users` | upsmon user (password from `.env`) | +| `upsmon.conf.template` | `/etc/nut/upsmon.conf` | shutdown + notification policy | +| `ups-notify.sh` | `/etc/nut/ups-notify.sh` | NOTIFYCMD → ntfy | +| (rendered from `.env`) | `/etc/nut/ups-notify.env` | ntfy settings for the hook | + +Everything in `/etc/nut` is `root:nut 640` (the notify script `750`); secrets +live only in the gitignored `.env` and the rendered `/etc/nut` files. diff --git a/linux-server/ups/nut.conf b/linux-server/ups/nut.conf new file mode 100644 index 0000000..6529f5e --- /dev/null +++ b/linux-server/ups/nut.conf @@ -0,0 +1 @@ +MODE=standalone diff --git a/linux-server/ups/setup.sh b/linux-server/ups/setup.sh new file mode 100755 index 0000000..9b6be76 --- /dev/null +++ b/linux-server/ups/setup.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deploys NUT (Network UPS Tools) config for the CyberPower PR1500LCDRT2U to +# /etc/nut and enables the services. Idempotent — re-runs are no-ops unless a +# config changed. Run as root: sudo bash setup.sh [--dry-run] + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" + +DRY_RUN=false +[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true + +log() { printf '==> %s\n' "$*"; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } +run() { + if [[ "$DRY_RUN" == true ]]; then + printf '[dry-run] %s\n' "$*" + else + "$@" + fi +} + +if [[ "$DRY_RUN" == false && $EUID -ne 0 ]]; then + die "must run as root: sudo bash $0" +fi + +[[ -f "$SCRIPT_DIR/.env" ]] || die "no .env — cp .env.example .env, then set UPSMON_PASSWORD (openssl rand -hex 16)" +set -a +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/.env" +set +a + +[[ -n "${UPSMON_PASSWORD:-}" ]] || die "UPSMON_PASSWORD is empty in .env — generate with: openssl rand -hex 16" +[[ "$UPSMON_PASSWORD" =~ ^[[:alnum:]]+$ ]] || die "UPSMON_PASSWORD must be alphanumeric (it is rendered into configs with sed)" + +if ! command -v upsdrvctl >/dev/null; then + if [[ "$DRY_RUN" == true ]]; then + printf 'warning: NUT is not installed yet — run: sudo apt install nut\n' >&2 + else + die "NUT is not installed — run: sudo apt install nut (or rerun the root setup.sh)" + fi +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +cp "$SCRIPT_DIR/nut.conf" "$SCRIPT_DIR/ups.conf" "$SCRIPT_DIR/upsd.conf" "$SCRIPT_DIR/ups-notify.sh" "$tmp/" +sed "s|@UPSMON_PASSWORD@|$UPSMON_PASSWORD|" "$SCRIPT_DIR/upsd.users.template" > "$tmp/upsd.users" +sed "s|@UPSMON_PASSWORD@|$UPSMON_PASSWORD|" "$SCRIPT_DIR/upsmon.conf.template" > "$tmp/upsmon.conf" +{ + printf 'NTFY_URL=%s\n' "${NTFY_URL:-}" + printf 'NTFY_TOPIC=%s\n' "${NTFY_TOPIC:-server-ups}" + printf 'NTFY_TOKEN=%s\n' "${NTFY_TOKEN:-}" +} > "$tmp/ups-notify.env" + +changed=false +deploy() { + local src="$1" dest="$2" mode="$3" + if [[ -f "$dest" ]] && cmp -s "$src" "$dest"; then + printf ' ✓ %s\n' "$dest" + return 0 + fi + run install -o root -g nut -m "$mode" "$src" "$dest" + changed=true + printf ' installed %s\n' "$dest" +} + +log "Deploying NUT configs to /etc/nut..." +deploy "$tmp/nut.conf" /etc/nut/nut.conf 640 +deploy "$tmp/ups.conf" /etc/nut/ups.conf 640 +deploy "$tmp/upsd.conf" /etc/nut/upsd.conf 640 +deploy "$tmp/upsd.users" /etc/nut/upsd.users 640 +deploy "$tmp/upsmon.conf" /etc/nut/upsmon.conf 640 +deploy "$tmp/ups-notify.env" /etc/nut/ups-notify.env 640 +deploy "$tmp/ups-notify.sh" /etc/nut/ups-notify.sh 750 + +log "Enabling NUT services..." +run systemctl enable --now nut-server.service nut-monitor.service + +if [[ "$changed" == true ]]; then + log "Configs changed — restarting NUT..." + run systemctl restart nut-server.service nut-monitor.service +fi + +if [[ "$DRY_RUN" == false ]]; then + log "Verifying (driver can take a few seconds to settle)..." + sleep 3 + if status="$(upsc cyberpower@localhost ups.status 2>/dev/null)"; then + log "ups.status: $status (OL = on line power)" + else + printf 'warning: upsc could not reach the UPS yet — check: journalctl -u nut-driver@cyberpower -u nut-server\n' >&2 + fi + log "Test a notification with:" + printf ' sudo -u nut NOTIFYTYPE=ONBATT /etc/nut/ups-notify.sh "test event"\n' +fi diff --git a/linux-server/ups/ups-notify.sh b/linux-server/ups/ups-notify.sh new file mode 100755 index 0000000..881d979 --- /dev/null +++ b/linux-server/ups/ups-notify.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# upsmon NOTIFYCMD hook — pushes UPS power events to ntfy. Deployed to +# /etc/nut/ups-notify.sh by setup.sh; runs as the nut user with the event +# message as $1 and the event type in $NOTIFYTYPE. Reads its ntfy settings +# from /etc/nut/ups-notify.env (root:nut 640, rendered from ../.env). +set -u + +[[ -f /etc/nut/ups-notify.env ]] && source /etc/nut/ups-notify.env +[[ -n "${NTFY_URL:-}" ]] || exit 0 + +msg="${1:-UPS event}" +case "${NOTIFYTYPE:-}" in + ONBATT) title="UPS on battery"; priority=urgent; tags=battery,warning ;; + LOWBATT) title="UPS battery LOW"; priority=urgent; tags=rotating_light ;; + FSD) title="UPS forced shutdown"; priority=urgent; tags=rotating_light ;; + SHUTDOWN) title="Server shutting down (UPS)"; priority=urgent; tags=rotating_light ;; + ONLINE) title="UPS back on line power"; priority=default; tags=electric_plug ;; + COMMBAD|NOCOMM) title="UPS communication lost"; priority=high; tags=warning ;; + COMMOK) title="UPS communication restored"; priority=default; tags=white_check_mark ;; + REPLBATT) title="UPS battery needs replacement"; priority=high; tags=battery ;; + *) title="UPS event (${NOTIFYTYPE:-unknown})"; priority=default; tags=zap ;; +esac + +args=(-fsS -H "Title: $title" -H "Priority: $priority" -H "Tags: $tags" -d "$msg") +[[ -n "${NTFY_TOKEN:-}" ]] && args+=(-H "Authorization: Bearer $NTFY_TOKEN") +curl "${args[@]}" "$NTFY_URL/${NTFY_TOPIC:-server-ups}" >/dev/null 2>&1 || true diff --git a/linux-server/ups/ups.conf b/linux-server/ups/ups.conf new file mode 100644 index 0000000..64dc2c4 --- /dev/null +++ b/linux-server/ups/ups.conf @@ -0,0 +1,10 @@ +[cyberpower] + driver = usbhid-ups + port = auto + vendorid = 0764 + productid = 0601 + desc = "CyberPower PR1500LCDRT2U" + # CyberPower firmware fires LOWBATT late (~10% charge). Uncomment to shut + # down with more headroom, by charge or by remaining runtime (seconds): + # override.battery.charge.low = 25 + # override.battery.runtime.low = 300 diff --git a/linux-server/ups/upsd.conf b/linux-server/ups/upsd.conf new file mode 100644 index 0000000..7a6d86d --- /dev/null +++ b/linux-server/ups/upsd.conf @@ -0,0 +1,2 @@ +LISTEN 127.0.0.1 3493 +LISTEN ::1 3493 diff --git a/linux-server/ups/upsd.users.template b/linux-server/ups/upsd.users.template new file mode 100644 index 0000000..55c95a6 --- /dev/null +++ b/linux-server/ups/upsd.users.template @@ -0,0 +1,3 @@ +[upsmon] + password = @UPSMON_PASSWORD@ + upsmon primary diff --git a/linux-server/ups/upsmon.conf.template b/linux-server/ups/upsmon.conf.template new file mode 100644 index 0000000..a816c38 --- /dev/null +++ b/linux-server/ups/upsmon.conf.template @@ -0,0 +1,24 @@ +MONITOR cyberpower@localhost 1 upsmon @UPSMON_PASSWORD@ primary + +MINSUPPLIES 1 +SHUTDOWNCMD "/sbin/shutdown -h +0" +POWERDOWNFLAG /etc/killpower +NOTIFYCMD /etc/nut/ups-notify.sh + +POLLFREQ 5 +POLLFREQALERT 5 +HOSTSYNC 15 +DEADTIME 15 +FINALDELAY 5 +RBWARNTIME 43200 +NOCOMMWARNTIME 300 + +NOTIFYFLAG ONLINE SYSLOG+EXEC +NOTIFYFLAG ONBATT SYSLOG+EXEC +NOTIFYFLAG LOWBATT SYSLOG+EXEC +NOTIFYFLAG FSD SYSLOG+EXEC +NOTIFYFLAG COMMOK SYSLOG+EXEC +NOTIFYFLAG COMMBAD SYSLOG+EXEC +NOTIFYFLAG NOCOMM SYSLOG+EXEC +NOTIFYFLAG SHUTDOWN SYSLOG+EXEC +NOTIFYFLAG REPLBATT SYSLOG+EXEC diff --git a/packages.json b/packages.json index 48e7d5c..a9f1792 100644 --- a/packages.json +++ b/packages.json @@ -409,6 +409,14 @@ "optional": false, "description": "Web-based server admin UI — system metrics, journal logs, network config, storage, and service management; access at https://:9090" }, + { + "name": "nut", + "tags": ["system-monitoring"], + "package_manager": { "server": "apt" }, + "priority": "medium", + "optional": false, + "description": "Network UPS Tools — monitors the CyberPower PR1500LCDRT2U over USB (usbhid-ups, standalone); graceful shutdown on low battery; configure via linux-server/ups/setup.sh" + }, { "name": "tailscale", "tags": ["networking"], From f542763e6d614081f117f988ce57a9c55000b93e Mon Sep 17 00:00:00 2001 From: Ulises Chavarria Date: Wed, 1 Jul 2026 21:58:28 +0000 Subject: [PATCH 2/3] feat(server): add PeaNUT UPS dashboard with homepage widget Host-networked PeaNUT container on :8097 (upsd is loopback-only) with charge/load/runtime graphs, plus a homepage ups card using the native peanut widget. Runtime settings dir gitignored. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 +++ linux-server/README.md | 4 +++- linux-server/homepage/config/services.yaml | 12 ++++++++++++ linux-server/post-install.md | 3 +++ linux-server/ups/README.md | 15 +++++++++++++-- linux-server/ups/docker-compose.yml | 18 ++++++++++++++++++ 6 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 linux-server/ups/docker-compose.yml diff --git a/.gitignore b/.gitignore index d4c7bc5..2df1f6c 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ linux-server/backup/status/ # Forgejo runner status JSON + last-state written at runtime (homepage card) linux-server/forgejo/runner-status/ + +# PeaNUT runtime settings written by its UI +linux-server/ups/peanut-config/ diff --git a/linux-server/README.md b/linux-server/README.md index 056040d..2bd710c 100644 --- a/linux-server/README.md +++ b/linux-server/README.md @@ -348,8 +348,10 @@ In NPM admin (`http://:81`): cp .env.example .env # set UPSMON_PASSWORD (openssl rand -hex 16) + ntfy sudo bash setup.sh upsc cyberpower ups.status # expect: OL + docker compose up -d # PeaNUT dashboard + homepage widget ``` - 3. Set BIOS **Restore on AC Power Loss → Power On** so the server boots unattended after an outage + 3. Dashboard (charge/load/runtime graphs) at `http://:8097`; the homepage **ups** card reads it via the `peanut` widget + 4. Set BIOS **Restore on AC Power Loss → Power On** so the server boots unattended after an outage 20. pi-hole | [GitHub](https://github.com/pi-hole/pi-hole) | [Docs](https://docs.pi-hole.net) 1. Network-wide DNS ad blocker — alternative to AdGuard Home diff --git a/linux-server/homepage/config/services.yaml b/linux-server/homepage/config/services.yaml index a2794e8..498da88 100644 --- a/linux-server/homepage/config/services.yaml +++ b/linux-server/homepage/config/services.yaml @@ -53,6 +53,18 @@ - field: repo_size_bytes label: Repo size format: bytes + - ups: + icon: mdi-battery-charging-high + href: http://{{HOMEPAGE_VAR_SERVER_IP}}:8097/ + description: NUT · CyberPower 1500VA + server: my-docker + container: peanut + widget: + type: peanut + # peanut is host-networked (upsd is loopback-only); homepage is too, + # so localhost reaches it. + url: http://localhost:8097 + key: cyberpower - glances: icon: si-glances href: https://{{HOMEPAGE_VAR_GLANCES_DOMAIN}}/ diff --git a/linux-server/post-install.md b/linux-server/post-install.md index 4e8c38a..2729f3c 100644 --- a/linux-server/post-install.md +++ b/linux-server/post-install.md @@ -264,6 +264,8 @@ cleanly on low battery. Full runbook in [`ups/README.md`](ups/README.md). sudo bash setup.sh ``` - [ ] Verify: `upsc cyberpower ups.status` prints `OL` +- [ ] Start the PeaNUT dashboard (`docker compose up -d`) — graphs at + `http://:8097`, and the homepage **ups** card goes live - [ ] Subscribe to the `server-ups` ntfy topic on your phone - [ ] Set BIOS **Restore on AC Power Loss → Power On** @@ -285,6 +287,7 @@ cleanly on low battery. Full runbook in [`ups/README.md`](ups/README.md). | Syncthing | http://\:8384 | | | AdGuard Home | http://\:8083 | Run setup wizard at :3003 first | | Cockpit | https://\:9090 | | +| PeaNUT (UPS) | http://\:8097 | Homepage ups card reads it via localhost | | Tailscale Web UI | http://localhost:8088 | After `tailscale up` | | Tailscale proxy | http://localhost:8089 | Internal — used by Homepage widget | | Forgejo | https://forgejo.\.ts.net/ | Tailscale sidecar (HTTPS via serve); Git over SSH on port 22 | diff --git a/linux-server/ups/README.md b/linux-server/ups/README.md index 43bd8a2..b15d857 100644 --- a/linux-server/ups/README.md +++ b/linux-server/ups/README.md @@ -59,8 +59,19 @@ wall power returns. - Brief blips send an ONBATT + ONLINE alert pair. If that gets noisy, `upssched` can debounce (only alert after N seconds on battery) — not wired up; layer it in later if needed. -- Possible follow-up: a homepage status card fed from `upsc` output, like the - backup status card (`backup/docker-compose.yml` is the pattern). + +## Dashboard (PeaNUT) + +[PeaNUT](https://github.com/Brandawg93/PeaNUT) serves a web dashboard with +charge/load/runtime graphs at `http://:8097` and feeds the homepage +**ups** card (`type: peanut` widget). Host-networked so it can reach the +loopback-only `upsd`; auth is disabled (read-only stats on a trusted LAN, same +posture as glances). Its runtime settings dir (`peanut-config/`) is gitignored. + +```sh +cd linux-server/ups +docker compose up -d +``` ## Files diff --git a/linux-server/ups/docker-compose.yml b/linux-server/ups/docker-compose.yml new file mode 100644 index 0000000..3820dee --- /dev/null +++ b/linux-server/ups/docker-compose.yml @@ -0,0 +1,18 @@ +services: + # NUT web dashboard (charge/load/runtime graphs) — also feeds the homepage + # "ups" card's peanut widget. Host-networked because upsd listens on + # loopback only; the web UI binds 0.0.0.0:8097 so it's reachable on the LAN. + peanut: + image: brandawg93/peanut:latest + container_name: peanut + restart: unless-stopped + network_mode: host + environment: + - NUT_HOST=127.0.0.1 + - NUT_PORT=3493 + - WEB_HOST=0.0.0.0 + - WEB_PORT=8097 + # Read-only UPS stats on a trusted LAN — same posture as glances. + - AUTH_DISABLED=true + volumes: + - ./peanut-config:/config From 41259029723052913b85cbfbb6eb03cb3160a3ba Mon Sep 17 00:00:00 2001 From: Ulises Chavarria Date: Wed, 1 Jul 2026 22:03:31 +0000 Subject: [PATCH 3/3] feat(server): front PeaNUT with a Tailscale HTTPS sidecar Moves the homepage ups tile from the raw LAN port to the standard peanut..ts.net convention: host-networked-variant sidecar (per HTTPS.md, like glances) proxying to :8097, HOMEPAGE_VAR_PEANUT_DOMAIN for the tile href, widget still reads localhost. Co-Authored-By: Claude Fable 5 --- linux-server/HTTPS.md | 1 + linux-server/README.md | 2 +- linux-server/homepage/.env.example | 3 +++ linux-server/homepage/config/services.yaml | 2 +- linux-server/post-install.md | 7 ++--- linux-server/ups/.env.example | 4 +++ linux-server/ups/README.md | 11 +++++--- linux-server/ups/docker-compose.yml | 30 +++++++++++++++++++++- linux-server/ups/ts-serve.json | 8 ++++++ 9 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 linux-server/ups/ts-serve.json diff --git a/linux-server/HTTPS.md b/linux-server/HTTPS.md index 979a44e..d05c96f 100644 --- a/linux-server/HTTPS.md +++ b/linux-server/HTTPS.md @@ -224,6 +224,7 @@ side of the `ports:` mapping (`host:container`), not the host side. | filebrowser | 80 | ✅ done | none — works at root; proxy to container :80, **not** the old 8080 host map | | syncthing | 8384 | ✅ done | set `STGUIADDRESS=127.0.0.1:8384` (disables Syncthing's Host-header check, else `Host check error`); publish sync `:22000`/`:21027` on the **sidecar** (raw TCP/UDP, not via serve) | | glances | 61208 | ✅ done | **host-networked variant** — keep `network_mode: host`, sidecar proxies via `host.docker.internal`; widget url stays localhost | +| peanut (UPS) | 8097 | ✅ done | host-networked variant like glances — PeaNUT must reach the loopback-only `upsd:3493`, sidecar proxies via `host.docker.internal`; widget url stays localhost | | adguard | 80 | ✅ done | UI at container :80 (not the 8083 host map); publish DNS `:53` tcp+udp on the **sidecar** (raw DNS, not via serve); no :443 so no DoH/serve conflict | | atvloadly | 80 | ✅ done | no `hostname:` on the app container — conflicts with `network_mode: service:...`; Apple TV discovery is unaffected by the shared netns since it goes through the host's avahi-daemon via bind-mounted sockets, not this container's own network | | nginx-proxy-mgr | 81 | ✅ done | host edge (binds `:80/:443/:81`); its **admin UI** is fronted by a host-gateway sidecar at `npm.`, while NPM itself stays the non-tailnet trusted-cert edge (see section below) | diff --git a/linux-server/README.md b/linux-server/README.md index 2bd710c..db6009e 100644 --- a/linux-server/README.md +++ b/linux-server/README.md @@ -350,7 +350,7 @@ In NPM admin (`http://:81`): upsc cyberpower ups.status # expect: OL docker compose up -d # PeaNUT dashboard + homepage widget ``` - 3. Dashboard (charge/load/runtime graphs) at `http://:8097`; the homepage **ups** card reads it via the `peanut` widget + 3. Dashboard (charge/load/runtime graphs) at `https://peanut..ts.net/` (set `TS_AUTHKEY` in `ups/.env` for the sidecar); the homepage **ups** card reads it via the `peanut` widget on localhost 4. Set BIOS **Restore on AC Power Loss → Power On** so the server boots unattended after an outage 20. pi-hole | [GitHub](https://github.com/pi-hole/pi-hole) | [Docs](https://docs.pi-hole.net) diff --git a/linux-server/homepage/.env.example b/linux-server/homepage/.env.example index 737557f..ea29675 100644 --- a/linux-server/homepage/.env.example +++ b/linux-server/homepage/.env.example @@ -45,6 +45,9 @@ HOMEPAGE_VAR_SYNCTHING_DOMAIN=syncthing..ts.net # Glances — its own Tailscale sidecar name (glances..ts.net), per HTTPS.md HOMEPAGE_VAR_GLANCES_DOMAIN=glances..ts.net +# PeaNUT (UPS dashboard) — its own Tailscale sidecar name (peanut..ts.net), per HTTPS.md +HOMEPAGE_VAR_PEANUT_DOMAIN=peanut..ts.net + # AdGuard Home — its own Tailscale sidecar name (adguard..ts.net), per HTTPS.md HOMEPAGE_VAR_ADGUARD_DOMAIN=adguard..ts.net diff --git a/linux-server/homepage/config/services.yaml b/linux-server/homepage/config/services.yaml index 498da88..c37fc97 100644 --- a/linux-server/homepage/config/services.yaml +++ b/linux-server/homepage/config/services.yaml @@ -55,7 +55,7 @@ format: bytes - ups: icon: mdi-battery-charging-high - href: http://{{HOMEPAGE_VAR_SERVER_IP}}:8097/ + href: https://{{HOMEPAGE_VAR_PEANUT_DOMAIN}}/ description: NUT · CyberPower 1500VA server: my-docker container: peanut diff --git a/linux-server/post-install.md b/linux-server/post-install.md index 2729f3c..e6ebf52 100644 --- a/linux-server/post-install.md +++ b/linux-server/post-install.md @@ -264,8 +264,9 @@ cleanly on low battery. Full runbook in [`ups/README.md`](ups/README.md). sudo bash setup.sh ``` - [ ] Verify: `upsc cyberpower ups.status` prints `OL` -- [ ] Start the PeaNUT dashboard (`docker compose up -d`) — graphs at - `http://:8097`, and the homepage **ups** card goes live +- [ ] Start the PeaNUT dashboard (`docker compose up -d`, needs `TS_AUTHKEY` in + `.env`) — graphs at `https://peanut..ts.net/`, and the homepage + **ups** card goes live - [ ] Subscribe to the `server-ups` ntfy topic on your phone - [ ] Set BIOS **Restore on AC Power Loss → Power On** @@ -287,7 +288,7 @@ cleanly on low battery. Full runbook in [`ups/README.md`](ups/README.md). | Syncthing | http://\:8384 | | | AdGuard Home | http://\:8083 | Run setup wizard at :3003 first | | Cockpit | https://\:9090 | | -| PeaNUT (UPS) | http://\:8097 | Homepage ups card reads it via localhost | +| PeaNUT (UPS) | https://peanut.\.ts.net/ | Tailscale sidecar; homepage ups card reads it via localhost :8097 | | Tailscale Web UI | http://localhost:8088 | After `tailscale up` | | Tailscale proxy | http://localhost:8089 | Internal — used by Homepage widget | | Forgejo | https://forgejo.\.ts.net/ | Tailscale sidecar (HTTPS via serve); Git over SSH on port 22 | diff --git a/linux-server/ups/.env.example b/linux-server/ups/.env.example index 7cbf647..699dbaf 100644 --- a/linux-server/ups/.env.example +++ b/linux-server/ups/.env.example @@ -11,3 +11,7 @@ UPSMON_PASSWORD= NTFY_URL=https://ntfy..ts.net NTFY_TOPIC=server-ups NTFY_TOKEN= + +# Tailscale auth for the PeaNUT sidecar (peanut..ts.net) — same OAuth +# client secret as the other sidecars (Auth Keys scope + tag:container in the ACL). +TS_AUTHKEY= diff --git a/linux-server/ups/README.md b/linux-server/ups/README.md index b15d857..3ccea4d 100644 --- a/linux-server/ups/README.md +++ b/linux-server/ups/README.md @@ -63,13 +63,16 @@ wall power returns. ## Dashboard (PeaNUT) [PeaNUT](https://github.com/Brandawg93/PeaNUT) serves a web dashboard with -charge/load/runtime graphs at `http://:8097` and feeds the homepage -**ups** card (`type: peanut` widget). Host-networked so it can reach the -loopback-only `upsd`; auth is disabled (read-only stats on a trusted LAN, same -posture as glances). Its runtime settings dir (`peanut-config/`) is gitignored. +charge/load/runtime graphs at `https://peanut..ts.net/` (Tailscale +sidecar per [`../HTTPS.md`](../HTTPS.md), host-networked variant like glances — +PeaNUT itself must stay on the host network to reach the loopback-only `upsd`) +and feeds the homepage **ups** card (`type: peanut` widget via localhost). +Auth is disabled (read-only stats on a trusted network, same posture as +glances). Its runtime settings dir (`peanut-config/`) is gitignored. ```sh cd linux-server/ups +# set TS_AUTHKEY in .env (same OAuth client secret as the other sidecars) docker compose up -d ``` diff --git a/linux-server/ups/docker-compose.yml b/linux-server/ups/docker-compose.yml index 3820dee..5ea0413 100644 --- a/linux-server/ups/docker-compose.yml +++ b/linux-server/ups/docker-compose.yml @@ -1,7 +1,35 @@ services: + # Tailscale sidecar — joins the tailnet as `peanut`, terminating HTTPS for + # https://peanut..ts.net. Host-networked variant per ../HTTPS.md: + # peanut stays on the host network (it must reach the loopback-only upsd), + # so the sidecar runs in its own netns and proxies back to the host's :8097 + # via host.docker.internal. + peanut-ts: + image: tailscale/tailscale:latest + container_name: peanut-ts + hostname: peanut + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_SERVE_CONFIG=/config/serve.json + # Requires TS_AUTHKEY to be allowed to apply tag:container (OAuth client, or + # a tag-scoped auth key). Drop this line if using an untagged personal key. + - TS_EXTRA_ARGS=--advertise-tags=tag:container + volumes: + - ./ts-state:/var/lib/tailscale + - ./ts-serve.json:/config/serve.json:ro + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + # NUT web dashboard (charge/load/runtime graphs) — also feeds the homepage # "ups" card's peanut widget. Host-networked because upsd listens on - # loopback only; the web UI binds 0.0.0.0:8097 so it's reachable on the LAN. + # loopback only; the web UI binds 0.0.0.0:8097 so both the sidecar (via the + # docker bridge gateway) and the LAN can reach it. peanut: image: brandawg93/peanut:latest container_name: peanut diff --git a/linux-server/ups/ts-serve.json b/linux-server/ups/ts-serve.json new file mode 100644 index 0000000..5b9607a --- /dev/null +++ b/linux-server/ups/ts-serve.json @@ -0,0 +1,8 @@ +{ + "TCP": { "443": { "HTTPS": true } }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { "/": { "Proxy": "http://host.docker.internal:8097" } } + } + } +}