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"| Uptime | {html.escape(host['uptime'])} |
",
+ f"| Load (1m,5m,15m) | "
+ f"{host['load1']}, {host['load5']}, {host['load15']} |
",
+ f"| Nextbox Package | {html.escape(nextbox_version)} |
",
+ "
",
+ # Services
+ "Service Status
| Service | Status |
"
+ ]
+ for svc, st in services.items():
+ cls = "error" if st.lower() != "active" else ""
+ parts.append(
+ f"| {html.escape(svc)} | "
+ f"{html.escape(st)} |
"
+ )
+ parts.append("
")
+
+ # Journal Matches
+ parts.append("Journal Pattern Checks
")
+ parts.append("| Check | Status | Details |
")
+ for label, (ok, message) in journal_checks.items():
+ cls = "" if ok else "error"
+ status = "OK" if ok else "ERROR"
+ parts.append(
+ f""
+ f"| {html.escape(label)} | "
+ f"{status} | "
+ f"{html.escape(message)} | "
+ f"
"
+ )
+ parts.append("
")
+
+ 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