Merge branch 'feature/appstore-phase-a' — App Store Phase A (read-only catalog)
Some checks are pending
License Headers / check (push) Waiting to run

This commit is contained in:
CyberMind-FR 2026-06-29 13:20:49 +02:00
commit 8f46bcb93b
15 changed files with 544 additions and 0 deletions

View File

@ -0,0 +1,167 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""
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])

View File

@ -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 <devel@cybermind.fr> 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 <devel@cybermind.fr> Mon, 29 Jun 2026 17:30:00 +0200

View File

@ -0,0 +1,15 @@
Source: secubox-appstore
Section: admin
Priority: optional
Maintainer: Gerald KERMA <devel@cybermind.fr>
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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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/

View File

@ -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"]
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,63 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""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/<pkg>/...)
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")

View File

@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SecuBox · App Store</title>
<link rel="stylesheet" href="/shared/design-tokens.css">
<link rel="stylesheet" href="/shared/crt-light.css">
<style>
:root{
--bg:#0a0a0f; --panel:#13131c; --line:#2a2a3a; --gold:#c9a84c;
--cyan:#00d4ff; --green:#00ff41; --muted:#6b6b7a; --text:#e8e6d9; --cinnabar:#e63946;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);
font-family:'JetBrains Mono',ui-monospace,monospace;}
header{padding:18px 24px;border-bottom:1px solid var(--line);
display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;}
header h1{font-family:'Cinzel',serif;color:var(--gold);margin:0;font-size:22px;letter-spacing:1px;}
header .sub{color:var(--muted);font-size:13px}
.bar{display:flex;gap:10px;flex-wrap:wrap;padding:14px 24px;border-bottom:1px solid var(--line);align-items:center}
.bar input,.bar select{background:var(--panel);border:1px solid var(--line);color:var(--text);
padding:8px 10px;border-radius:6px;font-family:inherit;font-size:13px}
.bar input{min-width:220px}
.stat{color:var(--muted);font-size:12px;margin-left:auto}
.stat b{color:var(--cyan)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px;padding:24px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px;
display:flex;flex-direction:column;gap:8px;transition:border-color .15s}
.card:hover{border-color:var(--gold)}
.card .top{display:flex;align-items:center;gap:10px}
.card .icon{font-size:26px;line-height:1}
.card .name{font-weight:700;color:var(--text);font-size:14px;word-break:break-word}
.card .desc{color:var(--muted);font-size:12px;min-height:32px;line-height:1.4}
.badges{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
.pill{font-size:10px;padding:2px 8px;border-radius:20px;border:1px solid var(--line);text-transform:uppercase;letter-spacing:.5px}
.pill.cat{color:var(--cyan);border-color:#1d4a55}
.pill.tier{color:var(--gold);border-color:#5a4a1d}
.pill.state-running{color:var(--green);border-color:#1d5a2a;background:rgba(0,255,65,.08)}
.pill.state-installed{color:var(--cyan);border-color:#1d4a55}
.pill.state-available{color:var(--muted)}
.pill.state-tier-locked{color:var(--cinnabar);border-color:#5a1d23}
.row{display:flex;justify-content:space-between;align-items:center;margin-top:auto}
.btn{background:transparent;border:1px solid var(--line);color:var(--muted);
padding:6px 12px;border-radius:6px;font-family:inherit;font-size:12px;cursor:not-allowed}
.ver{color:var(--muted);font-size:11px}
.empty{color:var(--muted);padding:40px;text-align:center;grid-column:1/-1}
</style>
</head>
<body>
<header>
<h1>⬢ SecuBox App Store</h1>
<span class="sub">module catalog · install / enable / configure — <em>Phase A: read-only</em></span>
</header>
<div class="bar">
<input id="q" type="search" placeholder="Search modules…" oninput="render()">
<select id="category" onchange="render()"><option value="">All categories</option></select>
<select id="tier" onchange="render()">
<option value="">All tiers</option><option>lite</option><option>standard</option><option>pro</option><option>all</option>
</select>
<select id="state" onchange="render()">
<option value="">All states</option><option value="running">running</option>
<option value="installed">installed</option><option value="available">available</option>
<option value="tier-locked">tier-locked</option>
</select>
<span class="stat" id="stat">loading…</span>
</div>
<div class="grid" id="grid"><div class="empty">Loading catalog…</div></div>
<script>
const API = '/api/v1/appstore';
const ICONS = {media:'🎬',email:'✉️',ai:'🧠',iot:'🛰️',communication:'💬',publishing:'📰',
network:'🌐',security:'🛡️',system:'⚙️',vpn:'🔒',dashboard:'📊',monitoring:'📈',misc:'🧩'};
let ALL = [];
async function getJSON(p){ try{const r=await fetch(API+p);if(!r.ok)throw 0;return await r.json();}catch(e){return null;} }
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':s;return d.innerHTML;}
async function load(){
const cats = await getJSON('/categories');
if(cats && cats.categories){
const sel=document.getElementById('category');
cats.categories.forEach(c=>{const o=document.createElement('option');o.value=c.name;
o.textContent=`${c.name} (${c.count})`;sel.appendChild(o);});
}
const data = await getJSON('/catalog');
ALL = (data && Array.isArray(data.modules)) ? data.modules : [];
render(cats && cats.board_tier);
}
function render(boardTier){
const q=document.getElementById('q').value.toLowerCase();
const cat=document.getElementById('category').value;
const tier=document.getElementById('tier').value;
const state=document.getElementById('state').value;
let items = ALL.filter(m=>{
if(cat && m.category!==cat) return false;
if(tier && m.tier!==tier) return false;
if(state && m.state!==state) return false;
if(q && !(m.name.toLowerCase().includes(q) || (m.description||'').toLowerCase().includes(q))) return false;
return true;
});
const grid=document.getElementById('grid');
const running = ALL.filter(m=>m.state==='running').length;
const installed = ALL.filter(m=>m.installed).length;
document.getElementById('stat').innerHTML =
`showing <b>${items.length}</b> / ${ALL.length} · installed <b>${installed}</b> · running <b>${running}</b>`;
if(!items.length){ grid.innerHTML='<div class="empty">No modules match.</div>'; return; }
grid.innerHTML = items.map(m=>{
const icon = ICONS[m.category]||ICONS.misc;
const label = m.name.replace(/^secubox-/,'');
const st = m.state||'available';
const btn = st==='running'?'Running' : st==='installed'?'Installed' :
st==='tier-locked'?'Tier locked' : 'Install';
return `<div class="card">
<div class="top"><span class="icon">${icon}</span><span class="name">${esc(label)}</span></div>
<div class="desc">${esc(m.description||'')}</div>
<div class="badges">
<span class="pill cat">${esc(m.category)}</span>
<span class="pill tier">${esc(m.tier)}</span>
<span class="pill state-${st}">${st}</span>
</div>
<div class="row">
<span class="ver">${m.version?('v'+esc(m.version)):''}</span>
<button class="btn" title="Lifecycle actions arrive in Phase C" disabled>${btn}</button>
</div>
</div>`;
}).join('');
}
document.addEventListener('DOMContentLoaded', load);
</script>
</body>
</html>

View File

@ -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/