diff --git a/debian/Makefile b/debian/Makefile index 22dd77b0..0049b6f2 100644 --- a/debian/Makefile +++ b/debian/Makefile @@ -21,6 +21,7 @@ unstable: -v $(shell pwd):/build \ -v $(shell pwd)/../app:/app \ -v $(shell pwd)/../daemon:/daemon \ + -v $(shell pwd)/../janitor:/janitor \ -p 8080:80 \ $(IMAGE_NAME):stable make PKG=nextbox-unstable GIT_TAG=main main-target @@ -33,6 +34,7 @@ testing: -v $(shell pwd):/build \ -v $(shell pwd)/../app:/app \ -v $(shell pwd)/../daemon:/daemon \ + -v $(shell pwd)/../janitor:/janitor \ -p 8080:80 \ $(IMAGE_NAME):stable make PKG=nextbox-testing GIT_TAG=main main-target @@ -45,6 +47,7 @@ stable: -v $(shell pwd):/build \ -v $(shell pwd)/../app:/app \ -v $(shell pwd)/../daemon:/daemon \ + -v $(shell pwd)/../janitor:/janitor \ -p 8080:80 \ $(IMAGE_NAME):stable make PKG=nextbox GIT_TAG=main main-target @@ -57,7 +60,7 @@ clean: make PKG=nextbox GIT_TAG=main deb-clean -main-target: $(PKG)/app $(PKG)/nextbox_daemon $(PKG)/scripts $(PKG)/templates $(PKG)/nextbox-compose $(PKG)/debian $(PKG)/rtun-linux-arm64 deb-src +main-target: $(PKG)/janitor $(PKG)/app $(PKG)/nextbox_daemon $(PKG)/scripts $(PKG)/templates $(PKG)/nextbox-compose $(PKG)/debian $(PKG)/rtun-linux-arm64 deb-src $(PKG)/debian: debian @@ -70,7 +73,7 @@ $(PKG)/debian: debian cp repos/daemon/services/nextbox.reverse-tunnel.service $(PKG)/debian/$(PKG).reverse-tunnel.service cp repos/daemon/services/nextbox.nextbox-factory-reset.service $(PKG)/debian/$(PKG).nextbox-factory-reset.service cp repos/daemon/services/nextbox.nextbox-updater.service $(PKG)/debian/$(PKG).nextbox-updater.service - + cp janitor/nextbox_status_page.service $(PKG)/debian/$(PKG).nextbox_status_page.service $(PKG)/app: repos/app/nextbox/js/nextbox-main.js mkdir -p $(PKG)/app/nextbox @@ -107,6 +110,11 @@ $(PKG)/templates: repos/daemon/templates $(PKG)/debian mkdir -p $(PKG)/templates cp repos/daemon/templates/* $(PKG)/templates + +$(PKG)/janitor: repos/janitor $(PKG)/debian + mkdir -p $(PKG)/janitor + cp repos/janitor/* $(PKG)/janitor + $(PKG)/rtun-linux-arm64: $(PKG)/debian cd $(PKG) && \ wget https://github.com/snsinfu/reverse-tunnel/releases/download/v1.3.0/rtun-linux-arm64 @@ -126,6 +134,7 @@ start-dev-docker: dev-image -v $(shell pwd):/build \ -v $(shell pwd)/../app:/app \ -v $(shell pwd)/../daemon:/daemon \ + -v $(shell pwd)/../janitor:/janitor \ -p 8080:80 \ $(IMAGE_NAME):stable @@ -140,7 +149,7 @@ dev-image: Dockerfile ### build source package ### -$(DEB_SRC): $(PKG)/app $(PKG)/nextbox_daemon $(PKG)/nextbox-compose $(PKG)/debian +$(DEB_SRC): $(PKG)/janitor $(PKG)/app $(PKG)/nextbox_daemon $(PKG)/nextbox-compose $(PKG)/debian cd $(PKG) && \ dpkg-buildpackage -S #debsign -k CBF5C9FD2105C32B1E9CDC2C0303797FE98B51CD nextbox_$(VERSION)_source.changes @@ -158,7 +167,7 @@ deb-clean: rm -f $(PKG)_$(VERSION).dsc rm -f $(PKG)_$(VERSION).tar.gz -deb-src: $(PKG)/app $(PKG)/nextbox_daemon $(PKG)/nextbox-compose $(PKG)/debian +deb-src: $(PKG)/janitor $(PKG)/app $(PKG)/nextbox_daemon $(PKG)/nextbox-compose $(PKG)/debian make PKG=$(PKG) GIT_TAG=$(GIT_TAG) $(DEB_SRC) make PKG=$(PKG) GIT_TAG=$(GIT_TAG) upload diff --git a/debian/debian/install b/debian/debian/install index 009d5832..dac938b7 100644 --- a/debian/debian/install +++ b/debian/debian/install @@ -14,3 +14,4 @@ scripts/nextbox-desec-hook.sh /usr/bin scripts/nextbox-update-debian.sh /usr/bin scripts/nextbox-soft-reset.sh /usr/bin templates/* /usr/lib/nextbox-templates +janitor/nextbox_status_page.py /usr/bin/ diff --git a/debian/debian/rules b/debian/debian/rules index 78a6f790..f6eadb9a 100755 --- a/debian/debian/rules +++ b/debian/debian/rules @@ -15,3 +15,4 @@ override_dh_installsystemd: dh_installsystemd --name=reverse-tunnel --no-start --no-enable dh_installsystemd --name=nextbox-factory-reset --no-start --no-enable dh_installsystemd --name=nextbox-updater --no-start --no-enable --no-stop-on-upgrade + dh_installsystemd --name=nextbox_status_page diff --git a/janitor/nextbox_status_page.py b/janitor/nextbox_status_page.py new file mode 100644 index 00000000..0605bfcc --- /dev/null +++ b/janitor/nextbox_status_page.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +import subprocess +import socket +import datetime +import os +import html +import shutil +import signal +import sys +import re +import threading +from http.server import HTTPServer, BaseHTTPRequestHandler + +# ------------------------------------------------------------------- +# CONFIGURATION +# ------------------------------------------------------------------- + +OUTPUT_HTML = "/var/www/html/status.html" +HTTP_PORT = 8080 +REFRESH_INTERVAL = 60 + +JOURNAL_PATTERNS = [ + ("UAS is ignored for this device, using usb-storage instead", True, "UAS not used warning, pressent"), + ("Can't start Nextcloud because upgrading", False, "No nextcloud upgrade block"), +] + +# Note: Added nextbox-daemon.server here +SERVICES = [ + "sshd.service", + "networking.service", + "docker.service", + "nextbox-daemon.service", +] + +# ------------------------------------------------------------------- +# HELPERS +# ------------------------------------------------------------------- + +def run_cmd(cmd): + p = subprocess.run(cmd, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + return p.stdout.strip(), p.stderr.strip(), p.returncode + +def get_package_version(pkg_name): + """ + Returns the installed version of the given Debian package, + or an error string if the package is not installed. + """ + # dpkg-query exits non-zero if the package is not installed + out, err, rc = run_cmd(["dpkg-query", "-W", "-f=${Version}", pkg_name]) + if rc == 0 and out: + return out + elif rc == 0: + return "(no version string returned)" + else: + return f"(not installed or error: {err or out})" + +def get_host_info(): + """Gather hostname, uptime, and load averages (no disk anymore).""" + hostname = socket.gethostname() + + up_out, up_err, up_rc = run_cmd(["uptime", "-p"]) + uptime = up_out if up_rc == 0 else f"error: {up_err}" + + la_out, la_err, la_rc = run_cmd(["cat", "/proc/loadavg"]) + if la_rc == 0: + load1, load5, load15 = la_out.split()[:3] + else: + load1 = load5 = load15 = f"error: {la_err}" + + return { + "hostname": hostname, + "uptime": uptime, + "load1": load1, + "load5": load5, + "load15": load15, + } + +def check_journal_boot(patterns): + """ + Scan the entire journal of the current boot for each pattern. + Patterns should be an iterable of: + - (string, bool) => (pattern, should_exist) + - (string, bool, string) => (pattern, should_exist, label) + - bare string (=> must exist, label=pattern) + + Returns a dict mapping each label to (ok:bool, message:str). + """ + # Normalize into triplets (pattern, should_exist, label) + normalized = [] + for p in patterns: + if isinstance(p, tuple): + if len(p) == 2: + pattern, should_exist = p + label = pattern + elif len(p) == 3: + pattern, should_exist, label = p + else: + raise ValueError("Pattern tuples must be (pat, bool[, label])") + elif isinstance(p, str): + pattern, should_exist, label = p, True, p + else: + raise ValueError("Each pattern entry must be str or tuple") + + normalized.append((pattern, bool(should_exist), str(label))) + + # Grab whole journal for this boot + cmd = ["journalctl", "-b", "--no-pager", "--output=short-iso"] + out, err, rc = run_cmd(cmd) + if rc != 0: + # mark all as FAIL + return { + label: (False, f"journalctl exit code {rc}, err={err}") + for _, _, label in normalized + } + + text = out.lower() + results = {} + for pattern, should_exist, label in normalized: + found = (pattern.lower() in text) + if should_exist: + if found: + results[label] = (True, f"Found required pattern") + else: + results[label] = (False, f"Missing required pattern") + else: + if found: + results[label] = (False, f"Forbidden pattern was found") + else: + results[label] = (True, f"Forbidden pattern not present") + return results + +def check_services(services): + status = {} + for svc in services: + out, err, rc = run_cmd(["systemctl", "is-active", svc]) + if rc == 0: + status[svc] = out + else: + status[svc] = (out or err or "unknown").strip() + return status + +def render_html(host, nextbox_version, journal_checks, services): + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + parts = [ + "", + "", + f"Status of {html.escape(host['hostname'])}", + "", + f"

Status for {html.escape(host['hostname'])}

", + f"

Generated: {now}

", + # Host Overview (no disk) + "

Host Overview

", + f"", + f"" + f"", + f"", + "
Uptime{html.escape(host['uptime'])}
Load (1m,5m,15m){host['load1']}, {host['load5']}, {host['load15']}
Nextbox Package{html.escape(nextbox_version)}
", + # Services + "

Service Status

" + ] + for svc, st in services.items(): + cls = "error" if st.lower() != "active" else "" + parts.append( + f"" + f"" + ) + parts.append("
ServiceStatus
{html.escape(svc)}{html.escape(st)}
") + + # Journal Matches + parts.append("

Journal Pattern Checks

") + parts.append("") + for label, (ok, message) in journal_checks.items(): + cls = "" if ok else "error" + status = "OK" if ok else "ERROR" + parts.append( + f"" + f"" + f"" + f"" + f"" + ) + parts.append("
CheckStatusDetails
{html.escape(label)}{status}{html.escape(message)}
") + + return "\n".join(parts) + +# ------------------------------------------------------------------- +# MAIN LOOP + HTTP SERVER +# ------------------------------------------------------------------- + +def generate_page(): + host = get_host_info() + nb_ver = get_package_version("nextbox") + journal_checks = check_journal_boot(JOURNAL_PATTERNS) + svcs = check_services(SERVICES) + page = render_html(host, nb_ver, journal_checks, svcs) + + os.makedirs(os.path.dirname(OUTPUT_HTML), exist_ok=True) + tmp = OUTPUT_HTML + ".tmp" + with open(tmp, "w") as f: + f.write(page) + os.replace(tmp, OUTPUT_HTML) + print(f"[{datetime.datetime.now()}] Updated {OUTPUT_HTML}", flush=True) + +class StatusHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path in ("/", "/status.html"): + try: + with open(OUTPUT_HTML, "rb") as f: + data = f.read() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + except FileNotFoundError: + self.send_error(404, "Status page not found") + except Exception as e: + self.send_error(500, f"Error: {e}") + else: + self.send_error(404, "Not Found") + +def serve(): + srv = HTTPServer(("0.0.0.0", HTTP_PORT), StatusHandler) + print(f"Serving on port {HTTP_PORT}", flush=True) + srv.serve_forever() + +def shutdown(signum, frame): + print("Shutting down...", flush=True) + sys.exit(0) + +if __name__ == "__main__": + signal.signal(signal.SIGTERM, shutdown) + generate_page() + + # background refresher + def refresher(): + import time + while True: + time.sleep(REFRESH_INTERVAL) + generate_page() + threading.Thread(target=refresher, daemon=True).start() + + serve() + diff --git a/janitor/nextbox_status_page.service b/janitor/nextbox_status_page.service new file mode 100644 index 00000000..89719851 --- /dev/null +++ b/janitor/nextbox_status_page.service @@ -0,0 +1,14 @@ +[Unit] +Description=Static HTML Device Status + HTTP Server +After=network.target + +[Service] +Type=simple +User=root +ExecStart=/usr/bin/nextbox_status_page.py +Restart=on-failure +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target