init
This commit is contained in:
@@ -0,0 +1,87 @@
|
|||||||
|
# jsonnutserver
|
||||||
|
|
||||||
|
Small HTTP server that runs `upsc <ups>` 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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Expose `upsc <ups>` 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()
|
||||||
Reference in New Issue
Block a user