diff --git a/packages/secubox-appstore/api/__init__.py b/packages/secubox-appstore/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/secubox-appstore/api/main.py b/packages/secubox-appstore/api/main.py new file mode 100644 index 00000000..37ea5b58 --- /dev/null +++ b/packages/secubox-appstore/api/main.py @@ -0,0 +1,167 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +""" +SecuBox-Deb :: secubox-appstore :: catalog API (Phase A — read-only) + +Serves a categorized, tiered, searchable catalog of SecuBox modules by +merging the baked manifest catalog (generated at build from every module's +debian/secubox.yaml) with live runtime state (dpkg installed/version + +systemctl active). Runs unprivileged (user `secubox`): all state queries are +read-only. Install/enable/prefs/profiles are later phases (a root worker). +""" +import os +import json +import time +import subprocess +from pathlib import Path +from typing import Optional + +from fastapi import FastAPI, HTTPException + +CATALOG_FILE = Path(os.environ.get( + "APPSTORE_CATALOG", "/usr/share/secubox/appstore/catalog.json")) +TIER_RANK = {"all": 0, "lite": 1, "standard": 2, "pro": 3} +_STATE_TTL = 30.0 +_state_cache = {"ts": 0.0, "data": {}} + +app = FastAPI(title="secubox-appstore", version="0.1.0", + root_path="/api/v1/appstore") + + +def board_tier() -> str: + """Best-effort board tier; defaults to 'pro' (unlock all) when unset.""" + t = os.environ.get("SECUBOX_TIER") + if t: + return t.strip() + try: + for line in open("/etc/secubox/secubox.conf", encoding="utf-8"): + s = line.strip() + if s.lower().startswith("tier") and "=" in s: + return s.split("=", 1)[1].strip().strip('"').strip("'") + except Exception: + pass + return "pro" + + +def load_catalog() -> list: + try: + return json.loads(CATALOG_FILE.read_text(encoding="utf-8")).get("modules", []) + except Exception: + return [] + + +def _dpkg_state() -> dict: + out = {} + try: + r = subprocess.run( + ["dpkg-query", "-W", "-f=${Package}\t${db:Status-Abbrev}\t${Version}\n", "secubox-*"], + capture_output=True, text=True, timeout=10) + for line in r.stdout.splitlines(): + p = line.split("\t") + if len(p) >= 3: + out[p[0]] = {"installed": p[1].strip().startswith("ii"), "version": p[2].strip()} + except Exception: + pass + return out + + +def _svc_active(names: list) -> dict: + out = {} + if not names: + return out + try: + units = [f"{n}.service" for n in names] + r = subprocess.run(["systemctl", "is-active", *units], + capture_output=True, text=True, timeout=10) + for n, st in zip(names, r.stdout.splitlines()): + out[n] = (st.strip() == "active") + except Exception: + pass + return out + + +def compute_state(force: bool = False) -> dict: + now = time.time() + if not force and _state_cache["data"] and (now - _state_cache["ts"] < _STATE_TTL): + return _state_cache["data"] + catalog = load_catalog() + dpkg = _dpkg_state() + installed_names = [m["name"] for m in catalog if dpkg.get(m["name"], {}).get("installed")] + active = _svc_active(installed_names) + brank = TIER_RANK.get(board_tier(), 2) + result = {} + for m in catalog: + name = m["name"] + d = dpkg.get(name, {}) + installed = bool(d.get("installed")) + running = bool(active.get(name)) + tier = m.get("tier", "lite") + tier_locked = (tier != "all") and (TIER_RANK.get(tier, 1) > brank) + if not installed: + state = "tier-locked" if tier_locked else "available" + elif running: + state = "running" + else: + state = "installed" + result[name] = { + **m, + "installed": installed, + "running": running, + "version": d.get("version"), + "tier_locked": tier_locked, + "state": state, + } + _state_cache["ts"] = now + _state_cache["data"] = result + return result + + +@app.get("/health") +async def health(): + return {"ok": True, "module": "appstore", + "catalog_count": len(load_catalog()), "board_tier": board_tier()} + + +@app.get("/categories") +async def categories(): + st = compute_state() + cats: dict = {} + for m in st.values(): + cats[m["category"]] = cats.get(m["category"], 0) + 1 + return { + "categories": [{"name": k, "count": v} for k, v in sorted(cats.items())], + "tiers": ["lite", "standard", "pro", "all"], + "states": ["available", "installed", "running", "tier-locked"], + "board_tier": board_tier(), + } + + +@app.get("/catalog") +async def catalog(category: Optional[str] = None, tier: Optional[str] = None, + state: Optional[str] = None, q: Optional[str] = None): + st = compute_state() + items = list(st.values()) + if category: + items = [m for m in items if m["category"] == category] + if tier: + items = [m for m in items if m["tier"] == tier] + if state: + items = [m for m in items if m["state"] == state] + if q: + ql = q.lower() + items = [m for m in items + if ql in m["name"].lower() or ql in (m.get("description") or "").lower()] + items.sort(key=lambda m: (m["category"], m["name"])) + return {"modules": items, "count": len(items), "total": len(st), "board_tier": board_tier()} + + +@app.get("/module/{name}") +async def module(name: str): + st = compute_state() + if name not in st: + alt = f"secubox-{name}" + if alt in st: + name = alt + else: + raise HTTPException(status_code=404, detail=f"unknown module {name!r}") + return dict(st[name]) diff --git a/packages/secubox-appstore/debian/changelog b/packages/secubox-appstore/debian/changelog new file mode 100644 index 00000000..962b6c34 --- /dev/null +++ b/packages/secubox-appstore/debian/changelog @@ -0,0 +1,20 @@ +secubox-appstore (0.1.1-1~bookworm1) bookworm; urgency=medium + + * fix(nginx): serve the catalog API from secubox-routes.d/ (the + authoritative /api/v1/ include) instead of secubox.d/, so it wins over + the generic /api/ -> aggregator catch-all. UI stays in secubox.d/. + + -- Gerald KERMA Mon, 29 Jun 2026 18:00:00 +0200 + +secubox-appstore (0.1.0-1~bookworm1) bookworm; urgency=medium + + * Initial release — App Store Phase A (read-only catalog). + - api/main.py: GET /catalog (category/tier/state/q filters), /module/{name}, + /categories, /health; merges the baked manifest catalog with live dpkg + + systemctl state (available / installed / running / tier-locked). + - catalog.json generated at build from every module's debian/secubox.yaml. + - www/appstore: categorized, tiered, searchable grid UI (read-only). + - Served by secubox-appstore.service on /run/secubox/appstore.sock; nginx + routes /api/v1/appstore/ + static /appstore/. No aggregator dependency. + + -- Gerald KERMA Mon, 29 Jun 2026 17:30:00 +0200 diff --git a/packages/secubox-appstore/debian/control b/packages/secubox-appstore/debian/control new file mode 100644 index 00000000..2fcdbb24 --- /dev/null +++ b/packages/secubox-appstore/debian/control @@ -0,0 +1,15 @@ +Source: secubox-appstore +Section: admin +Priority: optional +Maintainer: Gerald KERMA +Build-Depends: debhelper-compat (= 13), python3 +Standards-Version: 4.6.2 + +Package: secubox-appstore +Architecture: all +Depends: ${misc:Depends}, secubox-core (>= 1.0), python3, python3-fastapi | python3-pip, python3-uvicorn | python3-pip +Description: SecuBox App Store — module catalog & lifecycle (Phase A) + Categorized, tiered, searchable catalog of SecuBox modules with live + install/run state, served as a hub web UI. Phase A is read-only; install, + enable/disable, preferences and profiles arrive in later phases via a + privileged worker. diff --git a/packages/secubox-appstore/debian/postinst b/packages/secubox-appstore/debian/postinst new file mode 100755 index 00000000..0789ab57 --- /dev/null +++ b/packages/secubox-appstore/debian/postinst @@ -0,0 +1,14 @@ +#!/bin/sh +set -e +case "$1" in + configure) + install -d -m 0755 /usr/share/secubox/appstore /var/log/secubox /var/lib/secubox 2>/dev/null || true + systemctl daemon-reload 2>/dev/null || true + systemctl enable --now secubox-appstore.service 2>/dev/null || true + if systemctl is-active --quiet nginx 2>/dev/null; then + nginx -t >/dev/null 2>&1 && systemctl reload nginx 2>/dev/null || true + fi + ;; +esac +#DEBHELPER# +exit 0 diff --git a/packages/secubox-appstore/debian/prerm b/packages/secubox-appstore/debian/prerm new file mode 100755 index 00000000..acd1360e --- /dev/null +++ b/packages/secubox-appstore/debian/prerm @@ -0,0 +1,9 @@ +#!/bin/sh +set -e +case "$1" in + remove|deconfigure) + systemctl stop secubox-appstore.service 2>/dev/null || true + ;; +esac +#DEBHELPER# +exit 0 diff --git a/packages/secubox-appstore/debian/rules b/packages/secubox-appstore/debian/rules new file mode 100755 index 00000000..32ef91ef --- /dev/null +++ b/packages/secubox-appstore/debian/rules @@ -0,0 +1,37 @@ +#!/usr/bin/make -f + +%: + dh $@ + +override_dh_auto_install: + # API module (uvicorn api.main:app) + install -d $(CURDIR)/debian/secubox-appstore/usr/lib/secubox/appstore/api + cp -r $(CURDIR)/api/* $(CURDIR)/debian/secubox-appstore/usr/lib/secubox/appstore/api/ + + # Web UI + install -d $(CURDIR)/debian/secubox-appstore/usr/share/secubox/www/appstore + cp -r $(CURDIR)/www/appstore/* $(CURDIR)/debian/secubox-appstore/usr/share/secubox/www/appstore/ + + # nginx route + static + install -d $(CURDIR)/debian/secubox-appstore/etc/nginx/secubox.d + install -m 644 $(CURDIR)/nginx/appstore.conf $(CURDIR)/debian/secubox-appstore/etc/nginx/secubox.d/ + + # Hub menu entry + install -d $(CURDIR)/debian/secubox-appstore/etc/secubox/menu.d + install -m 644 $(CURDIR)/menu.d/580-appstore.json $(CURDIR)/debian/secubox-appstore/etc/secubox/menu.d/ + + # systemd service + install -d $(CURDIR)/debian/secubox-appstore/usr/lib/systemd/system + install -m 644 $(CURDIR)/debian/secubox-appstore.service $(CURDIR)/debian/secubox-appstore/usr/lib/systemd/system/ + + # Authoritative API route (secubox-routes.d) + install -d $(CURDIR)/debian/secubox-appstore/etc/nginx/secubox-routes.d + install -m 644 $(CURDIR)/nginx/appstore-routes.conf $(CURDIR)/debian/secubox-appstore/etc/nginx/secubox-routes.d/ + + # Generate the catalog index from every sibling module's debian/secubox.yaml + install -d $(CURDIR)/debian/secubox-appstore/usr/share/secubox/appstore + python3 $(CURDIR)/scripts/gen-appstore-catalog.py \ + $(CURDIR)/debian/secubox-appstore/usr/share/secubox/appstore/catalog.json + + # Runtime dir + install -d $(CURDIR)/debian/secubox-appstore/run/secubox diff --git a/packages/secubox-appstore/debian/secubox-appstore.service b/packages/secubox-appstore/debian/secubox-appstore.service new file mode 100644 index 00000000..0f51df21 --- /dev/null +++ b/packages/secubox-appstore/debian/secubox-appstore.service @@ -0,0 +1,25 @@ +[Unit] +Description=SecuBox App Store — module catalog API +After=network.target secubox-core.service +Requires=secubox-core.service + +[Service] +Type=simple +User=secubox +Group=secubox +RuntimeDirectory=secubox +RuntimeDirectoryPreserve=yes +ExecStart=/usr/bin/python3 -m uvicorn api.main:app --uds /run/secubox/appstore.sock --workers 1 +WorkingDirectory=/usr/lib/secubox/appstore +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +NoNewPrivileges=yes +ProtectHome=yes +PrivateTmp=yes +ReadWritePaths=/run/secubox /var/log/secubox /var/lib/secubox + +[Install] +WantedBy=multi-user.target diff --git a/packages/secubox-appstore/debian/secubox.yaml b/packages/secubox-appstore/debian/secubox.yaml new file mode 100644 index 00000000..82809723 --- /dev/null +++ b/packages/secubox-appstore/debian/secubox.yaml @@ -0,0 +1,15 @@ +# debian/secubox.yaml +name: secubox-appstore +category: system +tier: all +description: "SecuBox App Store — module catalog, install/enable, profiles" + +depends: + - secubox-core + +api: + socket: /run/secubox/appstore.sock + health: /api/v1/appstore/health + +ui: + path: /appstore/ diff --git a/packages/secubox-appstore/menu.d/580-appstore.json b/packages/secubox-appstore/menu.d/580-appstore.json new file mode 100644 index 00000000..1171afa5 --- /dev/null +++ b/packages/secubox-appstore/menu.d/580-appstore.json @@ -0,0 +1,10 @@ +{ + "id": "appstore", + "name": "App Store", + "icon": "🛍️", + "path": "/appstore/", + "category": "system", + "order": 580, + "description": "Install, enable & configure SecuBox modules", + "requires": ["secubox-appstore"] +} diff --git a/packages/secubox-appstore/nginx/appstore-routes.conf b/packages/secubox-appstore/nginx/appstore-routes.conf new file mode 100644 index 00000000..0a53871b --- /dev/null +++ b/packages/secubox-appstore/nginx/appstore-routes.conf @@ -0,0 +1,9 @@ +# /etc/nginx/secubox-routes.d/appstore.conf — Installed by secubox-appstore. +# App Store catalog API, served by the standalone secubox-appstore.service +# over /run/secubox/appstore.sock (no aggregator dependency). +location /api/v1/appstore/ { + rewrite ^/api/v1/appstore/(.*)$ /$1 break; + proxy_pass http://unix:/run/secubox/appstore.sock; + include /etc/nginx/snippets/secubox-proxy.conf; + proxy_intercept_errors on; +} diff --git a/packages/secubox-appstore/nginx/appstore.conf b/packages/secubox-appstore/nginx/appstore.conf new file mode 100644 index 00000000..dd649843 --- /dev/null +++ b/packages/secubox-appstore/nginx/appstore.conf @@ -0,0 +1,9 @@ +# /etc/nginx/secubox.d/appstore.conf — Installed by secubox-appstore. +# Static App Store UI. The /api/v1/appstore/ route lives in +# secubox-routes.d/appstore.conf (the authoritative API include). + +location /appstore/ { + alias /usr/share/secubox/www/appstore/; + index index.html; + try_files $uri $uri/ /appstore/index.html; +} diff --git a/packages/secubox-appstore/scripts/gen-appstore-catalog.py b/packages/secubox-appstore/scripts/gen-appstore-catalog.py new file mode 100755 index 00000000..64b8c7cd --- /dev/null +++ b/packages/secubox-appstore/scripts/gen-appstore-catalog.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +"""SecuBox-Deb :: secubox-appstore :: catalog generator. + +Scan every module's debian/secubox.yaml in the monorepo and emit a flat +catalog.json (the App Store's "available" set). Run at package build time +from packages/secubox-appstore (siblings live at ../*/debian/secubox.yaml). +Dependency-free: parses the small flat manifests without PyYAML. +""" +import json, sys, glob, os, re + +def parse_manifest(path): + m = {"depends": []} + in_depends = False + for raw in open(path, encoding="utf-8", errors="replace"): + line = raw.rstrip("\n") + if not line.strip() or line.lstrip().startswith("#"): + continue + # top-level key: value (no leading space) + mt = re.match(r"^([a-z_]+):\s*(.*)$", line) + if mt: + key, val = mt.group(1), mt.group(2).strip() + in_depends = (key == "depends") + if key in ("name", "category", "tier", "description") and val: + m[key] = val.strip().strip('"').strip("'") + continue + # list item under depends: + li = re.match(r"^\s+-\s+(.*)$", line) + if li and in_depends: + m["depends"].append(li.group(1).strip().strip('"').strip("'")) + return m + +def main(out): + base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") + catalog = [] + for path in sorted(glob.glob(os.path.join(base, "*", "debian", "secubox.yaml"))): + # skip generated build-tree copies (debian//...) + if "/debian/" in path and path.count("/debian/") > 1: + continue + m = parse_manifest(path) + if not m.get("name"): + continue + catalog.append({ + "name": m["name"], + "category": m.get("category", "misc"), + "tier": m.get("tier", "lite"), + "description": m.get("description", ""), + "depends": m.get("depends", []), + }) + # de-dup by name (keep first) + seen, uniq = set(), [] + for c in catalog: + if c["name"] in seen: + continue + seen.add(c["name"]); uniq.append(c) + data = {"version": 1, "modules": uniq, "count": len(uniq)} + with open(out, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + print(f"wrote {out}: {len(uniq)} modules") + +if __name__ == "__main__": + main(sys.argv[1] if len(sys.argv) > 1 else "catalog.json") diff --git a/packages/secubox-appstore/www/appstore/index.html b/packages/secubox-appstore/www/appstore/index.html new file mode 100644 index 00000000..f9fba670 --- /dev/null +++ b/packages/secubox-appstore/www/appstore/index.html @@ -0,0 +1,136 @@ + + + + + + SecuBox · App Store + + + + + +
+

⬢ SecuBox App Store

+ module catalog · install / enable / configure — Phase A: read-only +
+ +
+ + + + + loading… +
+ +
Loading catalog…
+ + + + diff --git a/packages/secubox-lyrion/debian/secubox.yaml b/packages/secubox-lyrion/debian/secubox.yaml new file mode 100644 index 00000000..d051bf35 --- /dev/null +++ b/packages/secubox-lyrion/debian/secubox.yaml @@ -0,0 +1,15 @@ +# debian/secubox.yaml +name: secubox-lyrion +category: media +tier: lite +description: "Lyrion Music Server (LMS / Squeezebox) in a Debian LXC" + +depends: + - secubox-core + +api: + socket: /run/secubox/aggregator.sock + health: /api/v1/lyrion/health + +ui: + path: /lyrion/