From b74114567deb9afcfe0d7f2f7ad10fc56d6d7209 Mon Sep 17 00:00:00 2001 From: JuanCF Date: Fri, 14 Aug 2026 02:09:55 -0600 Subject: [PATCH 1/6] feat: add Docker deployment support Add a multi-stage Docker deployment for NutWatch: the React SPA is built in a Node stage and bundled with the backend into an Ubuntu runtime that runs NUT under supervisord (tini as PID 1). A systemctl shim translates backend service calls to supervisorctl, propagating failures and reporting real service states, and an entrypoint generates first-boot NUT configs from environment variables using alphanumeric-only random passwords (NUT treats # as a comment start and would silently truncate them) and sets 640 permissions on upsd.users. An upsmon wrapper handles upsmon fork behavior: the unprivileged child escapes the process group via setsid, so plain supervisorctl restarts orphaned the child and put the program into FATAL on the first UI config save; the wrapper traps the stop signal and brings the whole pair down. The compose file offers USB access options and persistent volumes for config and data. The README documents Docker usage including the Wake-on-LAN broadcast limitation on bridge networks, and the CI shell-lint job now covers the same file set as the Makefile. --- .dockerignore | 29 +++++++ .github/workflows/lint.yml | 5 +- AGENTS.md | 2 +- Dockerfile | 68 ++++++++++++++++ LICENSE | 21 +++++ README.md | 63 +++++++++++++++ docker-compose.yml | 34 ++++++++ scripts/docker/entrypoint.sh | 133 +++++++++++++++++++++++++++++++ scripts/docker/supervisord.conf | 39 +++++++++ scripts/docker/systemctl-shim.sh | 107 +++++++++++++++++++++++++ scripts/docker/upsmon-wrapper.sh | 56 +++++++++++++ 11 files changed, 554 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 docker-compose.yml create mode 100644 scripts/docker/entrypoint.sh create mode 100644 scripts/docker/supervisord.conf create mode 100644 scripts/docker/systemctl-shim.sh create mode 100644 scripts/docker/upsmon-wrapper.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1909806 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,29 @@ +# Git +.git +.github + +# Python virtual environments +.venv +src/backend/.venv +src/backend/__pycache__ +src/backend/.pytest_cache +**/__pycache__ +**/.pytest_cache + +# Frontend build intermediates +src/frontend/node_modules +src/frontend/dist + +# Distribution artifacts +nutwatch.tar.gz + +# Editor / local config +.opencode +.claude +.coderabbit.yaml +.playwright-cli +.vscode +.idea + +# Documentation assets (not needed at runtime) +docs/screenshots diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 29e255e..3cf90d2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,11 +14,12 @@ jobs: - name: Install tools run: sudo apt-get install -y shellcheck shfmt + # Same file set as `make lint` / `make fmt` (see Makefile SHELL_FILES). - name: shellcheck - run: find vm/ -name "*.sh" -print0 | xargs -0 shellcheck + run: find vm/ src/backend/ scripts/ -name "*.sh" -print0 | xargs -0 shellcheck - name: shfmt - run: find vm/ -name "*.sh" -print0 | xargs -0 shfmt -d -i 2 + run: find vm/ src/backend/ scripts/ -name "*.sh" -print0 | xargs -0 shfmt -d -i 2 lint-python: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 0dfcb25..b004d38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ make install-tools # apt-get shellcheck shfmt python3-pytest # python3 -m venv .venv && source .venv/bin/activate && pip install -r src/backend/requirements.txt ``` -CI runs `shellcheck` + `shfmt -d -i 2` on `vm/*.sh` and Python lint + tests (see `.github/workflows/lint.yml`). `make check` reproduces the full local suite. +CI runs `shellcheck` + `shfmt -d -i 2` on `vm/`, `src/backend/`, and `scripts/` (same set as the Makefile's `SHELL_FILES`) plus Python lint + tests (see `.github/workflows/lint.yml`). `make check` reproduces the full local suite. ## Shell Conventions diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c2fa35f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,68 @@ +# syntax=docker/dockerfile:1 + +# Multi-stage build for NutWatch. +# Stage 1 builds the React frontend; Stage 2 is the runtime image with NUT. + +FROM node:22-slim AS frontend-builder +# Build the React SPA. Vite writes to ../backend/static, so we copy the +# backend tree into the same relative location before building. +WORKDIR /build/src/frontend +COPY src/frontend/package.json src/frontend/package-lock.json ./ +RUN npm ci +COPY src/frontend/ ./ +COPY src/backend/ /build/src/backend/ +RUN npm run build + +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive +ENV NUTWATCH_DIR=/opt/nutwatch +ENV NUTWATCH_HOST=0.0.0.0 +ENV NUTWATCH_PORT=8081 +ENV NUT_LISTEN_ADDR=0.0.0.0 +ENV NUT_LISTEN_PORT=3493 +ENV PYTHONUNBUFFERED=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + nut-server \ + nut-client \ + usbutils \ + python3 \ + python3-venv \ + supervisor \ + tini \ + curl \ + openssl \ + && rm -rf /var/lib/apt/lists/* \ + && rm -f /etc/nut/ups.conf /etc/nut/upsd.conf /etc/nut/upsd.users \ + /etc/nut/upsmon.conf /etc/nut/nut.conf + +WORKDIR $NUTWATCH_DIR + +# Copy backend application with freshly built frontend static files. +COPY --from=frontend-builder /build/src/backend/ $NUTWATCH_DIR/ + +# Install Python dependencies. +RUN python3 -m venv $NUTWATCH_DIR/venv \ + && $NUTWATCH_DIR/venv/bin/pip install --no-cache-dir -r $NUTWATCH_DIR/requirements.txt + +# Copy Docker runtime helpers. +COPY scripts/docker/entrypoint.sh /entrypoint.sh +COPY scripts/docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY scripts/docker/systemctl-shim.sh /usr/local/bin/systemctl +COPY scripts/docker/upsmon-wrapper.sh /usr/local/bin/upsmon-wrapper + +RUN chmod +x /entrypoint.sh /usr/local/bin/systemctl /usr/local/bin/upsmon-wrapper \ + && mkdir -p /etc/nut/notify.d /var/log/nut /var/run/nut /var/lib/nutwatch /var/log/supervisor \ + && chown -R root:nut /etc/nut \ + && chmod 750 /etc/nut /etc/nut/notify.d \ + && chown nut:nut /var/log/nut /var/run/nut \ + && chmod 755 /var/lib/nutwatch + +# Persist NUT configuration and NutWatch data (accounts, history, API keys). +VOLUME ["/etc/nut", "/var/lib/nutwatch"] + +EXPOSE 8081 3493 + +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["/entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3531944 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 nutwatch contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 287b215..8ea3e47 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,69 @@ curl -fsSL https://raw.githubusercontent.com/JuanCF/nutwatch/main/scripts/setup. Set the `NUTWATCH_REF` env var to pin a specific release version. +### Docker + +A multi-stage `Dockerfile` and `docker-compose.yml` are included. The image +bundles NUT, the NutWatch backend, and a built React frontend. + +```bash +# Build and run with Docker Compose +docker compose up -d + +# Or build and run manually +docker build -t nutwatch . +docker run -d \ + --name nutwatch \ + --privileged \ + -p 8081:8081 \ + -p 3493:3493 \ + -v nutwatch-config:/etc/nut \ + -v nutwatch-data:/var/lib/nutwatch \ + -e NUT_ADMIN_PASS=changeme \ + -e NUT_MONITOR_PASS=changeme \ + -e NUTWATCH_SECRET_KEY='a-very-long-random-string-at-least-32-characters' \ + nutwatch +``` + +**USB access.** NUT drivers need to talk to the UPS over USB. The easiest way +is to run the container `--privileged` and pass the host USB bus: + +```bash +docker run -d --privileged -v /dev/bus/usb:/dev/bus/usb ... nutwatch +``` + +If you know the exact USB device node (for example `/dev/usb/hiddev0`), you +can use `--device` instead of `--privileged`: + +```bash +docker run -d --device /dev/usb/hiddev0 ... nutwatch +``` + +Inside `docker-compose.yml`, `privileged: true` is enabled by default. Replace +it with the `devices:` block if you prefer a more restrictive setup. + +**Persistent data.** Two volumes are used: + +- `/etc/nut` — NUT configuration files (`ups.conf`, `upsd.users`, `upsmon.conf`, + hooks in `notify.d/`, etc.) +- `/var/lib/nutwatch` — NutWatch account/API-key database and UPS history SQLite + database + +**Container notes.** + +- The container uses `supervisord` instead of `systemd`. A small `systemctl` + shim maps the UI's service-restart actions to `supervisorctl` so NUT service + restarts still work. +- System-level actions (reboot/shutdown) are disabled inside the container. +- Live log streaming relies on `journalctl`; in the container this is not + available. Use `docker exec nutwatch tail -f /var/log/supervisor/upsd.log` or + inspect the individual supervisor log files instead. +- Wake on LAN: magic packets default to the `255.255.255.255` broadcast, + which does not leave Docker's default bridge network. For WOL to reach + hosts on your LAN, either run the container with `--network host` + (`network_mode: host` in compose) or set a directed broadcast address + (e.g. `192.168.1.255`) on each WOL target. + ### Proxmox VM (One-Liner) ```bash diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..92bbeee --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +services: + nutwatch: + build: . + container_name: nutwatch + # USB access option A: privileged mode (simplest, required for hotplug). + privileged: true + # USB access option B: pass the host USB bus (more restrictive). + # Uncomment the next two lines and remove/comment `privileged: true` if you + # know the UPS is already plugged in when the container starts. + # devices: + # - /dev/bus/usb:/dev/bus/usb + ports: + - "8081:8081" + - "3493:3493" + volumes: + - nutwatch-config:/etc/nut + - nutwatch-data:/var/lib/nutwatch + environment: + - NUT_UPS_NAME=ups + - NUT_UPS_DESC=My UPS + - NUT_DRIVER=usbhid-ups + - NUT_ADMIN_USER=admin + # Change this before first start; it is only used to create upsd.users. + - NUT_ADMIN_PASS=changeme + - NUT_MONITOR_USER=monuser + # Change this before first start. + - NUT_MONITOR_PASS=changeme + # Generate a strong secret, at least 32 characters. + - NUTWATCH_SECRET_KEY=change-me-to-a-long-random-string-at-least-32-chars + restart: unless-stopped + +volumes: + nutwatch-config: + nutwatch-data: diff --git a/scripts/docker/entrypoint.sh b/scripts/docker/entrypoint.sh new file mode 100644 index 0000000..49e4f2b --- /dev/null +++ b/scripts/docker/entrypoint.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# Docker entrypoint for NutWatch. +# Generates a minimal NUT configuration on first boot, ensures permissions, +# starts USB drivers, and then hands off to supervisord. + +set -euo pipefail + +NUT_UPS_NAME="${NUT_UPS_NAME:-ups}" +NUT_UPS_DESC="${NUT_UPS_DESC:-My UPS}" +NUT_DRIVER="${NUT_DRIVER:-usbhid-ups}" +NUT_ADMIN_USER="${NUT_ADMIN_USER:-admin}" +NUT_ADMIN_PASS="${NUT_ADMIN_PASS:-}" +NUT_MONITOR_USER="${NUT_MONITOR_USER:-monuser}" +NUT_MONITOR_PASS="${NUT_MONITOR_PASS:-}" + +# Generate a password only when the caller did not provide one. Stick to +# alphanumerics: NUT's config parser treats '#' as the start of a comment +# and would silently truncate the password (and the rest of the line). +generate_password() { + local length="${1:-16}" + openssl rand -base64 48 2>/dev/null | tr -dc 'a-zA-Z0-9' | head -c "$length" || true +} + +if [[ -z "$NUT_ADMIN_PASS" ]]; then + NUT_ADMIN_PASS="$(generate_password 16)" + echo "[nutwatch] Generated NUT admin password: $NUT_ADMIN_PASS" +fi + +if [[ -z "$NUT_MONITOR_PASS" ]]; then + NUT_MONITOR_PASS="$(generate_password 16)" + echo "[nutwatch] Generated NUT monitor password: $NUT_MONITOR_PASS" +fi + +mkdir -p /etc/nut/notify.d /var/log/nut /var/run/nut /var/lib/nutwatch /var/log/supervisor + +if [[ ! -f /etc/nut/nut.conf ]]; then + echo 'MODE=netserver' >/etc/nut/nut.conf +fi + +if [[ ! -f /etc/nut/upsd.conf ]]; then + cat >/etc/nut/upsd.conf </etc/nut/upsd.users </etc/nut/ups.conf </etc/nut/upsmon.conf </dev/null || true +chmod 640 /etc/nut/*.conf /etc/nut/upsd.users 2>/dev/null || true +chown root:nut /etc/nut/notify.d && chmod 750 /etc/nut/notify.d +chown nut:nut /var/log/nut /var/run/nut + +# Start USB drivers. This may fail if no USB device is present yet or if the +# configuration is intentionally empty; upsd/upsmon will keep retrying and the +# UI can start drivers later via upsdrvctl. +echo "[nutwatch] Starting NUT drivers..." +upsdrvctl start || true + +echo "[nutwatch] Starting supervisord..." +exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf diff --git a/scripts/docker/supervisord.conf b/scripts/docker/supervisord.conf new file mode 100644 index 0000000..f255df6 --- /dev/null +++ b/scripts/docker/supervisord.conf @@ -0,0 +1,39 @@ +[supervisord] +nodaemon=true +user=root +logfile=/var/log/supervisor/supervisord.log +pidfile=/var/run/supervisord.pid + +[unix_http_server] +file=/var/run/supervisor.sock +chmod=0700 + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=unix:///var/run/supervisor.sock + +[program:upsd] +command=/usr/sbin/upsd -F +autostart=true +autorestart=true +stdout_logfile=/var/log/supervisor/upsd.log +stderr_logfile=/var/log/supervisor/upsd.err + +[program:upsmon] +# upsmon -F forks an unprivileged child that survives a plain restart; the +# wrapper traps supervisord's stop signal and brings the whole pair down. +command=/usr/local/bin/upsmon-wrapper +autostart=true +autorestart=true +stdout_logfile=/var/log/supervisor/upsmon.log +stderr_logfile=/var/log/supervisor/upsmon.err + +[program:nutwatch] +command=/opt/nutwatch/venv/bin/python /opt/nutwatch/app.py +directory=/opt/nutwatch +autostart=true +autorestart=true +stdout_logfile=/var/log/supervisor/nutwatch.log +stderr_logfile=/var/log/supervisor/nutwatch.err diff --git a/scripts/docker/systemctl-shim.sh b/scripts/docker/systemctl-shim.sh new file mode 100644 index 0000000..8566d8d --- /dev/null +++ b/scripts/docker/systemctl-shim.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# systemctl shim for NutWatch running inside a Docker container supervised by +# supervisord. Translates the systemctl calls issued by the backend into +# supervisorctl commands so the UI restart buttons keep working. + +set -euo pipefail + +map_service() { + case "$1" in + nut-server) echo "upsd" ;; + nut-monitor) echo "upsmon" ;; + nutwatch) echo "nutwatch" ;; + *) echo "" ;; + esac +} + +supervisor_running() { + supervisorctl status "$1" 2>/dev/null | grep -q "RUNNING" +} + +# Drivers run via upsdrvctl (not supervisord), so report on them by looking +# for a live driver PID file in the NUT state directory. +driver_running() { + local pidfile pid + for pidfile in /var/run/nut/*.pid; do + [[ -e "$pidfile" ]] || continue + case "$(basename "$pidfile")" in + upsd.pid | upsmon.pid) continue ;; + esac + pid="$(cat "$pidfile" 2>/dev/null || true)" + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + return 0 + fi + done + return 1 +} + +is_active() { + local svc="$1" target + target="$(map_service "$svc")" + if [[ -n "$target" ]]; then + supervisor_running "$target" + return + fi + case "$svc" in + nut-driver | nut-driver@*) driver_running ;; + *) return 3 ;; + esac +} + +cmd="${1:-}" +shift || true + +case "$cmd" in +restart | start | stop) + # Propagate failures so the UI can report them instead of claiming success. + rc=0 + for svc in "$@"; do + target="$(map_service "$svc")" + if [[ -n "$target" ]]; then + supervisorctl "$cmd" "$target" || rc=$? + fi + done + exit "$rc" + ;; + +is-active) + # Mirror systemctl: print the state, exit 0 when active, 3 otherwise. + rc=3 + for svc in "$@"; do + if is_active "$svc"; then + echo "active" + rc=0 + else + echo "inactive" + fi + done + exit "$rc" + ;; + +status) + for svc in "$@"; do + if is_active "$svc"; then + state="active (running)" + else + state="inactive (dead)" + fi + echo "● ${svc}.service" + echo " Active: ${state}" + done + exit 0 + ;; + +reboot | poweroff) + echo "systemctl $cmd is not supported inside a container" >&2 + exit 1 + ;; + +*) + # Unknown command: fall back to the real systemctl if one exists. + if command -v /usr/bin/systemctl &>/dev/null; then + /usr/bin/systemctl "$cmd" "$@" + else + exit 0 + fi + ;; +esac diff --git a/scripts/docker/upsmon-wrapper.sh b/scripts/docker/upsmon-wrapper.sh new file mode 100644 index 0000000..5a6f74f --- /dev/null +++ b/scripts/docker/upsmon-wrapper.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# upsmon wrapper for supervisord. +# +# `upsmon -F` still forks: a root parent (the PID supervisord would track) and +# an unprivileged child running as `nut` in its own session (setsid). A plain +# `supervisorctl restart upsmon` signals only the tracked parent, so the child +# survives holding the PID file and every respawn dies instantly with +# "A previous upsmon instance is already running!", putting the program into +# FATAL. This wrapper stays in the foreground, traps termination signals, and +# brings the whole upsmon pair down so restarts are clean. + +set -euo pipefail + +PIDFILE="/var/run/nut/upsmon.pid" + +stop_upsmon() { + # The unprivileged child escapes the process group via setsid(), so signal + # by name. This container runs exactly one upsmon instance, launched by this + # wrapper, so any match is safe to kill. + pkill -x upsmon 2>/dev/null || true +} + +on_term() { + stop_upsmon + exit 0 +} +trap on_term TERM INT + +# Clean up orphans from a previous unclean stop so the new instance can claim +# its PID file. Give them a moment to exit on SIGTERM before escalating. +if pgrep -x upsmon >/dev/null 2>&1; then + stop_upsmon + for _ in $(seq 1 50); do + pgrep -x upsmon >/dev/null 2>&1 || break + sleep 0.1 + done + if pgrep -x upsmon >/dev/null 2>&1; then + pkill -9 -x upsmon 2>/dev/null || true + fi +fi + +# Drop a stale PID file left behind by a SIGKILLed instance, otherwise upsmon +# refuses to start. A live process holding it was handled above. +if [[ -f "$PIDFILE" ]]; then + old_pid="$(cat "$PIDFILE" 2>/dev/null || true)" + if [[ -z "$old_pid" ]] || ! kill -0 "$old_pid" 2>/dev/null; then + rm -f "$PIDFILE" + fi +fi + +/usr/sbin/upsmon -F & +upsmon_pid=$! + +# Surface upsmon's exit status to supervisord so autorestart works on crash. +# If the wrapper is signaled, the trap above runs and brings the pair down. +wait "$upsmon_pid" From d563934be5b2415daeb2da91bae31146cd6d9b53 Mon Sep 17 00:00:00 2001 From: JuanCF Date: Thu, 20 Aug 2026 21:48:41 -0600 Subject: [PATCH 2/6] fix: run NUT driver and upsd as root in Docker The container NUT driver dropped to user "nut", which cannot open host USB device nodes (typically root-owned with mode 660/664), so the generated ups.conf now runs the driver as root. Since the driver then creates its control socket as root:root, upsd also runs as root (-u root) so it can connect to the driver socket and serve UPS data. --- scripts/docker/entrypoint.sh | 3 +++ scripts/docker/supervisord.conf | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/docker/entrypoint.sh b/scripts/docker/entrypoint.sh index 49e4f2b..a19ae4d 100644 --- a/scripts/docker/entrypoint.sh +++ b/scripts/docker/entrypoint.sh @@ -65,6 +65,9 @@ if [[ ! -f /etc/nut/ups.conf ]]; then port = auto desc = "${NUT_UPS_DESC}" pollinterval = 5 + # Run the driver as root: host USB device nodes are typically root-owned + # (mode 660/664) and the container's 'nut' user has no write access to them. + user = root EOF fi diff --git a/scripts/docker/supervisord.conf b/scripts/docker/supervisord.conf index f255df6..c2733cb 100644 --- a/scripts/docker/supervisord.conf +++ b/scripts/docker/supervisord.conf @@ -15,7 +15,9 @@ supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface serverurl=unix:///var/run/supervisor.sock [program:upsd] -command=/usr/sbin/upsd -F +# Run as root: the driver (see user = root in ups.conf) creates its control +# socket as root:root, which upsd running as `nut` cannot access. +command=/usr/sbin/upsd -F -u root autostart=true autorestart=true stdout_logfile=/var/log/supervisor/upsd.log From ffae676c6f5aca23dc37dad06c256170a0f2c39e Mon Sep 17 00:00:00 2001 From: JuanCF Date: Thu, 20 Aug 2026 22:12:28 -0600 Subject: [PATCH 3/6] fix: make Logs tab work in Docker without journald The container has no journald, so the Logs tab (which read NUT service logs via journalctl) was empty, and NUT daemon syslog() output was silently dropped with no syslog daemon running. The image now installs busybox-syslogd and the entrypoint starts it early, capturing all NUT logs to /var/log/messages (the packaged /etc/syslog.conf is removed so the -O path is honored). The logs API probes journalctl and, when no journal is available, falls back to tailing that file for both the recent and stream endpoints; host installs with journald keep the exact same journalctl commands. --- Dockerfile | 4 +- scripts/docker/entrypoint.sh | 10 ++++ src/backend/routes/logs.py | 62 +++++++++++++++++-------- src/backend/tests/test_routes.py | 79 ++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 21 deletions(-) diff --git a/Dockerfile b/Dockerfile index c2fa35f..6da2e36 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ nut-server \ nut-client \ usbutils \ + busybox-syslogd \ python3 \ python3-venv \ supervisor \ @@ -35,7 +36,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ openssl \ && rm -rf /var/lib/apt/lists/* \ && rm -f /etc/nut/ups.conf /etc/nut/upsd.conf /etc/nut/upsd.users \ - /etc/nut/upsmon.conf /etc/nut/nut.conf + /etc/nut/upsmon.conf /etc/nut/nut.conf \ + && rm -f /etc/syslog.conf WORKDIR $NUTWATCH_DIR diff --git a/scripts/docker/entrypoint.sh b/scripts/docker/entrypoint.sh index a19ae4d..e012974 100644 --- a/scripts/docker/entrypoint.sh +++ b/scripts/docker/entrypoint.sh @@ -126,6 +126,16 @@ chmod 640 /etc/nut/*.conf /etc/nut/upsd.users 2>/dev/null || true chown root:nut /etc/nut/notify.d && chmod 750 /etc/nut/notify.d chown nut:nut /var/log/nut /var/run/nut +# NUT daemons log via syslog(3), which is silently dropped when no syslog +# daemon is running (the container has no journald), leaving the NutWatch +# Logs tab empty. busybox syslogd is tiny, daemonizes on its own and captures +# everything to /var/log/messages, which the backend tails when journald is +# unavailable. +touch /var/log/messages +if command -v busybox >/dev/null 2>&1; then + busybox syslogd -O /var/log/messages +fi + # Start USB drivers. This may fail if no USB device is present yet or if the # configuration is intentionally empty; upsd/upsmon will keep retrying and the # UI can start drivers later via upsdrvctl. diff --git a/src/backend/routes/logs.py b/src/backend/routes/logs.py index b27e6a0..8c219ac 100644 --- a/src/backend/routes/logs.py +++ b/src/backend/routes/logs.py @@ -1,3 +1,4 @@ +import os import select import subprocess @@ -8,20 +9,48 @@ logs_bp = Blueprint("logs", __name__) +# Syslog capture file used when journald is unavailable (e.g. inside the +# Docker container, where NUT daemons log via syslog()). +SYSLOG_FILE = os.environ.get("NUTWATCH_SYSLOG_FILE", "/var/log/messages") + +JOURNAL_UNITS = ["nut-server", "nut-monitor", "nut-driver"] + + +def _journal_available() -> bool: + """Return True when journalctl can serve the NUT unit logs. + + Containers usually run without journald; journalctl then prints + "No journal files were found" on stderr and returns nothing usable, + so the Logs tab would stay empty. In that case we fall back to + tailing the syslog capture file instead. + """ + rc, _, err = run_cmd(["journalctl", "--no-pager", "-n", "1"], timeout=10) + return rc == 0 and "No journal files" not in err + + +def _recent_command(lines: str, journal: bool) -> list: + if journal: + cmd = ["journalctl", "--no-pager", "-n", lines] + for unit in JOURNAL_UNITS: + cmd += ["-u", unit] + return cmd + return ["tail", "-n", lines, SYSLOG_FILE] + + +def _stream_command(journal: bool) -> list: + if journal: + cmd = ["journalctl", "--no-pager", "-f", "-n", "0"] + for unit in JOURNAL_UNITS: + cmd += ["-u", unit] + return cmd + return ["tail", "-F", "-n", "0", SYSLOG_FILE] + @logs_bp.route("/api/logs/stream") @require_auth def stream_logs(): proc = subprocess.Popen( - [ - "journalctl", - "-u", "nut-server", - "-u", "nut-monitor", - "-u", "nut-driver", - "-f", - "-n", "0", - "--no-pager", - ], + _stream_command(_journal_available()), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, @@ -57,15 +86,8 @@ def recent_logs(): lines = request.args.get("lines", "100") if not lines.isdigit(): lines = "100" - rc, out, err = run_cmd( - [ - "journalctl", - "-u", "nut-server", - "-u", "nut-monitor", - "-u", "nut-driver", - "-n", lines, - "--no-pager", - ], - timeout=30, - ) + journal = _journal_available() + if not journal and not os.path.exists(SYSLOG_FILE): + return jsonify({"returncode": 0, "stdout": "", "stderr": ""}) + rc, out, err = run_cmd(_recent_command(lines, journal), timeout=30) return jsonify({"returncode": rc, "stdout": out, "stderr": err}) \ No newline at end of file diff --git a/src/backend/tests/test_routes.py b/src/backend/tests/test_routes.py index 4f5b12e..3321f8e 100644 --- a/src/backend/tests/test_routes.py +++ b/src/backend/tests/test_routes.py @@ -112,6 +112,85 @@ def test_recent_logs(monkeypatch): assert data["returncode"] == 0 +def test_recent_logs_syslog_fallback(monkeypatch, tmp_path): + orig = routes.logs.run_cmd + log_file = tmp_path / "messages" + log_file.write_text("syslog line 1\nsyslog line 2\n") + + def fake_run_cmd(cmd, **kw): + if cmd[0] == "journalctl": + return (0, "-- No entries --\n", "No journal files were found.\n") + return orig(cmd, **kw) + + monkeypatch.setattr("routes.logs.SYSLOG_FILE", str(log_file)) + monkeypatch.setattr("routes.logs.run_cmd", fake_run_cmd) + app = _register_all(_make_app()) + with app.test_client() as c: + resp = c.get("/api/logs/recent") + assert resp.status_code == 200 + data = resp.get_json() + assert data["returncode"] == 0 + assert "syslog line 1" in data["stdout"] + assert "syslog line 2" in data["stdout"] + + +def test_recent_logs_syslog_fallback_missing_file(monkeypatch): + monkeypatch.setattr("routes.logs.SYSLOG_FILE", "/nonexistent/nutwatch/messages") + monkeypatch.setattr( + "routes.logs.run_cmd", + lambda cmd, **kw: (0, "-- No entries --\n", "No journal files were found.\n"), + ) + app = _register_all(_make_app()) + with app.test_client() as c: + resp = c.get("/api/logs/recent") + assert resp.status_code == 200 + data = resp.get_json() + assert data["returncode"] == 0 + assert data["stdout"] == "" + + +def test_journal_available_false_when_no_journal(monkeypatch): + monkeypatch.setattr( + "routes.logs.run_cmd", + lambda cmd, **kw: (0, "-- No entries --\n", "No journal files were found.\n"), + ) + assert routes.logs._journal_available() is False + + +def test_journal_available_true_with_journal(monkeypatch): + monkeypatch.setattr( + "routes.logs.run_cmd", + lambda cmd, **kw: (0, "Jun 01 00:00:00 host upsd[1]: started\n", ""), + ) + assert routes.logs._journal_available() is True + + +def test_journal_available_false_when_binary_missing(monkeypatch): + # utils.run_cmd returns (-1, "", "") when the binary is absent. + monkeypatch.setattr("routes.logs.run_cmd", lambda cmd, **kw: (-1, "", "journalctl: not found")) + assert routes.logs._journal_available() is False + + +def test_recent_command_uses_journalctl_when_available(monkeypatch): + cmd = routes.logs._recent_command("50", True) + assert cmd[0] == "journalctl" + assert "-n" in cmd and "50" in cmd + for unit in ("nut-server", "nut-monitor", "nut-driver"): + assert "-u" in cmd and unit in cmd + + +def test_recent_command_uses_tail_in_fallback(monkeypatch): + monkeypatch.setattr("routes.logs.SYSLOG_FILE", "/var/log/messages") + cmd = routes.logs._recent_command("50", False) + assert cmd == ["tail", "-n", "50", "/var/log/messages"] + + +def test_stream_command_journal_and_fallback(monkeypatch): + assert routes.logs._stream_command(True)[0] == "journalctl" + monkeypatch.setattr("routes.logs.SYSLOG_FILE", "/var/log/messages") + assert routes.logs._stream_command(False) == ["tail", "-F", "-n", "0", "/var/log/messages"] + + # ── System routes ───────────────────────────────────────────────────── def test_get_config_route(monkeypatch): From df360cd2fd960cf587396d53c430f6c32dee3066 Mon Sep 17 00:00:00 2001 From: JuanCF Date: Thu, 20 Aug 2026 22:36:37 -0600 Subject: [PATCH 4/6] fix: enable WOL device discovery in Docker via host networking The container previously ran on the default Docker bridge network, where the ARP-based LAN host scan returned nothing: the bridge namespace has no L2 access to the physical LAN, and the image lacked the tools the scan needs. The compose service now uses network_mode: host so the container shares the host network stack (LAN route and ARP table) and magic-packet broadcasts can reach the physical network; the ports: mapping is removed since it is ignored under host networking. The image additionally installs iproute2, iputils-arping, and iputils-ping, which the WOL scan uses to detect the LAN subnet, probe neighbors, and read the ARP cache. --- Dockerfile | 3 +++ docker-compose.yml | 8 +++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6da2e36..d073129 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ nut-client \ usbutils \ busybox-syslogd \ + iproute2 \ + iputils-arping \ + iputils-ping \ python3 \ python3-venv \ supervisor \ diff --git a/docker-compose.yml b/docker-compose.yml index 92bbeee..4c3c98a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,9 +9,11 @@ services: # know the UPS is already plugged in when the container starts. # devices: # - /dev/bus/usb:/dev/bus/usb - ports: - - "8081:8081" - - "3493:3493" + # Share the host network stack: WOL device discovery needs the LAN's ARP + # table, and magic-packet broadcasts must reach the physical network. + # With host networking, `ports:` is ignored; NutWatch serves directly on + # host ports 8081 (UI) and 3493 (NUT). + network_mode: host volumes: - nutwatch-config:/etc/nut - nutwatch-data:/var/lib/nutwatch From 985a5f562b59e84a6cceecf567f9534ed2f89cd8 Mon Sep 17 00:00:00 2001 From: JuanCF Date: Thu, 20 Aug 2026 23:23:33 -0600 Subject: [PATCH 5/6] fix: harden Docker USB access and NUT driver service resolution Docker deployments no longer need privileged mode: the compose file and README now mount the host USB bus with a device cgroup rule (c 189:* rwm), which is evaluated when devices appear, so UPS hotplug keeps working. Hardcoded changeme NUT passwords and the fixed Flask secret are gone; the entrypoint generates random credentials on first start (persisted in the nutwatch-config volume), the backend generates its session key in nutwatch-data, and generated passwords are no longer logged. The systemctl shim now dispatches nut-driver and nut-driver@ actions through upsdrvctl, mirroring systemctl semantics (stopping an inactive driver succeeds), and exits 4 for unmapped service names instead of reporting success without doing anything. NUT 2.8.x ships no bare nut-driver.service on most distros: drivers run as nut-driver@ instances, so host installs showed the driver as inactive and dropped its journal lines. Driver unit names are now resolved from ups.conf for status, restart, and journalctl, with pid-file and upsdrvctl fallbacks. The lint workflow also pins token permissions to contents: read. --- .github/workflows/lint.yml | 3 + AGENTS.md | 1 + README.md | 40 ++++++---- docker-compose.yml | 30 ++++---- scripts/docker/entrypoint.sh | 4 +- scripts/docker/systemctl-shim.sh | 49 ++++++++++++ src/backend/routes/logs.py | 12 ++- src/backend/services/system.py | 65 +++++++++++++++- src/backend/tests/test_routes.py | 3 +- src/backend/tests/test_services_system.py | 90 +++++++++++++++++++++-- 10 files changed, 253 insertions(+), 44 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3cf90d2..acc2b15 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: lint-shell: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index b004d38..e410c89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,7 @@ CI runs `shellcheck` + `shfmt -d -i 2` on `vm/`, `src/backend/`, and `scripts/` - Slow DHCP / guest agent: retries for up to 5 minutes. - virt-customize network failure on Debian 13 (Proxmox VE 9): auto-installs `dhcpcd-base` when missing. - NUT service enablement varies by distro: `nut-driver-enumerator` → `nut-driver@` → `nut-driver`. Each unit is enabled individually with `|| true` so missing units don't abort the whole run. +- NUT 2.8.x has no bare `nut-driver.service` on most distros (drivers run as `nut-driver@` instances); `services/system.py::_driver_status_units()` resolves the right unit for status/restart and `routes/logs.py::_journal_units()` uses it for journalctl, falling back to pid-file checks or `upsdrvctl`. - NutWatch install failure inside virt-customize: wrapped in `&& ... || echo` so a download failure doesn't abort the VM setup. - Script interruption: `trap ERR` calls `error_handler`, `trap EXIT` runs `cleanup` (removes temp dir and working disk image), and `trap SIGINT/SIGTERM` posts failure to the API before exiting. - Hook ownership: per-UPS hook scripts must be `root:nut 750` so `upsmon` (running as the `nut` user) can execute them. `services/hooks.py::put_hook()` explicitly `chown`s to `root:nut` after writing. diff --git a/README.md b/README.md index 8ea3e47..e428e1a 100644 --- a/README.md +++ b/README.md @@ -439,33 +439,41 @@ docker compose up -d docker build -t nutwatch . docker run -d \ --name nutwatch \ - --privileged \ + --device /dev/bus/usb:/dev/bus/usb \ + --device-cgroup-rule 'c 189:* rwm' \ -p 8081:8081 \ -p 3493:3493 \ -v nutwatch-config:/etc/nut \ -v nutwatch-data:/var/lib/nutwatch \ - -e NUT_ADMIN_PASS=changeme \ - -e NUT_MONITOR_PASS=changeme \ - -e NUTWATCH_SECRET_KEY='a-very-long-random-string-at-least-32-characters' \ nutwatch ``` -**USB access.** NUT drivers need to talk to the UPS over USB. The easiest way -is to run the container `--privileged` and pass the host USB bus: +**USB access.** NUT drivers need to talk to the UPS over USB. The container +gets the host USB bus plus an allow-rule for USB character devices: ```bash -docker run -d --privileged -v /dev/bus/usb:/dev/bus/usb ... nutwatch -``` - -If you know the exact USB device node (for example `/dev/usb/hiddev0`), you -can use `--device` instead of `--privileged`: - -```bash -docker run -d --device /dev/usb/hiddev0 ... nutwatch +docker run -d \ + --device /dev/bus/usb:/dev/bus/usb \ + --device-cgroup-rule 'c 189:* rwm' \ + ... nutwatch ``` -Inside `docker-compose.yml`, `privileged: true` is enabled by default. Replace -it with the `devices:` block if you prefer a more restrictive setup. +The cgroup rule (major number 189 is USB) is evaluated when devices appear, +so UPS hotplug works without privileged mode. If USB access still fails on +your setup, `--privileged` (or `privileged: true` in compose) is the +documented fallback. + +**Credentials.** On first start the entrypoint generates random passwords for +the NUT `admin` and `monuser` users and writes them to `/etc/nut/upsd.users` +inside the `nutwatch-config` volume; the Flask session key is generated by the +backend and persisted in the auth database inside `nutwatch-data`. Generated +values are not printed to logs. To use your own credentials, set +`NUT_ADMIN_PASS` / `NUT_MONITOR_PASS` (and optionally `NUT_ADMIN_USER` / +`NUT_MONITOR_USER` or `NUTWATCH_SECRET_KEY`, at least 32 characters) before +the first start. Because `upsd.users` is only initialized on first start, +rotate existing credentials via the NutWatch UI (NUT Users tab, which +restarts NUT automatically) or the Config Files tab instead of environment +variables. **Persistent data.** Two volumes are used: diff --git a/docker-compose.yml b/docker-compose.yml index 4c3c98a..442d4a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,13 +2,15 @@ services: nutwatch: build: . container_name: nutwatch - # USB access option A: privileged mode (simplest, required for hotplug). - privileged: true - # USB access option B: pass the host USB bus (more restrictive). - # Uncomment the next two lines and remove/comment `privileged: true` if you - # know the UPS is already plugged in when the container starts. - # devices: - # - /dev/bus/usb:/dev/bus/usb + # USB access: mount the host USB bus and allow USB character devices via + # the device cgroup. The cgroup rule is evaluated when devices appear, so + # UPS hotplug works without privileged mode. + devices: + - /dev/bus/usb:/dev/bus/usb + device_cgroup_rules: + - 'c 189:* rwm' + # Fallback: replace the two blocks above with `privileged: true` if USB + # access still fails on your setup. # Share the host network stack: WOL device discovery needs the LAN's ARP # table, and magic-packet broadcasts must reach the physical network. # With host networking, `ports:` is ignored; NutWatch serves directly on @@ -22,13 +24,15 @@ services: - NUT_UPS_DESC=My UPS - NUT_DRIVER=usbhid-ups - NUT_ADMIN_USER=admin - # Change this before first start; it is only used to create upsd.users. - - NUT_ADMIN_PASS=changeme - NUT_MONITOR_USER=monuser - # Change this before first start. - - NUT_MONITOR_PASS=changeme - # Generate a strong secret, at least 32 characters. - - NUTWATCH_SECRET_KEY=change-me-to-a-long-random-string-at-least-32-chars + # Optional. Credentials are generated on first start and persisted in + # the nutwatch-config volume. Set these before the first + # `docker compose up` to use your own: + # - NUT_ADMIN_PASS=your-admin-password + # - NUT_MONITOR_PASS=your-monitor-password + # Optional. Flask session signing key (>= 32 characters). If unset, the + # backend generates a random key and persists it in nutwatch-data. + # - NUTWATCH_SECRET_KEY=your-long-random-secret restart: unless-stopped volumes: diff --git a/scripts/docker/entrypoint.sh b/scripts/docker/entrypoint.sh index e012974..312810b 100644 --- a/scripts/docker/entrypoint.sh +++ b/scripts/docker/entrypoint.sh @@ -23,12 +23,12 @@ generate_password() { if [[ -z "$NUT_ADMIN_PASS" ]]; then NUT_ADMIN_PASS="$(generate_password 16)" - echo "[nutwatch] Generated NUT admin password: $NUT_ADMIN_PASS" + echo "[nutwatch] Generated NUT admin credentials." fi if [[ -z "$NUT_MONITOR_PASS" ]]; then NUT_MONITOR_PASS="$(generate_password 16)" - echo "[nutwatch] Generated NUT monitor password: $NUT_MONITOR_PASS" + echo "[nutwatch] Generated NUT monitor credentials." fi mkdir -p /etc/nut/notify.d /var/log/nut /var/run/nut /var/lib/nutwatch /var/log/supervisor diff --git a/scripts/docker/systemctl-shim.sh b/scripts/docker/systemctl-shim.sh index 8566d8d..de474d8 100644 --- a/scripts/docker/systemctl-shim.sh +++ b/scripts/docker/systemctl-shim.sh @@ -35,6 +35,50 @@ driver_running() { return 1 } +name_pid_alive() { + local pid + pid="$(cat "/var/run/nut/${1}.pid" 2>/dev/null || true)" + [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null +} + +# Dispatch driver lifecycle actions through upsdrvctl. Mirror systemctl: +# stopping an inactive driver is a success. +driver_action() { + local action="$1" svc="$2" name="" + case "$svc" in + nut-driver@*) name="${svc#nut-driver@}" ;; + esac + case "$action" in + stop) + if [[ -n "$name" ]]; then + name_pid_alive "$name" || return 0 + upsdrvctl stop "$name" + else + driver_running || return 0 + upsdrvctl stop + fi + ;; + restart) + # upsdrvctl has no restart verb; the stop is best-effort because the + # driver may not be running yet. + if [[ -n "$name" ]]; then + upsdrvctl stop "$name" 2>/dev/null || true + upsdrvctl start "$name" + else + upsdrvctl stop 2>/dev/null || true + upsdrvctl start + fi + ;; + start) + if [[ -n "$name" ]]; then + upsdrvctl start "$name" + else + upsdrvctl start + fi + ;; + esac +} + is_active() { local svc="$1" target target="$(map_service "$svc")" @@ -59,6 +103,11 @@ restart | start | stop) target="$(map_service "$svc")" if [[ -n "$target" ]]; then supervisorctl "$cmd" "$target" || rc=$? + elif [[ "$svc" == "nut-driver" || "$svc" == nut-driver@* ]]; then + driver_action "$cmd" "$svc" || rc=$? + else + printf 'Unsupported service: %s\n' "$svc" >&2 + rc=4 fi done exit "$rc" diff --git a/src/backend/routes/logs.py b/src/backend/routes/logs.py index 8c219ac..7072b94 100644 --- a/src/backend/routes/logs.py +++ b/src/backend/routes/logs.py @@ -5,6 +5,7 @@ from flask import Blueprint, Response, stream_with_context, request, jsonify from auth import require_auth +from services.system import _driver_status_units from utils import run_cmd logs_bp = Blueprint("logs", __name__) @@ -13,7 +14,12 @@ # Docker container, where NUT daemons log via syslog()). SYSLOG_FILE = os.environ.get("NUTWATCH_SYSLOG_FILE", "/var/log/messages") -JOURNAL_UNITS = ["nut-server", "nut-monitor", "nut-driver"] + +def _journal_units() -> list[str]: + # The driver has no bare nut-driver.service on most NUT 2.8.x distros + # (drivers run as nut-driver@ instances), so resolve the unit names + # the same way service status does, or journalctl would drop driver logs. + return ["nut-server", "nut-monitor", *_driver_status_units()] def _journal_available() -> bool: @@ -31,7 +37,7 @@ def _journal_available() -> bool: def _recent_command(lines: str, journal: bool) -> list: if journal: cmd = ["journalctl", "--no-pager", "-n", lines] - for unit in JOURNAL_UNITS: + for unit in _journal_units(): cmd += ["-u", unit] return cmd return ["tail", "-n", lines, SYSLOG_FILE] @@ -40,7 +46,7 @@ def _recent_command(lines: str, journal: bool) -> list: def _stream_command(journal: bool) -> list: if journal: cmd = ["journalctl", "--no-pager", "-f", "-n", "0"] - for unit in JOURNAL_UNITS: + for unit in _journal_units(): cmd += ["-u", unit] return cmd return ["tail", "-F", "-n", "0", SYSLOG_FILE] diff --git a/src/backend/services/system.py b/src/backend/services/system.py index 8e02038..39a956a 100644 --- a/src/backend/services/system.py +++ b/src/backend/services/system.py @@ -4,6 +4,7 @@ import time from config import NUT_DIR, ALLOWED_CONFIGS, IDENTIFIER_REGEX +from parsers.ups_conf import parse_ups_conf from utils import run_cmd, read_file, write_file, stop_driver_and_cleanup @@ -15,8 +16,60 @@ def restart_monitor(): return run_cmd(["systemctl", "restart", "nut-monitor"]) +def _ups_names() -> list[str]: + try: + content = read_file(os.path.join(NUT_DIR, "ups.conf")) + except FileNotFoundError: + return [] + return [entry["name"] for entry in parse_ups_conf(content)] + + +def _driver_status_units() -> list[str]: + # NUT's systemd unit set varies by distro/version. Current packaging + # (NUT 2.8.x, e.g. Ubuntu noble) starts drivers as nut-driver@ + # template instances managed by nut-driver-enumerator and ships no bare + # nut-driver.service; only some distros provide that wrapper unit. + rc, out, _ = run_cmd( + ["systemctl", "list-unit-files", "--no-legend", "--no-pager", "nut-driver.service"], + timeout=5, + ) + if rc == 0 and out.strip(): + return ["nut-driver"] + return [f"nut-driver@{name}" for name in _ups_names()] + + +def _pidfile_driver_active() -> tuple[bool, str]: + # No systemd unit to ask (matches the Docker systemctl shim logic). + for base in ("/var/run/nut", "/run/nut"): + for pid_file in glob.glob(os.path.join(base, "*.pid")): + if os.path.basename(pid_file) in ("upsd.pid", "upsmon.pid"): + continue + try: + with open(pid_file, encoding="utf-8") as f: + pid = f.read().strip() + if pid.isdigit() and os.path.exists(f"/proc/{pid}"): + return True, "active" + except OSError: + pass + return False, "inactive" + + def restart_driver(): - return run_cmd(["systemctl", "restart", "nut-driver"]) + units = _driver_status_units() + if units: + rc = 0 + out = err = "" + for unit in units: + r, o, e = run_cmd(["systemctl", "restart", unit]) + rc = rc or r + out += o + err += e + return rc, out, err + # No systemd driver unit at all: fall back to upsdrvctl, which also works + # through the Docker systemctl shim. + rc1, out1, err1 = run_cmd(["upsdrvctl", "stop"], timeout=30) + rc2, out2, err2 = run_cmd(["upsdrvctl", "start"], timeout=30) + return rc2, out1 + out2, err1 + err2 def restart_all(): @@ -49,7 +102,15 @@ def detailed_service_status(): services = ["nut-driver", "nut-server", "nut-monitor"] result = {} for svc in services: - rc, out, err = run_cmd(["systemctl", "is-active", svc], timeout=5) + if svc == "nut-driver": + units = _driver_status_units() + if not units: + active, state = _pidfile_driver_active() + result[svc] = {"active": active, "state": state} + continue + else: + units = [svc] + rc, out, err = run_cmd(["systemctl", "is-active", *units], timeout=5) state = (out or err).strip() result[svc] = {"active": rc == 0, "state": state} return result diff --git a/src/backend/tests/test_routes.py b/src/backend/tests/test_routes.py index 3321f8e..c7991d5 100644 --- a/src/backend/tests/test_routes.py +++ b/src/backend/tests/test_routes.py @@ -172,10 +172,11 @@ def test_journal_available_false_when_binary_missing(monkeypatch): def test_recent_command_uses_journalctl_when_available(monkeypatch): + monkeypatch.setattr("routes.logs._driver_status_units", lambda: ["nut-driver@ups"]) cmd = routes.logs._recent_command("50", True) assert cmd[0] == "journalctl" assert "-n" in cmd and "50" in cmd - for unit in ("nut-server", "nut-monitor", "nut-driver"): + for unit in ("nut-server", "nut-monitor", "nut-driver@ups"): assert "-u" in cmd and unit in cmd diff --git a/src/backend/tests/test_services_system.py b/src/backend/tests/test_services_system.py index ae5cb58..ec657df 100644 --- a/src/backend/tests/test_services_system.py +++ b/src/backend/tests/test_services_system.py @@ -1,3 +1,5 @@ +import os + import pytest @@ -22,13 +24,50 @@ def test_restart_monitor(monkeypatch): assert rc == 0 -def test_restart_driver(monkeypatch): +def test_restart_driver_bare_unit(monkeypatch): calls = [] - monkeypatch.setattr("services.system.run_cmd", lambda cmd, **kw: (calls.append(cmd), _fake_rc(0))[1]) + def fake_run(cmd, **kw): + calls.append(cmd) + if "list-unit-files" in cmd: + return _fake_rc(0, "nut-driver.service enabled") + return _fake_rc(0) + monkeypatch.setattr("services.system.run_cmd", fake_run) + from services.system import restart_driver + rc, _, _ = restart_driver() + assert rc == 0 + assert calls[-1] == ["systemctl", "restart", "nut-driver"] + + +def test_restart_driver_template_units(monkeypatch): + calls = [] + def fake_run(cmd, **kw): + calls.append(cmd) + if "list-unit-files" in cmd: + return _fake_rc(1, "0 unit files listed.") + return _fake_rc(0) + monkeypatch.setattr("services.system.run_cmd", fake_run) + monkeypatch.setattr("services.system._ups_names", lambda: ["ups1", "ups2"]) from services.system import restart_driver rc, _, _ = restart_driver() assert rc == 0 - assert any("nut-driver" in c for c in calls) + assert ["systemctl", "restart", "nut-driver@ups1"] in calls + assert ["systemctl", "restart", "nut-driver@ups2"] in calls + + +def test_restart_driver_upsdrvctl_fallback(monkeypatch): + calls = [] + def fake_run(cmd, **kw): + calls.append(cmd) + if "list-unit-files" in cmd: + return _fake_rc(1, "0 unit files listed.") + return _fake_rc(0) + monkeypatch.setattr("services.system.run_cmd", fake_run) + monkeypatch.setattr("services.system._ups_names", lambda: []) + from services.system import restart_driver + rc, _, _ = restart_driver() + assert rc == 0 + assert ["upsdrvctl", "stop"] in calls + assert ["upsdrvctl", "start"] in calls def test_restart_all(monkeypatch): @@ -56,16 +95,53 @@ def fake_run(cmd, **kw): def test_detailed_service_status(monkeypatch): - states = iter(["active", "inactive", "active"]) def fake_run(cmd, **kw): - state = next(states) - rc = 0 if state == "active" else 3 - return _fake_rc(rc, state) + if "list-unit-files" in cmd: + return _fake_rc(0, "nut-driver.service enabled") + if cmd[:2] == ["systemctl", "is-active"]: + if "nut-driver" in cmd[2:]: + return _fake_rc(0, "active") + if "nut-server" in cmd[2:]: + return _fake_rc(3, "inactive") + return _fake_rc(0, "active") + return _fake_rc(0) monkeypatch.setattr("services.system.run_cmd", fake_run) from services.system import detailed_service_status result = detailed_service_status() assert result["nut-driver"]["active"] is True assert result["nut-server"]["active"] is False + assert result["nut-monitor"]["active"] is True + + +def test_detailed_service_status_template_instances(monkeypatch): + def fake_run(cmd, **kw): + if "list-unit-files" in cmd: + return _fake_rc(1, "0 unit files listed.") + if cmd[:2] == ["systemctl", "is-active"]: + if "nut-driver" in cmd[2:]: + assert cmd[2:] == ["nut-driver@ups"] + return _fake_rc(0, "active") + return _fake_rc(0) + monkeypatch.setattr("services.system.run_cmd", fake_run) + monkeypatch.setattr("services.system._ups_names", lambda: ["ups"]) + from services.system import detailed_service_status + result = detailed_service_status() + assert result["nut-driver"]["active"] is True + + +def test_detailed_service_status_pidfile_fallback(monkeypatch, tmp_path): + def fake_run(cmd, **kw): + if "list-unit-files" in cmd: + return _fake_rc(1, "0 unit files listed.") + return _fake_rc(0) + monkeypatch.setattr("services.system.run_cmd", fake_run) + monkeypatch.setattr("services.system._ups_names", lambda: []) + monkeypatch.setattr("services.system.glob.glob", lambda p: [str(tmp_path / "ups.pid")]) + (tmp_path / "ups.pid").write_text(str(os.getpid())) + from services.system import detailed_service_status + result = detailed_service_status() + assert result["nut-driver"]["active"] is True + assert result["nut-driver"]["state"] == "active" def test_service_status(monkeypatch): From e86233c046248ed46827ac5fa81d51c4c6b93d3c Mon Sep 17 00:00:00 2001 From: JuanCF Date: Thu, 20 Aug 2026 23:52:54 -0600 Subject: [PATCH 6/6] fix: sharpen driver unit detection and stop-failure propagation The systemctl shim name_pid_alive only checked /var/run/nut/.pid, but drivers name their pid file -.pid (e.g. usbhid-ups-ups.pid), so stopping or restarting a named driver like nut-driver@ups found no live pid and silently skipped the stop; it now also matches the suffixed form. On hosts, _driver_status_units treated any non-empty list-unit-files output as proof of a bare nut-driver.service, so a row listing only the nut-driver@.service template could send restarts to the wrong unit; it now requires the row first field to be exactly nut-driver.service, otherwise falling back to nut-driver@ units derived from ups.conf. The upsdrvctl fallback in restart_driver also propagates the stop rc instead of masking it with the start rc. Regression tests cover the template-row fallback and stop-failure propagation. The compose file documents the trust boundary of mounting the whole USB bus and that the UI stays open on the host network until the first admin account is created. --- docker-compose.yml | 9 ++++-- scripts/docker/systemctl-shim.sh | 14 +++++++-- src/backend/services/system.py | 8 +++-- src/backend/tests/test_services_system.py | 36 +++++++++++++++++++++++ 4 files changed, 59 insertions(+), 8 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 442d4a5..9aa0c03 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,10 @@ services: container_name: nutwatch # USB access: mount the host USB bus and allow USB character devices via # the device cgroup. The cgroup rule is evaluated when devices appear, so - # UPS hotplug works without privileged mode. + # UPS hotplug works without privileged mode. Trust boundary: the container + # can then drive every USB device on the host bus. To scope it down, + # replace the mount with a specific /dev/bus/usb// device when + # your UPS always reappears at the same path. devices: - /dev/bus/usb:/dev/bus/usb device_cgroup_rules: @@ -14,7 +17,9 @@ services: # Share the host network stack: WOL device discovery needs the LAN's ARP # table, and magic-packet broadcasts must reach the physical network. # With host networking, `ports:` is ignored; NutWatch serves directly on - # host ports 8081 (UI) and 3493 (NUT). + # host ports 8081 (UI) and 3493 (NUT). Note: until the first admin + # account is created on the Setup page, the UI is open — run the first + # `docker compose up` on a trusted network and finish setup promptly. network_mode: host volumes: - nutwatch-config:/etc/nut diff --git a/scripts/docker/systemctl-shim.sh b/scripts/docker/systemctl-shim.sh index de474d8..9d9f350 100644 --- a/scripts/docker/systemctl-shim.sh +++ b/scripts/docker/systemctl-shim.sh @@ -36,9 +36,17 @@ driver_running() { } name_pid_alive() { - local pid - pid="$(cat "/var/run/nut/${1}.pid" 2>/dev/null || true)" - [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null + local pidfile pid + # Drivers name their pid file -.pid (e.g. usbhid-ups-myups.pid), + # so match the suffix too; keep the plain .pid form for older setups. + for pidfile in "/var/run/nut/${1}.pid" "/var/run/nut/"*"-${1}.pid"; do + [[ -e "$pidfile" ]] || continue + pid="$(cat "$pidfile" 2>/dev/null || true)" + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + return 0 + fi + done + return 1 } # Dispatch driver lifecycle actions through upsdrvctl. Mirror systemctl: diff --git a/src/backend/services/system.py b/src/backend/services/system.py index 39a956a..06499e6 100644 --- a/src/backend/services/system.py +++ b/src/backend/services/system.py @@ -33,8 +33,10 @@ def _driver_status_units() -> list[str]: ["systemctl", "list-unit-files", "--no-legend", "--no-pager", "nut-driver.service"], timeout=5, ) - if rc == 0 and out.strip(): - return ["nut-driver"] + if rc == 0: + for line in out.splitlines(): + if line.split() and line.split()[0] == "nut-driver.service": + return ["nut-driver"] return [f"nut-driver@{name}" for name in _ups_names()] @@ -69,7 +71,7 @@ def restart_driver(): # through the Docker systemctl shim. rc1, out1, err1 = run_cmd(["upsdrvctl", "stop"], timeout=30) rc2, out2, err2 = run_cmd(["upsdrvctl", "start"], timeout=30) - return rc2, out1 + out2, err1 + err2 + return rc1 or rc2, out1 + out2, err1 + err2 def restart_all(): diff --git a/src/backend/tests/test_services_system.py b/src/backend/tests/test_services_system.py index ec657df..534ca29 100644 --- a/src/backend/tests/test_services_system.py +++ b/src/backend/tests/test_services_system.py @@ -70,6 +70,42 @@ def fake_run(cmd, **kw): assert ["upsdrvctl", "start"] in calls +def test_restart_driver_ignores_template_unit_row(monkeypatch): + # list-unit-files may exit 0 while listing only the nut-driver@.service + # template; only a row whose first field is exactly nut-driver.service + # selects the bare unit. + calls = [] + def fake_run(cmd, **kw): + calls.append(cmd) + if "list-unit-files" in cmd: + return _fake_rc(0, "nut-driver@.service enabled enabled") + return _fake_rc(0) + monkeypatch.setattr("services.system.run_cmd", fake_run) + monkeypatch.setattr("services.system._ups_names", lambda: ["ups1"]) + from services.system import restart_driver + rc, _, _ = restart_driver() + assert rc == 0 + assert ["systemctl", "restart", "nut-driver@ups1"] in calls + + +def test_restart_driver_upsdrvctl_stop_failure_propagates(monkeypatch): + calls = [] + def fake_run(cmd, **kw): + calls.append(cmd) + if "list-unit-files" in cmd: + return _fake_rc(1, "0 unit files listed.") + if cmd == ["upsdrvctl", "stop"]: + return _fake_rc(1, "", "stop failed") + return _fake_rc(0) + monkeypatch.setattr("services.system.run_cmd", fake_run) + monkeypatch.setattr("services.system._ups_names", lambda: []) + from services.system import restart_driver + rc, _, err = restart_driver() + assert rc == 1 + assert "stop failed" in err + assert ["upsdrvctl", "start"] in calls + + def test_restart_all(monkeypatch): calls = [] def fake_run(cmd, **kw):