From 538b71596cfec89744322703d9b92553d097fa75 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Thu, 24 Sep 2026 13:16:47 +0500 Subject: [PATCH] init --- README.md | 87 ++++++++++++++++++++++++++++++++++++++++ jsonnutserver.service | 23 +++++++++++ server.py | 92 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 README.md create mode 100644 jsonnutserver.service create mode 100644 server.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..1bae682 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# jsonnutserver + +Small HTTP server that runs `upsc ` and returns the result as JSON. + +``` +GET /jsonstatus?=nutdev1@localhost +``` + +## Requirements + +- Python 3 (standard library only) +- `upsc` from the NUT client package, and a reachable `upsd` + +```bash +# Debian/Ubuntu +sudo apt install python3 nut-client +# Fedora/RHEL +sudo dnf install python3 nut-client +``` + +Check that `upsc` works before going further: + +```bash +upsc nutdev1@localhost +``` + +## Install to /opt + +```bash +sudo mkdir -p /opt/jsonnutserver +sudo install -m 0755 server.py /opt/jsonnutserver/server.py +``` + +## systemd service + +```bash +sudo install -m 0644 jsonnutserver.service /etc/systemd/system/jsonnutserver.service +sudo systemctl daemon-reload +sudo systemctl enable --now jsonnutserver.service +``` + +The unit runs `/usr/bin/python3 /opt/jsonnutserver/server.py` as an unprivileged +`DynamicUser` and restarts it if it crashes. If `python3` or `upsc` lives +somewhere else on your system, adjust `ExecStart` (check with `command -v python3`). + +### Change the listen address or port + +The server reads `LISTEN_HOST` (default `0.0.0.0`) and `LISTEN_PORT` +(default `8000`). Override them without editing the unit file: + +```bash +sudo systemctl edit jsonnutserver.service +``` + +```ini +[Service] +Environment=LISTEN_HOST=127.0.0.1 +Environment=LISTEN_PORT=9000 +``` + +```bash +sudo systemctl restart jsonnutserver.service +``` + +## Check it + +```bash +systemctl status jsonnutserver.service +journalctl -u jsonnutserver.service -f +curl 'http://localhost:8000/jsonstatus?=nutdev1@localhost' +``` + +## Update + +```bash +sudo install -m 0755 server.py /opt/jsonnutserver/server.py +sudo systemctl restart jsonnutserver.service +``` + +## Uninstall + +```bash +sudo systemctl disable --now jsonnutserver.service +sudo rm /etc/systemd/system/jsonnutserver.service +sudo systemctl daemon-reload +sudo rm -r /opt/jsonnutserver +``` diff --git a/jsonnutserver.service b/jsonnutserver.service new file mode 100644 index 0000000..d246690 --- /dev/null +++ b/jsonnutserver.service @@ -0,0 +1,23 @@ +[Unit] +Description=JSON HTTP API for NUT upsc +After=network-online.target nut-server.service +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /opt/jsonnutserver/server.py +Environment=LISTEN_HOST=0.0.0.0 +Environment=LISTEN_PORT=8000 +Environment=PYTHONUNBUFFERED=1 +Restart=on-failure +RestartSec=5 + +# runs as a throwaway unprivileged user; upsc only needs network access to upsd +DynamicUser=yes +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes + +[Install] +WantedBy=multi-user.target diff --git a/server.py b/server.py new file mode 100644 index 0000000..732f6ce --- /dev/null +++ b/server.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Expose `upsc ` as JSON: GET /jsonstatus?=nutdev1@localhost""" +import json +import os +import re +import subprocess +from urllib.parse import unquote +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +# upsname[@hostname[:port]] -- no leading '-', so it can't be parsed as a upsc flag +UPS_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]*(@[A-Za-z0-9_.\[\]:-]+)?$") +HOST = os.environ.get("LISTEN_HOST", "0.0.0.0") +PORT = int(os.environ.get("LISTEN_PORT", "8000")) + +# keys that look numeric but must stay strings (leading zeros / identifiers) +KEEP_STRING = ("firmware", "productid", "vendorid", "model", "version") + + +def convert(key, value): + if any(k in key for k in KEEP_STRING): + return value + try: + f = float(value) + return int(f) if f.is_integer() and "." not in value else f + except ValueError: + return value + + +def read_ups(ups): + proc = subprocess.run( + ["upsc", ups], capture_output=True, text=True, timeout=10 + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or f"upsc exited {proc.returncode}") + + data = {} + for line in proc.stdout.splitlines(): + if ": " not in line: + continue + key, value = line.split(": ", 1) + key = key.strip() + if key.startswith("Init SSL"): + continue + data[key] = convert(key, value.strip()) + + if "ups.status" not in data: + raise RuntimeError("no ups.status in upsc output") + + status = str(data["ups.status"]) + return { + "ups": ups, + "online": "OL" in status.split(), + "on_battery": "OB" in status.split(), + "low_battery": "LB" in status.split(), + "status": status, + "charge": data.get("battery.charge"), + "load": data.get("ups.load"), + "input_voltage": data.get("input.voltage"), + "raw": data, + } + + +class Handler(BaseHTTPRequestHandler): + def _send(self, code, payload): + body = json.dumps(payload, indent=2).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path, _, query = self.path.partition("?") + if path != "/jsonstatus": + return self._send(404, {"error": "not found"}) + ups = unquote(query).lstrip("=").strip() + if not ups: + return self._send(400, {"error": "usage: /jsonstatus?=upsname@host"}) + if not UPS_RE.match(ups): + return self._send(400, {"error": f"invalid ups name: {ups!r}"}) + try: + self._send(200, read_ups(ups)) + except Exception as e: # upsc missing, timeout, NUT down + self._send(503, {"ups": ups, "online": False, "error": str(e)}) + + def log_message(self, fmt, *args): + pass # keep journal quiet; remove to log every request + + +if __name__ == "__main__": + print(f"listening on http://{HOST}:{PORT}/jsonstatus?=ups@host", flush=True) + ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()