Compare commits

...

12 Commits

Author SHA1 Message Date
8905228cbd fix(round): restore ICON_PATHS /var/www/common + defense-in-depth icon copy
Some checks failed
License Headers / check (push) Failing after 6s
These two edits were on fix/round-real-root-icons (commit a5b312f7) but
got wiped by my earlier force-push to fix/round-armv6-boot-services.
Re-applying identically so v4 picks them up.

fallback_manager.py: add /var/www/common/assets/icons as the third
lookup path so the 6 brand icons (auth/wall/boot/mind/root/mesh) ship
via common/ are found. Without this, fallback_manager only sees the
round/-side placeholder set and can't render the canonical module
icons on the fallback radar.

build-eye-remote-image.sh: also copy common/assets/icons/*.png into
/usr/lib/secubox-eye/assets/icons/ (cp -n so placeholders aren't
overwritten when names collide — none do). Pure defense-in-depth.
2026-05-18 06:25:41 +02:00
CyberMind
9a0a9873a7
fix(secubox-metrics): VisitorOrigin entries=[] forever — CAP_NET_ADMIN + DB-IP fallback (closes #194) (#195)
Two latent bugs since #92 that together masked VisitorOrigin:

1. Missing CAP_NET_ADMIN
   The aggregator runs as User=secubox and calls
     nft -j list set inet secubox_metrics seen_src
   which requires CAP_NET_ADMIN. EPERM, subprocess silently returns
   no output, _read_nft_set returns [], entries stays []. Added:
     AmbientCapabilities=CAP_NET_ADMIN
     CapabilityBoundingSet=CAP_NET_ADMIN
   Coexists with NoNewPrivileges=true (systemd sets the ambient set
   before the User= drop).

2. MaxMind license barrier
   secubox-geoipupdate.service had:
     ConditionPathExists=/etc/secubox/secrets/maxmind.conf
   So operators without a MaxMind account couldn't activate VisitorOrigin
   at all. Replaced with a new fetcher /usr/bin/secubox-geoipupdate-fetch
   that:
     - Uses MaxMind geoipupdate if /etc/secubox/secrets/maxmind.conf exists
       AND geoipupdate is installed (Recommends).
     - Falls back to DB-IP free ASN lite (no signup needed). The file is
       a MaxMind-compatible mmdb so maxminddb.open_database reads it
       transparently. Sanity-checked on prod board:
         metadata: DBIP-ASN-Lite (compat=GeoLite2-ASN)
         lookup 1.1.1.1 -> AS13335 Cloudflare, Inc.

Live verification on gk2 board after applying both fixes:
  top visitor ASNs (last 60min):
    AS32934  Facebook              59 hits
    AS8075   Microsoft             34 hits
    AS396982 Google                13 hits
    AS16509  Amazon                 6 hits

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 06:16:43 +02:00
CyberMind
c2f1682c59
feat(portal): regenerate full dashboard — navbar+vhosts+certs+attacks+metrics+cookies (closes #192) (#193)
Some checks failed
License Headers / check (push) Failing after 6s
Public-facing operational dashboard for /portal/. Replaces the previous
services-grid (links to admin modules) with a live overview of:

  - Navbar: portal/soc/metablogs/publish/cookies/repo
  - Hero: overall health % + uptime + SSL days + vhost/cert/ban totals
  - Services LED grid: waf/crowdsec/haproxy/nginx/system status
  - System: CPU/RAM/Disk/Load/LXC with progress bars
  - Certificates: total/valid/expiring/critical/expired + next renewal
  - Cookie Audit RGPD: violations + by_category breakdown
  - Attacks tiles: active bans + WAF blocked % + today alerts
  - Top vhosts (last 60 min from live-hosts)
  - Top visitor ASN (geographic attribution from visitor-origin)

Wires 6 unauthenticated CORS-open endpoints:
  /api/v1/metrics/health/summary
  /api/v1/metrics/cert-status
  /api/v1/metrics/visitor-origin
  /api/v1/metrics/live-hosts
  /api/v1/cookie-audit/summary
  /api/v1/crowdsec/decisions

Auto-refresh 30s. Vanilla HTML/CSS/JS (no framework, no build step).
P31 phosphor green CRT theme preserved.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 05:59:17 +02:00
382b4cb7dc fix(image): build-mochabin-live-usb.sh accepts --slipstream (no-op match w/ other live-USB scripts)
Some checks failed
License Headers / check (push) Failing after 6s
build-all-live-usb.yml workflow passes `--slipstream` to every live-USB
build script when there are .deb files in output/debs/, but
build-mochabin-live-usb.sh only knew `--no-slipstream` (with slipstream
ON by default). The workflow's `--slipstream` therefore hit the `*) err
\"Unknown argument: \$1\"` branch and the job failed after ~50 min of
chroot setup — which is why mochabin live-USB was missing from v2.9.0
while x64 + rpi400 shipped fine (their scripts already accept both
forms).

This adds the no-op match, identical to what build-live-usb.sh and
build-rpi-usb.sh do at the same place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 05:35:52 +02:00
33f6c1f68e fix(round): re-disable agent + storage-gadget enables wiped by force-push
PR #175 commented out four ln -sf lines (two for secubox-eye-gadget.service
storage-only mode, two for secubox-eye-agent.service Pydantic-v2/SIGILL
crash loop). A force-push for the dwc2 fix earlier today inadvertently
overwrote that branch HEAD, dropping the four edits. Re-applying them
identically so the branch's intent — only secubox-otg-gadget.service
active at boot — is restored.

Combined with the dwc2 modprobe fix already on the branch, the rpiz now
boots into:
  - secubox-otg-gadget.service modprobes dwc2, libcomposite, usb_f_*
  - waits for /sys/class/udc to populate (poll, 5s)
  - creates composite ECM+ACM gadget on the UDC
  - MOCHAbin enumerates "Linux Foundation Multifunction Composite Gadget"
2026-05-17 12:00:03 +02:00
d58460ebaf fix(round): modprobe dwc2 in secubox-otg-gadget chain (rpiz UDC was implicit on eye-gadget)
The previous build had two gadget services enabled at boot:
  - secubox-eye-gadget.service: ExecStartPre=modprobe dwc2 + libcomposite
  - secubox-otg-gadget.service: ExecStartPre=modprobe libcomposite + usb_f_*
                                (NO dwc2 — relied on eye-gadget loading it first)

When PR #175 disabled secubox-eye-gadget at boot (to stop the UDC conflict),
nobody loaded dwc2 anymore. systemd-modules-load reads /etc/modules but the
order of dwc2 load vs the otg-gadget service start window was tight — and
on the rpiz the journal showed no successful gadget enumeration after the
PR #175 image deployed.

Defensive fix in two places:

1. secubox-otg-gadget.service: prepend ExecStartPre=/sbin/modprobe dwc2.
   Drop the ConditionPathIsDirectory=/sys/class/udc gate so the service
   actually runs and can load dwc2 if it isn't loaded yet (the path is a
   sysfs dir that exists when its parent /sys/class is mounted — it was
   harmless, but removing it makes the chain self-bootstrapping).

2. secubox-otg-gadget.sh check_prerequisites(): also modprobe dwc2 (in
   case the service is invoked manually by an operator), then poll
   /sys/class/udc for up to 5s waiting for the BCM USB controller to
   bind asynchronously. Diagnostic message updated to point at the two
   places to check (dtoverlay + /etc/modules) if no UDC ever shows up.
2026-05-17 12:00:03 +02:00
CyberMind
4f19c604c7
feat(giteactl): forge runner noun verbs — LXC-only CI execution (closes #190) (#191)
Some checks failed
License Headers / check (push) Failing after 5s
Sixth routing verb of SecuBox, parallel to:
  haproxyctl   vhost  add/remove    (routing)
  mitmproxyctl route  add/remove    (interception, #173)
  giteactl     repo   mirror add    (replication, #176)
  giteactl     user   add/remove    (identity)
  giteactl     runner add/remove    (CI execution, this — #190)

Phase B of the gitea-actions migration. Operator decree: LXC only, no
docker on host, no insecure host-mode. Each runner lives in its own LXC
`act-runner-<name>` (matching the modular topology of mail/gitea/
mitmproxy LXCs).

Subcommands:
  runner token gen                  Generate one-shot registration token
  runner add NAME --labels L1,L2 [--arch arm64|amd64] [--memory 1G]
                                    lxc-create + apt install + DL gitea-
                                    runner v1.0.3 + register + systemd
  runner remove NAME [--keep-data]  lxc-stop + lxc-destroy
  runner list                       LXCs locales + /admin/runners JSON
  runner logs NAME [--lines N]      journalctl -u act_runner (in-LXC)
  runner restart NAME

Runner version pinned to 1.0.3 via RUNNER_VERSION env (override-able).
Binary downloaded from https://gitea.com/gitea/runner/releases/.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:59:59 +02:00
CyberMind
81168ff49a
feat(publishctl): rename metactl -> publishctl + add post noun (closes #180) (#189)
Some checks failed
License Headers / check (push) Failing after 5s
Naming consistency with the rest of the SecuBox CTL grammar:
  haproxyctl / giteactl / mitmproxyctl / dropletctl / metablogizerctl /
  streamlitctl / streamforgectl / publishctl

The old metactl name stays as a symlink so existing scripts keep working.
Added `post` noun dispatch that wraps the existing flat verbs:

  publishctl post upload <file.zip>     (= publishctl upload, kept as alias)
  publishctl post publish <name>
  publishctl post list / download / qrcode / health

This closes the publishing-layer grammar gap and aligns with #181
(dropletctl file), #184 (metablogizerctl site/tor), and the rest of
the modular ctl pattern documented in CLAUDE.md.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:38:46 +02:00
CyberMind
199e52b5cb
feat(streamlitctl): add app info + app restart verbs (closes #182) (#188)
The #182 audit overstated the gap — the existing dispatch already wired
app list/start/stop/deploy/remove/logs (and instance/gitea sub-nouns).
The real missing pieces were:

  app info <name>      Metadata (entrypoint, port, pid) + runtime state
  app restart <name>   Stop + start, preserving port from .streamlit.toml

Both implemented as thin wrappers over existing lifecycle. Existing
verbs left untouched.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:38:44 +02:00
CyberMind
b97e36cdeb
feat(streamforgectl): forge project noun verbs (closes #183) (#187)
streamforgectl is the dev workbench counterpart of streamlitctl (hosting).
Together they express the forge -> host workflow:

  streamforgectl project create dashboard --template basic
  streamforgectl project export dashboard gitea://secubox/dashboard.git   (TODO)
  streamlitctl   app   deploy   dashboard gitea://secubox/dashboard.git   (#182)

Subcommands wrap /api/v1/streamforge/app* endpoints. Three-fold JSON
(components/access) for ecosystem consistency.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:38:41 +02:00
CyberMind
7c273e2132
feat(metablogizerctl): forge tor noun verbs — Emancipate (closes #184) (#186)
Per CLAUDE.md the Punk Exposure Engine has three verbs (Peek/Poke/
Emancipate) and Tor is one of the three exposure channels. metablogizerctl
already handled site create/delete/publish/unpublish/list (the Poke verb
at the publishing layer); this adds the Emancipate verb:

  tor expose <site>   Publish site via Tor hidden service
  tor revoke <site>   Stop publishing via Tor
  tor list            List Tor-exposed sites + onion addresses
  tor status <site>   Stanza + onion + tor service state

When secubox-exposure is installed, the verbs delegate to it for
consistency with other exposure channels (DNS+SSL, Mesh) — one
orchestrator across all three channels. When unavailable, falls back
to direct /etc/tor/secubox-metablogizer.d/<site>.conf stanza writes
and `systemctl reload tor`, reading the resulting .onion hostname from
/var/lib/tor/secubox-metablogizer/<site>/hostname.

The onion address is persisted back into the site's site.json under
exposure.tor so the Peek verb (later, separate ticket) can surface it
without re-running tor status.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:32:24 +02:00
CyberMind
986b18b163
feat(dropletctl): forge file noun verbs (closes #181) (#185)
dropletctl is SecuBox's publishing-layer routing verb, parallel to:
  haproxyctl   vhost  add/remove    (routing)
  mitmproxyctl route  add/remove    (interception, #173)
  giteactl     repo   mirror add    (replication, #176)
  dropletctl   file   upload/...    (publishing, this)

The Droplet API exposes /upload, /list, /remove, /rename over a Unix
socket — operators had to curl them by hand. This ctl wraps them under
a coherent `<noun> <verb>` grammar.

Subcommands:
  Lifecycle (top-level): install start stop restart status logs
  Three-fold (JSON):     components access
  Files (issue #181):
    file upload <path> [--public] [--ttl 7d]
    file remove <name>
    file rename <old> <new>
    file list   [--limit N]
    file info   <name>

debian/rules installs the binary at /usr/sbin/dropletctl. Bumped to
0.1.1.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:32:21 +02:00
27 changed files with 1580 additions and 455 deletions

View File

@ -90,6 +90,7 @@ while [[ $# -gt 0 ]]; do
--size) IMG_SIZE="$2"; shift 2 ;; --size) IMG_SIZE="$2"; shift 2 ;;
--embed-image) EMBED_IMAGE="$2"; shift 2 ;; --embed-image) EMBED_IMAGE="$2"; shift 2 ;;
--local-cache) USE_LOCAL_CACHE=1; shift ;; --local-cache) USE_LOCAL_CACHE=1; shift ;;
--slipstream) SLIPSTREAM_DEBS=1; shift ;;
--no-slipstream) SLIPSTREAM_DEBS=0; shift ;; --no-slipstream) SLIPSTREAM_DEBS=0; shift ;;
--no-compress) NO_COMPRESS=1; shift ;; --no-compress) NO_COMPRESS=1; shift ;;
--no-led) INCLUDE_LED_KERNEL=0; shift ;; --no-led) INCLUDE_LED_KERNEL=0; shift ;;

View File

@ -1,3 +1,14 @@
secubox-droplet (1.0.2-1~bookworm1) bookworm; urgency=medium
* Forge dropletctl (issue #181) — third routing verb on the publishing
layer, parallel to giteactl repo mirror (#176) and mitmproxyctl route
(#173). Subcommands: lifecycle (start/stop/restart/status/logs),
Three-fold JSON (components/access), and file noun verbs (upload,
remove, rename, list, info) wrapping the /api/v1/droplet/* endpoints
over the Unix socket.
-- Gerald KERMA <devel@cybermind.fr> Sun, 17 May 2026 11:20:54 +0200
secubox-droplet (1.0.1-1~bookworm1) bookworm; urgency=medium secubox-droplet (1.0.1-1~bookworm1) bookworm; urgency=medium
* Add dynamic menu system with menu.d JSON definitions * Add dynamic menu system with menu.d JSON definitions

View File

@ -12,3 +12,6 @@ override_dh_auto_install:
# Modular nginx config # Modular nginx config
install -d debian/secubox-droplet/etc/nginx/secubox.d install -d debian/secubox-droplet/etc/nginx/secubox.d
[ -f nginx/droplet.conf ] && cp nginx/droplet.conf debian/secubox-droplet/etc/nginx/secubox.d/ || true [ -f nginx/droplet.conf ] && cp nginx/droplet.conf debian/secubox-droplet/etc/nginx/secubox.d/ || true
# dropletctl (issue #181)
install -d debian/secubox-droplet/usr/sbin
install -m 755 sbin/dropletctl debian/secubox-droplet/usr/sbin/

View File

@ -0,0 +1,225 @@
#!/bin/bash
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
#
# dropletctl — SecuBox Droplet File Publisher control (issue #181)
#
# Third routing verb on the publishing layer, parallel to:
# haproxyctl vhost add/remove (routing)
# mitmproxyctl route add/remove (interception, #173)
# giteactl repo mirror add (replication, #176)
# dropletctl file upload/list (publishing, this)
#
# The Droplet API exposes /upload, /list, /remove, /rename over a Unix
# socket; this ctl wraps those endpoints under a coherent <noun> <verb>
# grammar so operators don't have to curl by hand.
set -euo pipefail
VERSION="0.1.0"
SOCKET="${DROPLET_SOCKET:-/run/secubox/droplet.sock}"
API_BASE="http://localhost/api/v1/droplet"
SERVICE="secubox-droplet.service"
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'
log() { printf "${GREEN}[DROPLET]${NC} %s\n" "$*"; }
warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$*"; }
error() { printf "${RED}[ERROR]${NC} %s\n" "$*" >&2; }
# ── Helpers ───────────────────────────────────────────────────────────────
require_socket() {
if [ ! -S "$SOCKET" ]; then
error "API socket $SOCKET not present — start $SERVICE first"
exit 2
fi
}
api() {
local method="$1" path="$2"
shift 2
require_socket
curl --unix-socket "$SOCKET" -sS -X "$method" \
-w "\nHTTP_CODE:%{http_code}\n" \
"${API_BASE}${path}" "$@"
}
api_code() {
echo "$1" | grep '^HTTP_CODE:' | cut -d: -f2
}
api_body() {
echo "$1" | sed '/^HTTP_CODE:/d'
}
# ── Lifecycle (top-level, mirrors mitmproxyctl/giteactl) ──────────────────
cmd_install() { log "install via dpkg; this ctl assumes package already installed"; return 0; }
cmd_start() { systemctl start "$SERVICE" && log "started"; }
cmd_stop() { systemctl stop "$SERVICE" && log "stopped"; }
cmd_restart() { systemctl restart "$SERVICE" && log "restarted";}
cmd_status() {
systemctl is-active "$SERVICE" >/dev/null 2>&1 \
&& echo "active" || echo "inactive"
}
cmd_logs() {
journalctl -u "$SERVICE" -n "${1:-50}" --no-pager
}
# ── Three-fold (giteactl convention: components / status / access JSON) ───
cmd_components() {
cat <<EOF
{
"service": "$SERVICE",
"socket": "$SOCKET",
"api_base": "$API_BASE",
"ctl_version": "$VERSION"
}
EOF
}
cmd_access() {
cat <<EOF
{
"socket": "$SOCKET",
"api_paths": {
"upload": "POST /api/v1/droplet/upload",
"list": "GET /api/v1/droplet/list",
"remove": "POST /api/v1/droplet/remove",
"rename": "POST /api/v1/droplet/rename",
"info": "GET /api/v1/droplet/droplet/{name}",
"stats": "GET /api/v1/droplet/stats",
"storage":"GET /api/v1/droplet/storage"
}
}
EOF
}
# ── file noun verbs (the missing grammar — issue #181) ────────────────────
cmd_file() {
local action="${1:-}"; shift || true
case "$action" in
upload) cmd_file_upload "$@" ;;
remove|rm|del) cmd_file_remove "$@" ;;
rename) cmd_file_rename "$@" ;;
list|ls) cmd_file_list "$@" ;;
info) cmd_file_info "$@" ;;
*)
cat <<EOF
File commands:
file upload <path> [--public] [--ttl <e.g. 7d>]
file remove <name>
file rename <old> <new>
file list [--limit N]
file info <name>
EOF
;;
esac
}
cmd_file_upload() {
local path="$1"; shift || { error "file upload <path> required"; return 1; }
[ -f "$path" ] || { error "file not found: $path"; return 1; }
local public=false ttl=""
while [ $# -gt 0 ]; do
case "$1" in
--public) public=true ;;
--ttl) ttl="$2"; shift ;;
*) error "unknown flag: $1"; return 1 ;;
esac; shift
done
log "uploading $path (public=$public ttl=${ttl:-default})"
local args=("-F" "file=@${path}")
$public && args+=("-F" "public=true")
[ -n "$ttl" ] && args+=("-F" "ttl=$ttl")
local out
out=$(api POST "/upload" "${args[@]}")
local code; code=$(api_code "$out")
case "$code" in
200|201) api_body "$out" ;;
*) error "upload failed (HTTP $code): $(api_body "$out" | head -3)"; return 1 ;;
esac
}
cmd_file_remove() {
local name="$1"
[ -z "$name" ] && { error "file remove <name> required"; return 1; }
log "removing $name"
local out
out=$(api POST "/remove" -H "Content-Type: application/json" \
-d "$(printf '{"name":"%s"}' "$name")")
local code; code=$(api_code "$out")
[ "$code" = "200" ] || { error "remove failed (HTTP $code): $(api_body "$out")"; return 1; }
log "removed $name"
}
cmd_file_rename() {
local old="$1" new="$2"
[ -z "$old" ] || [ -z "$new" ] && { error "file rename <old> <new> required"; return 1; }
log "renaming $old -> $new"
local out
out=$(api POST "/rename" -H "Content-Type: application/json" \
-d "$(printf '{"old":"%s","new":"%s"}' "$old" "$new")")
local code; code=$(api_code "$out")
[ "$code" = "200" ] || { error "rename failed (HTTP $code): $(api_body "$out")"; return 1; }
log "renamed"
}
cmd_file_list() {
local limit=""
[ "${1:-}" = "--limit" ] && { limit="?limit=$2"; }
local out
out=$(api GET "/list${limit}")
api_body "$out"
}
cmd_file_info() {
local name="$1"
[ -z "$name" ] && { error "file info <name> required"; return 1; }
local out
out=$(api GET "/droplet/${name}")
api_body "$out"
}
# ── Main dispatch ─────────────────────────────────────────────────────────
show_help() {
cat <<EOF
SecuBox Droplet Controller v$VERSION (issue #181)
File publisher CLI — parallel to giteactl, mitmproxyctl
Usage: dropletctl <command> [options]
Lifecycle:
install / start / stop / restart / status / logs
Three-fold (JSON):
components / access
Files (issue #181):
file upload <path> [--public] [--ttl 7d]
file remove <name>
file rename <old> <new>
file list [--limit N]
file info <name>
Examples:
dropletctl file upload /tmp/report.pdf --public --ttl 7d
dropletctl file list
dropletctl access | jq .api_paths
EOF
}
case "${1:-}" in
install|start|stop|restart|status) cmd="$1"; shift; cmd_$cmd "$@" ;;
logs) shift; cmd_logs "$@" ;;
components) cmd_components ;;
access) cmd_access ;;
file) shift; cmd_file "$@" ;;
help|--help|-h|'') show_help ;;
*) error "unknown command: $1"; show_help; exit 1 ;;
esac

View File

@ -1,3 +1,21 @@
secubox-gitea (1.4.1-1~bookworm1) bookworm; urgency=medium
* giteactl: forge `runner` noun verbs (issue #190) — the CI execution
layer of the SecuBox grammar, parallel to `repo mirror` (#176) and
`user`. Subcommands:
runner token gen
runner add NAME --labels L1,L2 [--arch arm64|amd64] [--memory 1G]
runner remove NAME [--keep-data]
runner list
runner logs NAME [--lines N]
runner restart NAME
Each runner lives in its own LXC `act-runner-<name>` (operator
decree: LXC only, no docker, no host-mode). `runner add` bootstraps
the LXC, downloads gitea-runner v1.0.3 from gitea.com, registers
against the local Gitea, installs a systemd unit, starts it.
-- Gerald KERMA <devel@cybermind.fr> Sun, 17 May 2026 11:51:30 +0200
secubox-gitea (1.4.0-1~bookworm1) bookworm; urgency=medium secubox-gitea (1.4.0-1~bookworm1) bookworm; urgency=medium
* Fix startup script: add PATH and HOME environment variables * Fix startup script: add PATH and HOME environment variables

View File

@ -672,6 +672,277 @@ except Exception as e:
" 2>&1 " 2>&1
} }
# ============================================================================
# Runner — the CI execution verb (issue #190)
#
# Parallel to `repo mirror` (#176) and `user` (existing). Bootstraps a
# dedicated LXC for each gitea-runner instance — operator decree: LXC only,
# no docker on host, no insecure host-mode.
#
# Subcommands:
# runner token gen Generate a one-shot registration token
# runner add NAME --labels L1,L2,... [--arch arm64|amd64] [--memory 1G]
# runner remove NAME [--keep-data]
# runner list
# runner logs NAME [--lines N]
# runner restart NAME
# ============================================================================
RUNNER_VERSION="${RUNNER_VERSION:-1.0.3}"
RUNNER_DL_BASE="https://gitea.com/gitea/runner/releases/download/v${RUNNER_VERSION}"
RUNNER_LXC_PREFIX="act-runner-"
RUNNER_GITEA_URL="${RUNNER_GITEA_URL:-http://${LXC_IP}:${HTTP_PORT}}"
RUNNER_BRIDGE="${RUNNER_BRIDGE:-br-lxc}"
RUNNER_SUBNET="${RUNNER_SUBNET:-10.100.0.0/24}"
RUNNER_GATEWAY="${RUNNER_GATEWAY:-10.100.0.1}"
RUNNER_IP_POOL_START="${RUNNER_IP_POOL_START:-50}" # 10.100.0.50..200
RUNNER_IP_POOL_END="${RUNNER_IP_POOL_END:-200}"
RUNNER_DNS="${RUNNER_DNS:-1.1.1.1 8.8.8.8}"
# Find the next free IP in the runner pool (10.100.0.50..200), avoiding
# IPs already declared in existing LXC configs.
_runner_next_ip() {
local octet ip used
used=$(grep -h "lxc.net.0.ipv4.address" "$LXC_PATH"/*/config 2>/dev/null \
| sed 's|.*= *\([0-9.]*\)/.*|\1|' \
| grep "^10\.100\.0\." | sed 's|.*\.||' | sort -un)
for octet in $(seq "$RUNNER_IP_POOL_START" "$RUNNER_IP_POOL_END"); do
echo "$used" | grep -qx "$octet" || { echo "10.100.0.$octet"; return 0; }
done
return 1
}
_runner_lxc_name() { echo "${RUNNER_LXC_PREFIX}${1}"; }
_runner_lxc_exists() { [ -d "$LXC_PATH/$(_runner_lxc_name "$1")/rootfs" ]; }
_runner_lxc_running() {
lxc-info -n "$(_runner_lxc_name "$1")" -P "$LXC_PATH" 2>/dev/null | grep -q "State:.*RUNNING"
}
# Generate a runner registration token via gitea CLI inside the Gitea LXC.
# Output is the raw token on the last line.
cmd_runner_token_gen() {
require_root
if ! lxc_running; then error "gitea LXC not running"; return 1; fi
local out
out=$(lxc-attach -n "$CONTAINER" -P "$LXC_PATH" -- \
su -s /bin/bash gitea -c \
"gitea --config $GITEA_APP_INI actions generate-runner-token 2>&1" \
| tail -1)
[ -z "$out" ] && { error "token gen failed"; return 1; }
echo "$out"
}
cmd_runner_add() {
local name="$1"; shift || { error "runner add NAME required"; return 1; }
local labels="" arch="" memory="1G" ip=""
while [ $# -gt 0 ]; do
case "$1" in
--labels) labels="$2"; shift ;;
--arch) arch="$2"; shift ;;
--memory) memory="$2"; shift ;;
--ip) ip="$2"; shift ;;
*) error "unknown flag: $1"; return 1 ;;
esac; shift
done
[ -z "$labels" ] && { error "--labels L1,L2,... required"; return 1; }
[ -z "$arch" ] && arch=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
[ -z "$ip" ] && ip=$(_runner_next_ip)
[ -z "$ip" ] && { error "no free IP in pool 10.100.0.${RUNNER_IP_POOL_START}..${RUNNER_IP_POOL_END}"; return 1; }
require_root
local lxc_name; lxc_name=$(_runner_lxc_name "$name")
if _runner_lxc_exists "$name"; then
error "LXC $lxc_name already exists (use 'runner remove $name' first)"
return 1
fi
log "creating LXC $lxc_name (arch $arch, memory $memory, ip $ip)"
lxc-create -n "$lxc_name" -P "$LXC_PATH" -t download -- \
-d debian -r bookworm -a "$arch"
# Static IP on br-lxc + memory limit (no DHCP on this bridge)
cat >> "$LXC_PATH/$lxc_name/config" <<EOF
# act-runner LXC — set via giteactl runner add (#190)
lxc.net.0.ipv4.address = ${ip}/24
lxc.net.0.ipv4.gateway = ${RUNNER_GATEWAY}
lxc.cgroup2.memory.max = $((${memory%[GgMm]} * (1024**$( [ "${memory: -1}" = "G" ] && echo 3 || echo 2 ))))
EOF
log "starting $lxc_name for bootstrap"
lxc-start -n "$lxc_name" -P "$LXC_PATH"
sleep 4 # let networking settle
# Fix DNS — Debian template's resolv.conf symlinks systemd-resolved
# stub which is empty until configured; write static nameservers.
lxc-attach -n "$lxc_name" -P "$LXC_PATH" -- bash -c "
for ns in $RUNNER_DNS; do echo \"nameserver \$ns\"; done > /etc/resolv.conf
"
log "installing gitea-runner v$RUNNER_VERSION inside"
local dl_url="${RUNNER_DL_BASE}/gitea-runner-${RUNNER_VERSION}-linux-${arch}"
lxc-attach -n "$lxc_name" -P "$LXC_PATH" -- bash -c "
set -e
apt-get update -qq
apt-get install -y -qq curl ca-certificates git
curl -sL -o /usr/local/bin/act_runner '$dl_url'
chmod 755 /usr/local/bin/act_runner
useradd -r -s /usr/sbin/nologin -m -d /var/lib/act_runner act_runner 2>/dev/null || true
mkdir -p /etc/act_runner /var/lib/act_runner
chown -R act_runner:act_runner /var/lib/act_runner
"
log "generating runner registration token"
local token
token=$(cmd_runner_token_gen | tail -1)
[ -z "$token" ] && { error "could not get registration token"; return 1; }
log "registering runner $name (labels: $labels) -> $RUNNER_GITEA_URL"
lxc-attach -n "$lxc_name" -P "$LXC_PATH" -- bash -c "
cd /var/lib/act_runner
su -s /bin/bash act_runner -c \"
cd /var/lib/act_runner
act_runner register \\
--no-interactive \\
--instance '$RUNNER_GITEA_URL' \\
--token '$token' \\
--name '$name' \\
--labels '$labels'
\"
"
log "installing systemd unit + starting"
lxc-attach -n "$lxc_name" -P "$LXC_PATH" -- bash -c "
cat > /etc/systemd/system/act_runner.service <<UNIT
[Unit]
Description=Gitea act_runner
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=act_runner
Group=act_runner
WorkingDirectory=/var/lib/act_runner
ExecStart=/usr/local/bin/act_runner daemon
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now act_runner.service
sleep 2
systemctl is-active act_runner.service
"
log "runner $name installed in $lxc_name"
}
cmd_runner_remove() {
local name="$1"; shift || { error "runner remove NAME required"; return 1; }
local keep_data=false
[ "${1:-}" = "--keep-data" ] && keep_data=true
require_root
local lxc_name; lxc_name=$(_runner_lxc_name "$name")
if ! _runner_lxc_exists "$name"; then
warn "LXC $lxc_name does not exist; nothing to remove"
return 0
fi
if _runner_lxc_running "$name"; then
log "stopping $lxc_name"
lxc-stop -n "$lxc_name" -P "$LXC_PATH"
fi
if $keep_data; then
log "keeping LXC rootfs (--keep-data); just stopped"
else
log "destroying $lxc_name"
lxc-destroy -n "$lxc_name" -P "$LXC_PATH"
fi
log "(note: runner remains registered in Gitea — visit Settings -> Actions -> Runners to delete the dead entry, or wait for it to expire)"
}
cmd_runner_list() {
require_root
echo "Local LXC runners:"
local any=0
for d in "$LXC_PATH"/${RUNNER_LXC_PREFIX}*; do
[ -d "$d/rootfs" ] || continue
any=1
local n; n=$(basename "$d" | sed "s|^${RUNNER_LXC_PREFIX}||")
local state="STOPPED"
_runner_lxc_running "$n" && state="RUNNING"
printf " %-25s %s\n" "$n" "$state"
done
[ $any = 0 ] && echo " (none)"
echo
echo "Registered runners (gitea API):"
local tok tname; read -r tok tname <<<"$(gitea_token_gen)" || return 1
local out
out=$(gitea_api "$tok" GET "/admin/runners" "")
gitea_token_revoke "$tname"
api_body() { echo "$1" | sed '/^HTTP_CODE:/d'; }
echo "$out" | sed '/^HTTP_CODE:/d' | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
for r in d.get('runners', d if isinstance(d, list) else []):
print(f\" {r.get('name','?'):25s} status={r.get('status','?')} labels={','.join(r.get('labels',[]))}\")
if not (d.get('runners') if isinstance(d, dict) else d):
print(' (none registered)')
except Exception as e:
print(f' parse error: {e}')
" 2>&1
}
cmd_runner_logs() {
local name="$1"; [ -z "$name" ] && { error "runner logs NAME required"; return 1; }
local lines="${2:-100}"
[ "$1" = "--lines" ] && { lines="$2"; name="$3"; }
require_root
_runner_lxc_running "$name" || { error "LXC $(_runner_lxc_name "$name") not running"; return 1; }
lxc-attach -n "$(_runner_lxc_name "$name")" -P "$LXC_PATH" -- \
journalctl -u act_runner -n "$lines" --no-pager
}
cmd_runner_restart() {
local name="$1"; [ -z "$name" ] && { error "runner restart NAME required"; return 1; }
require_root
_runner_lxc_running "$name" || { error "LXC not running, start it first"; return 1; }
lxc-attach -n "$(_runner_lxc_name "$name")" -P "$LXC_PATH" -- \
systemctl restart act_runner
log "act_runner restarted in $(_runner_lxc_name "$name")"
}
cmd_runner() {
local act="${1:-}"; shift || true
case "$act" in
token)
local sub="${1:-}"
case "$sub" in
gen|generate) cmd_runner_token_gen ;;
*) echo "Usage: giteactl runner token gen" ;;
esac
;;
add) cmd_runner_add "$@" ;;
remove|rm|delete) cmd_runner_remove "$@" ;;
list|ls) cmd_runner_list ;;
logs) cmd_runner_logs "$@" ;;
restart) cmd_runner_restart "$@" ;;
*)
cat <<EOF
Runner commands (issue #190 — CI execution layer):
runner token gen Generate one-shot registration token
runner add NAME --labels L1,L2 [--arch arm64|amd64] [--memory 1G]
Bootstrap LXC, install gitea-runner, register, start
runner remove NAME [--keep-data] Stop + destroy LXC (Gitea entry stays)
runner list Local LXCs + registered runners (JSON)
runner logs NAME [--lines N] Tail in-LXC act_runner journal
runner restart NAME systemctl restart act_runner in the LXC
EOF
;;
esac
}
# ============================================================================ # ============================================================================
# Backup / Restore # Backup / Restore
# ============================================================================ # ============================================================================
@ -1093,6 +1364,14 @@ Repos & Mirrors (issue #176, parallel to mitmproxyctl route):
repo mirror sync OWNER/NAME repo mirror sync OWNER/NAME
repo mirror list repo mirror list
Runners (issue #190 — LXC-only act_runner, CI execution layer):
runner token gen
runner add NAME --labels L1,L2 [--arch arm64|amd64] [--memory 1G]
runner remove NAME [--keep-data]
runner list
runner logs NAME [--lines N]
runner restart NAME
Backup: Backup:
backup [name] Create backup backup [name] Create backup
restore <file> Restore from backup restore <file> Restore from backup
@ -1129,6 +1408,7 @@ case "${1:-}" in
restart) shift; cmd_restart "$@" ;; restart) shift; cmd_restart "$@" ;;
user) shift; cmd_user "$@" ;; user) shift; cmd_user "$@" ;;
repo) shift; cmd_repo "$@" ;; repo) shift; cmd_repo "$@" ;;
runner) shift; cmd_runner "$@" ;;
backup) shift; cmd_backup "$@" ;; backup) shift; cmd_backup "$@" ;;
restore) shift; cmd_restore "$@" ;; restore) shift; cmd_restore "$@" ;;
migrate) shift; cmd_migrate "$@" ;; migrate) shift; cmd_migrate "$@" ;;

View File

@ -1,3 +1,18 @@
secubox-metablogizer (1.1.1-1~bookworm1) bookworm; urgency=medium
* metablogizerctl: forge tor noun verbs (issue #184) — the Emancipate
verb of the Punk Exposure Engine at the publishing layer.
Subcommands:
tor expose <site> Publish site via Tor hidden service
tor revoke <site> Stop publishing via Tor
tor list List Tor-exposed sites with onion addresses
tor status <site> Show stanza + onion + tor service state
When secubox-exposure is installed, delegates to it for consistency
with other exposure channels (DNS+SSL, Mesh). Otherwise falls back
to direct /etc/tor/secubox-metablogizer.d/ stanza management.
-- Gerald KERMA <devel@cybermind.fr> Sun, 17 May 2026 11:23:12 +0200
secubox-metablogizer (1.1.0-1~bookworm1) bookworm; urgency=medium secubox-metablogizer (1.1.0-1~bookworm1) bookworm; urgency=medium
* Add metablogizerctl three-fold commands: components, access (JSON output) * Add metablogizerctl three-fold commands: components, access (JSON output)

View File

@ -194,6 +194,155 @@ site_unpublish() {
log "Site unpublished: $name" log "Site unpublished: $name"
} }
# ============================================================================
# Tor — Emancipate verb (Punk Exposure Engine, issue #184)
#
# Per CLAUDE.md the Punk Exposure Engine has three verbs (Peek/Poke/Emancipate)
# and Tor is one of the three exposure channels. `metablogizerctl tor expose`
# is the Emancipate verb at the publishing layer for static sites.
#
# Implementation: write a per-site Tor HiddenService stanza, reload tor,
# read the generated .onion hostname back. If secubox-exposure is installed,
# delegate to it for consistency with other channels; otherwise fall back
# to direct torrc manipulation.
# ============================================================================
TOR_DROPIN_DIR="${TOR_DROPIN_DIR:-/etc/tor/secubox-metablogizer.d}"
TOR_DATA_DIR="${TOR_DATA_DIR:-/var/lib/tor/secubox-metablogizer}"
_tor_drop_path() { echo "$TOR_DROPIN_DIR/${1}.conf"; }
_tor_data_path() { echo "$TOR_DATA_DIR/${1}"; }
_have_exposure() { command -v secubox-exposure >/dev/null 2>&1; }
_have_tor() { command -v tor >/dev/null 2>&1; }
tor_expose() {
local name="$1"
[ -z "$name" ] && { error "Usage: metablogizerctl tor expose <site>"; return 1; }
local site_dir="$SITES_ROOT/$name"
[ -d "$site_dir" ] || { error "site not found: $name (run: metablogizerctl site create $name first)"; return 1; }
# Prefer secubox-exposure when available so all 3 exposure channels share
# the same orchestration (Tor / DNS+SSL / Mesh) and Peek shows it.
if _have_exposure; then
log "delegating to secubox-exposure emancipate (tor channel)"
# The static site is served via nginx on port 80 (per site_publish);
# secubox-exposure handles the HiddenService wiring and revocation.
secubox-exposure emancipate "metablogizer-${name}" 80 --tor
return $?
fi
# Fallback: write a tor drop-in directly.
_have_tor || { error "tor not installed and secubox-exposure unavailable"; return 1; }
mkdir -p "$TOR_DROPIN_DIR"
mkdir -p "$TOR_DATA_DIR"
local data; data=$(_tor_data_path "$name")
local drop; drop=$(_tor_drop_path "$name")
log "writing Tor HiddenService stanza for $name"
cat > "$drop" <<EOF
# metablogizer site: $name (#184 — Emancipate via Tor)
HiddenServiceDir $data
HiddenServicePort 80 127.0.0.1:80
EOF
chown -R debian-tor:debian-tor "$TOR_DATA_DIR" 2>/dev/null || true
chmod 700 "$data" 2>/dev/null || true
log "reloading tor"
systemctl reload tor 2>/dev/null || systemctl restart tor
# Wait briefly for tor to publish the hostname file
local i=0
while [ $i -lt 10 ] && [ ! -f "$data/hostname" ]; do sleep 1; i=$((i+1)); done
if [ -f "$data/hostname" ]; then
local onion; onion=$(cat "$data/hostname")
log "site emancipated via Tor: $onion"
# Persist the address back into site.json for future Peek calls
if [ -f "$site_dir/site.json" ] && command -v python3 >/dev/null 2>&1; then
python3 -c "
import json, sys
p='$site_dir/site.json'
d=json.load(open(p))
d.setdefault('exposure',{})['tor']='$onion'
json.dump(d, open(p,'w'), indent=2)
" 2>/dev/null || true
fi
else
warn "tor reload OK but hostname not yet written; check 'metablogizerctl tor status $name'"
fi
}
tor_revoke() {
local name="$1"
[ -z "$name" ] && { error "Usage: metablogizerctl tor revoke <site>"; return 1; }
if _have_exposure; then
log "delegating to secubox-exposure revoke"
secubox-exposure revoke "metablogizer-${name}" --tor
return $?
fi
local drop; drop=$(_tor_drop_path "$name")
[ -f "$drop" ] || { warn "no Tor stanza for $name"; return 0; }
rm -f "$drop"
systemctl reload tor 2>/dev/null || systemctl restart tor
log "tor stanza removed for $name (data dir kept under $TOR_DATA_DIR/$name — delete manually if desired)"
}
tor_list() {
if _have_exposure; then
log "(delegate) secubox-exposure list --tor"
secubox-exposure list --tor 2>/dev/null && return
fi
if [ ! -d "$TOR_DROPIN_DIR" ]; then
echo "(no Tor-exposed sites)"
return
fi
local any=0
for d in "$TOR_DROPIN_DIR"/*.conf; do
[ -f "$d" ] || continue
any=1
local n; n=$(basename "$d" .conf)
local h="$TOR_DATA_DIR/$n/hostname"
if [ -f "$h" ]; then
printf " %-30s -> %s\n" "$n" "$(cat "$h")"
else
printf " %-30s -> (publishing...)\n" "$n"
fi
done
[ $any = 0 ] && echo "(no Tor-exposed sites)"
}
tor_status() {
local name="$1"
[ -z "$name" ] && { error "Usage: metablogizerctl tor status <site>"; return 1; }
local data; data=$(_tor_data_path "$name")
local drop; drop=$(_tor_drop_path "$name")
echo "site: $name"
echo "stanza present: $([ -f "$drop" ] && echo yes || echo no)"
if [ -f "$data/hostname" ]; then
echo "onion: $(cat "$data/hostname")"
else
echo "onion: (not yet published)"
fi
systemctl is-active tor >/dev/null 2>&1 && echo "tor service: active" || echo "tor service: inactive"
}
cmd_tor() {
local action="${1:-}"; shift || true
case "$action" in
expose) tor_expose "$@" ;;
revoke|remove) tor_revoke "$@" ;;
list|ls) tor_list ;;
status) tor_status "$@" ;;
*)
cat <<EOF
Tor commands (Punk Exposure / Emancipate verb, issue #184):
tor expose <site> - publish site via Tor hidden service
tor revoke <site> - stop publishing via Tor
tor list - list Tor-exposed sites + their onion addresses
tor status <site> - show stanza presence + onion + tor service state
EOF
;;
esac
}
site_list() { site_list() {
echo "MetaBlogizer Sites:" echo "MetaBlogizer Sites:"
echo "===================" echo "==================="
@ -387,6 +536,12 @@ Sites:
site unpublish <name> Unpublish site site unpublish <name> Unpublish site
site list List all sites site list List all sites
Tor (Punk Exposure / Emancipate, issue #184):
tor expose <site> Publish site via Tor hidden service
tor revoke <site> Stop publishing via Tor
tor list List Tor-exposed sites + onions
tor status <site> Stanza + onion + tor service state
Service: Service:
migrate [host] Migrate from OpenWrt migrate [host] Migrate from OpenWrt
@ -394,6 +549,7 @@ Examples:
metablogizerctl components # JSON components metablogizerctl components # JSON components
metablogizerctl site create myblog blog.example.com metablogizerctl site create myblog blog.example.com
metablogizerctl site publish myblog metablogizerctl site publish myblog
metablogizerctl tor expose myblog # Emancipate via Tor
metablogizerctl migrate 192.168.255.1 metablogizerctl migrate 192.168.255.1
EOF EOF
@ -422,6 +578,8 @@ case "${1:-}" in
*) echo "Usage: metablogizerctl site create|delete|publish|unpublish|list" ;; *) echo "Usage: metablogizerctl site create|delete|publish|unpublish|list" ;;
esac esac
;; ;;
# Tor (Emancipate, issue #184)
tor) shift; cmd_tor "$@" ;;
migrate) shift; cmd_migrate "$@" ;; migrate) shift; cmd_migrate "$@" ;;
help|--help|-h|'') show_help ;; help|--help|-h|'') show_help ;;
*) error "Unknown: $1"; exit 1 ;; *) error "Unknown: $1"; exit 1 ;;

View File

@ -0,0 +1,49 @@
#!/bin/bash
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
#
# secubox-geoipupdate-fetch — refresh /var/lib/GeoIP/GeoLite2-ASN.mmdb
#
# Two paths (#194):
# 1. If /etc/secubox/secrets/maxmind.conf exists AND `geoipupdate` is
# installed, use the MaxMind GeoLite2 download (operator has a key).
# 2. Otherwise fall back to DB-IP free ASN lite, no signup required.
# Format is MaxMind-compatible — the visitor_origin aggregator reads
# it with `maxminddb.open_database` transparently.
#
# Invoked by secubox-geoipupdate.service (weekly timer).
set -euo pipefail
MMDB_DEST="/var/lib/GeoIP/GeoLite2-ASN.mmdb"
MAXMIND_CONF="/etc/secubox/secrets/maxmind.conf"
DBIP_URL_BASE="https://download.db-ip.com/free"
log() { echo "[geoipupdate-fetch] $*"; }
err() { echo "[geoipupdate-fetch] ERROR: $*" >&2; }
install -d -m 755 /var/lib/GeoIP
if [ -f "$MAXMIND_CONF" ] && command -v geoipupdate >/dev/null 2>&1; then
log "MaxMind license + geoipupdate present — using MaxMind path"
exec geoipupdate -f "$MAXMIND_CONF" -d /var/lib/GeoIP
fi
log "no MaxMind license (or geoipupdate missing) — falling back to DB-IP free"
MONTH=$(date +%Y-%m)
URL="${DBIP_URL_BASE}/dbip-asn-lite-${MONTH}.mmdb.gz"
TMP=$(mktemp)
TMP_GZ="${TMP}.gz"
trap 'rm -f "$TMP" "$TMP_GZ"' EXIT
log "GET $URL"
if ! curl -fsSL -o "$TMP_GZ" "$URL"; then
err "DB-IP download failed; trying previous month as fallback"
LAST_MONTH=$(date -d "${MONTH}-01 -1 day" +%Y-%m 2>/dev/null || date -v-1m +%Y-%m)
URL="${DBIP_URL_BASE}/dbip-asn-lite-${LAST_MONTH}.mmdb.gz"
log "GET $URL"
curl -fsSL -o "$TMP_GZ" "$URL"
fi
gunzip -f "$TMP_GZ" # writes $TMP
install -m 644 -o secubox -g secubox "$TMP" "$MMDB_DEST"
log "installed $MMDB_DEST ($(stat -c %s "$MMDB_DEST") bytes)"

View File

@ -1,3 +1,24 @@
secubox-metrics (1.0.3-1~bookworm1) bookworm; urgency=medium
* VisitorOrigin: add AmbientCapabilities=CAP_NET_ADMIN to the systemd
unit (#194, latent since #92). Without this the aggregator runs as
secubox user, `nft -j list set inet secubox_metrics seen_src` returns
EPERM, _read_nft_set silently returns [] and entries stays empty
forever. Caps coexist with NoNewPrivileges=true.
* secubox-geoipupdate: drop the ConditionPathExists=/etc/secubox/
secrets/maxmind.conf gate. New helper `secubox-geoipupdate-fetch`
tries MaxMind first if a license is present, else falls back to
DB-IP free ASN lite (https://download.db-ip.com/free/dbip-asn-lite-
YYYY-MM.mmdb.gz). DB-IP releases mmdb in MaxMind-compatible format
so the visitor_origin aggregator reads it transparently.
* Operators get VisitorOrigin out of the box now (no MaxMind account
required); those with a license keep using MaxMind via the same
helper.
-- Gerald KERMA <devel@cybermind.fr> Mon, 18 May 2026 06:16:28 +0200
secubox-metrics (1.0.2-1~bookworm1) bookworm; urgency=medium secubox-metrics (1.0.2-1~bookworm1) bookworm; urgency=medium
* Add Cookie Audit (RGPD / ePrivacy) endpoints: POST /api/v1/cookie-audit/ingest, * Add Cookie Audit (RGPD / ePrivacy) endpoints: POST /api/v1/cookie-audit/ingest,

View File

@ -39,3 +39,7 @@ override_dh_install:
debian/secubox-metrics/lib/systemd/system/secubox-geoipupdate.service debian/secubox-metrics/lib/systemd/system/secubox-geoipupdate.service
install -D -m 0644 systemd/secubox-geoipupdate.timer \ install -D -m 0644 systemd/secubox-geoipupdate.timer \
debian/secubox-metrics/lib/systemd/system/secubox-geoipupdate.timer debian/secubox-metrics/lib/systemd/system/secubox-geoipupdate.timer
# Helper fetcher (#194): tries MaxMind first if license present,
# else falls back to DB-IP free ASN lite (no signup required).
install -D -m 0755 bin/secubox-geoipupdate-fetch \
debian/secubox-metrics/usr/bin/secubox-geoipupdate-fetch

View File

@ -27,6 +27,13 @@ RestartSec=5
# ProtectSystem=full # ProtectSystem=full
NoNewPrivileges=true NoNewPrivileges=true
ReadWritePaths=/run/secubox /var/cache/secubox ReadWritePaths=/run/secubox /var/cache/secubox
# VisitorOrigin reads the nft `seen_src` set via `nft -j list set ...` which
# requires CAP_NET_ADMIN. Without it, _read_nft_set silently returns [] and
# the aggregator emits empty entries forever (#194 fix, latent since #92).
# Coexists with NoNewPrivileges=true because systemd sets the ambient set
# before the User= drop.
AmbientCapabilities=CAP_NET_ADMIN
CapabilityBoundingSet=CAP_NET_ADMIN
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@ -1,14 +1,15 @@
[Unit] [Unit]
Description=SecuBox — refresh GeoLite2 ASN database Description=SecuBox — refresh GeoLite2-ASN database (MaxMind or DB-IP fallback)
ConditionPathExists=/etc/secubox/secrets/maxmind.conf
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
[Service] [Service]
Type=oneshot Type=oneshot
User=secubox # Fetcher must run as root because /var/lib/GeoIP is owned by root and the
Group=secubox # DB-IP fallback path uses `install -o secubox` to land the mmdb under the
ExecStart=/usr/bin/geoipupdate -f /etc/secubox/secrets/maxmind.conf -d /var/lib/GeoIP # correct ownership. The helper does not exec anything as root that takes
NoNewPrivileges=true # untrusted input.
User=root
ExecStart=/usr/bin/secubox-geoipupdate-fetch
ProtectSystem=full ProtectSystem=full
ReadWritePaths=/var/lib/GeoIP ReadWritePaths=/var/lib/GeoIP

View File

@ -1,3 +1,23 @@
secubox-portal (2.2.0-1~bookworm1) bookworm; urgency=medium
* portal: regenerate /portal/index.html as a public-facing operational
dashboard (issue #192). Consumes unauthenticated metrics endpoints
(cert-status, visitor-origin, live-hosts, health/summary, cookie-
audit/summary, crowdsec/decisions) and renders:
- Navbar (portal/soc/metablogs/publish/cookies/repo)
- Hero (overall health score + uptime/SSL/vhosts/certs/bans)
- Services LED grid (waf/crowdsec/haproxy/nginx/system)
- System metrics (CPU/RAM/Disk/Load/LXC) with progress bars
- Certificates summary (total/valid/expiring/critical/expired)
- Cookie Audit · RGPD breakdown
- Attacks & Bans tiles (active bans / WAF % / today alerts)
- Top vhosts (last 60 min traffic)
- Top visitor ASN (geographic attribution)
Auto-refresh 30s, graceful degradation on endpoint failure.
Style: P31 phosphor green CRT (preserved from previous version).
-- Gerald KERMA <devel@cybermind.fr> Mon, 18 May 2026 05:54:39 +0200
secubox-portal (2.1.0-1~bookworm1) bookworm; urgency=medium secubox-portal (2.1.0-1~bookworm1) bookworm; urgency=medium
* Add device-specific theming based on detected board * Add device-specific theming based on detected board

View File

@ -3,7 +3,8 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SecuBox - Portal</title> <title>SecuBox · Portal</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛡️</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared/crt-light.css"> <link rel="stylesheet" href="/shared/crt-light.css">
@ -20,16 +21,13 @@
--tube-pale: #c8e6c9; --tube-pale: #c8e6c9;
--tube-soft: #a5d6a7; --tube-soft: #a5d6a7;
--tube-dark: #1b1b1f; --tube-dark: #1b1b1f;
--text-dim: var(--p31-dim);
--primary: var(--p31-peak); --primary: var(--p31-peak);
--cyan: #00d4ff; --cyan: #00d4ff;
--green: var(--p31-peak);
--red: #ff4466; --red: #ff4466;
--yellow: var(--p31-decay); --yellow: var(--p31-decay);
--purple: #a371f7;
--bloom-text: 0 0 2px var(--p31-peak), 0 0 6px var(--p31-peak), 0 0 14px rgba(51,255,102,0.5); --bloom-text: 0 0 2px var(--p31-peak), 0 0 6px var(--p31-peak), 0 0 14px rgba(51,255,102,0.5);
--bloom-soft: 0 0 6px var(--p31-peak), 0 0 14px rgba(51,255,102,0.5);
--bloom-amber: 0 0 3px var(--p31-decay), 0 0 10px rgba(255,179,71,0.4); --bloom-amber: 0 0 3px var(--p31-decay), 0 0 10px rgba(255,179,71,0.4);
--bloom-red: 0 0 3px var(--red), 0 0 10px rgba(255,68,102,0.4);
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
@ -38,471 +36,462 @@
background-image: radial-gradient(ellipse at 50% 40%, rgba(51,255,102,0.025) 0%, transparent 70%); background-image: radial-gradient(ellipse at 50% 40%, rgba(51,255,102,0.025) 0%, transparent 70%);
color: var(--tube-dark); color: var(--tube-dark);
min-height: 100vh; min-height: 100vh;
padding: 2rem; padding-bottom: 3rem;
} }
body::before { body::before {
content: ""; content: "";
position: fixed; position: fixed; inset: 0;
inset: 0;
background-image: background-image:
linear-gradient(rgba(0,221,68,0.02) 1px, transparent 1px), linear-gradient(rgba(0,221,68,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,221,68,0.02) 1px, transparent 1px); linear-gradient(90deg, rgba(0,221,68,0.02) 1px, transparent 1px);
background-size: 50px 50px; background-size: 50px 50px;
pointer-events: none; pointer-events: none; z-index: -1;
z-index: -1;
} }
.container { max-width: 1400px; margin: 0 auto; } a { color: var(--p31-mid); text-decoration: none; }
a:hover { color: var(--p31-peak); text-shadow: var(--bloom-text); }
/* Header */ /* Navbar */
.header { text-align: center; margin-bottom: 3rem; } .navbar {
.logo-icon { background: var(--tube-dark);
display: inline-flex; color: var(--p31-peak);
align-items: center; padding: 0.75rem 2rem;
justify-content: center; box-shadow: 0 2px 8px rgba(0,0,0,0.3);
width: 100px; display: flex; align-items: center; justify-content: space-between;
height: 100px; position: sticky; top: 0; z-index: 50;
background: linear-gradient(135deg, var(--p31-peak), var(--p31-mid)); border-bottom: 1px solid var(--p31-mid);
border-radius: 24px;
font-size: 3rem;
margin-bottom: 1rem;
box-shadow: 0 8px 32px rgba(0,221,68,0.3);
} }
.logo { .navbar .brand {
font-size: 2.5rem; font-weight: bold; font-size: 1.1rem; letter-spacing: 1px;
font-weight: 700;
color: var(--p31-hot);
text-shadow: var(--bloom-text); text-shadow: var(--bloom-text);
letter-spacing: 0.05em;
} }
.tagline { color: var(--text-dim); font-size: 1.1rem; margin-top: 0.5rem; } .navbar .brand a { color: var(--p31-peak); }
.navbar .nav-links { display: flex; gap: 1.5rem; flex-wrap: wrap; }
.navbar .nav-links a {
color: var(--p31-mid); font-size: 0.9rem;
padding: 0.25rem 0.5rem; border-radius: 3px;
transition: all 0.15s;
}
.navbar .nav-links a:hover { color: var(--p31-peak); background: rgba(0,221,68,0.08); text-shadow: var(--bloom-text); }
.navbar .nav-meta { font-size: 0.75rem; color: var(--p31-dim); }
/* Quick Actions */ .container { max-width: 1400px; margin: 0 auto; padding: 2rem; }
.quick-actions {
display: flex; /* Hero */
justify-content: center; .hero {
gap: 1rem; margin-bottom: 2rem;
margin-top: 1.5rem; padding: 1.5rem 2rem;
flex-wrap: wrap;
}
.quick-btn {
padding: 0.6rem 1.2rem;
background: var(--tube-pale); background: var(--tube-pale);
border: 1px solid var(--tube-soft); border: 1px solid var(--p31-mid);
border-radius: 8px;
color: var(--p31-mid);
text-decoration: none;
font-size: 0.9rem;
font-family: inherit;
cursor: pointer;
transition: all 0.2s;
}
.quick-btn:hover {
border-color: var(--p31-peak);
color: var(--p31-peak);
text-shadow: var(--bloom-soft);
}
.quick-btn.primary {
background: rgba(0,221,68,0.1);
border-color: var(--p31-peak);
color: var(--p31-peak);
}
/* Stats Bar */
.stats-bar {
display: flex;
justify-content: center;
gap: 2rem;
margin: 2rem 0;
flex-wrap: wrap;
}
.stat {
text-align: center;
padding: 1rem 1.5rem;
background: var(--tube-pale);
border: 1px solid var(--tube-soft);
border-radius: 12px;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--p31-peak);
text-shadow: 0 0 10px var(--p31-peak);
}
.stat-label {
font-size: 0.75rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.1em;
}
/* Sections */
.section { margin-bottom: 2.5rem; }
.section-title {
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--p31-decay);
text-shadow: var(--bloom-amber);
margin-bottom: 1rem;
padding-left: 0.5rem;
border-left: 3px solid var(--p31-decay);
}
/* Service Grid */
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
}
.service-card {
background: var(--tube-pale);
border: 1px solid var(--tube-soft);
border-radius: 12px;
padding: 1.25rem;
text-align: center;
text-decoration: none;
color: var(--tube-dark);
transition: all 0.2s ease;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.service-card:hover {
border-color: var(--p31-dim);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
}
.service-card .icon {
font-size: 2rem;
width: 50px;
height: 50px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.service-card .name { font-weight: 600; font-size: 0.95rem; }
.service-card .desc { font-size: 0.7rem; color: var(--text-dim); }
.service-card .status {
font-size: 0.65rem;
padding: 2px 8px;
border-radius: 10px;
margin-top: auto;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status.online { background: rgba(0,221,68,0.15); color: var(--green); }
.status.offline { background: rgba(255,68,102,0.15); color: var(--red); }
.status.external { background: rgba(0,212,255,0.15); color: var(--cyan); }
/* Icon backgrounds */
.icon.security { background: rgba(255,68,102,0.1); }
.icon.network { background: rgba(0,212,255,0.1); }
.icon.monitoring { background: rgba(0,221,68,0.1); }
.icon.apps { background: rgba(163,113,247,0.1); }
.icon.comm { background: rgba(255,179,71,0.1); }
/* Published Sites */
.published-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.published-card {
background: var(--tube-pale);
border: 1px solid var(--tube-soft);
border-radius: 12px;
padding: 1.25rem;
text-decoration: none;
color: var(--tube-dark);
transition: all 0.2s;
}
.published-card:hover {
border-color: var(--p31-dim);
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
}
.published-card .title {
font-weight: 600;
font-size: 1rem;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.published-card .domain {
font-size: 0.8rem;
color: var(--cyan);
font-family: monospace;
}
.published-card .meta {
font-size: 0.7rem;
color: var(--text-dim);
margin-top: 0.5rem;
}
.published-card .badge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
font-size: 0.65rem; box-shadow: 0 0 12px rgba(0,221,68,0.15);
text-transform: uppercase; display: grid;
letter-spacing: 0.05em; grid-template-columns: auto 1fr auto;
align-items: center;
gap: 2rem;
} }
.badge.ssl { background: rgba(0,221,68,0.15); color: var(--green); } .hero-score {
.badge.static { background: rgba(163,113,247,0.15); color: var(--purple); } display: flex; flex-direction: column; align-items: center;
.badge.proxy { background: rgba(0,212,255,0.15); color: var(--cyan); } min-width: 140px;
/* Empty State */
.empty {
text-align: center;
padding: 2rem;
color: var(--text-dim);
font-style: italic;
} }
.hero-score .num {
font-size: 3.5rem; font-weight: bold; line-height: 1;
color: var(--p31-peak); text-shadow: var(--bloom-text);
}
.hero-score .label { font-size: 0.8rem; color: var(--p31-dim); margin-top: 0.25rem; letter-spacing: 1px; }
.hero-score.warn .num { color: var(--yellow); text-shadow: var(--bloom-amber); }
.hero-score.crit .num { color: var(--red); text-shadow: var(--bloom-red); }
.hero-info { font-size: 0.95rem; line-height: 1.6; }
.hero-info .kv { display: flex; gap: 1.5rem; flex-wrap: wrap; }
.hero-info .k { color: var(--p31-dim); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; }
.hero-info .v { color: var(--tube-dark); font-weight: bold; }
.hero-refresh { font-size: 0.75rem; color: var(--p31-dim); text-align: right; }
/* Section grid */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.card {
background: var(--tube-light);
border: 1px solid var(--p31-mid);
border-radius: 4px;
padding: 1.25rem;
position: relative;
box-shadow: 0 0 8px rgba(0,221,68,0.08);
}
.card h3 {
font-size: 0.85rem; text-transform: uppercase; letter-spacing: 2px;
color: var(--p31-mid); margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px dashed var(--p31-ghost);
text-shadow: var(--bloom-text);
}
.card .row { display: flex; justify-content: space-between; padding: 0.25rem 0; font-size: 0.9rem; }
.card .row .k { color: var(--p31-dim); }
.card .row .v { color: var(--tube-dark); font-weight: bold; }
.card .row .v.ok { color: var(--p31-peak); text-shadow: var(--bloom-text); }
.card .row .v.warn { color: var(--yellow); text-shadow: var(--bloom-amber); }
.card .row .v.crit { color: var(--red); text-shadow: var(--bloom-red); }
.empty { color: var(--p31-dim); font-style: italic; font-size: 0.85rem; padding: 0.5rem 0; }
/* Bar */
.bar {
height: 4px; background: var(--p31-ghost); border-radius: 2px;
overflow: hidden; margin: 0.25rem 0;
}
.bar > span { display: block; height: 100%; background: var(--p31-peak); }
.bar.warn > span { background: var(--yellow); }
.bar.crit > span { background: var(--red); }
/* Modules LED grid */
.modules { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.5rem; }
.module {
display: flex; flex-direction: column; align-items: center;
gap: 0.25rem; padding: 0.5rem; border-radius: 4px;
background: var(--tube-pale); border: 1px solid var(--p31-ghost);
font-size: 0.7rem;
}
.module .led {
width: 10px; height: 10px; border-radius: 50%;
background: var(--p31-dim);
}
.module.ok .led { background: var(--p31-peak); box-shadow: 0 0 8px var(--p31-peak); }
.module.warn .led { background: var(--yellow); box-shadow: 0 0 8px var(--yellow); }
.module.err .led { background: var(--red); box-shadow: 0 0 8px var(--red); }
.module .name { font-size: 0.65rem; text-transform: uppercase; color: var(--p31-dim); }
/* Tables — vhosts / ASNs */
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th, td { text-align: left; padding: 0.25rem 0.5rem; }
th { font-size: 0.7rem; color: var(--p31-dim); text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px dashed var(--p31-ghost); }
td.num { text-align: right; color: var(--cyan); font-variant-numeric: tabular-nums; }
tr:hover td { background: rgba(0,221,68,0.04); }
/* Big number tiles */
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.5rem; }
.tile {
background: var(--tube-pale); border: 1px solid var(--p31-ghost);
border-radius: 4px; padding: 0.75rem; text-align: center;
}
.tile .v { font-size: 1.75rem; font-weight: bold; color: var(--p31-peak); text-shadow: var(--bloom-text); }
.tile .v.warn { color: var(--yellow); text-shadow: var(--bloom-amber); }
.tile .v.crit { color: var(--red); text-shadow: var(--bloom-red); }
.tile .l { font-size: 0.7rem; text-transform: uppercase; color: var(--p31-dim); letter-spacing: 1px; }
/* Footer */
.footer { .footer {
text-align: center; margin-top: 3rem; padding-top: 1.5rem;
margin-top: 3rem; border-top: 1px dashed var(--p31-ghost);
padding-top: 2rem; text-align: center; font-size: 0.8rem; color: var(--p31-dim);
border-top: 1px solid var(--tube-soft);
color: var(--text-dim);
font-size: 0.8rem;
} }
.footer a { color: var(--p31-mid); text-decoration: none; } .footer a { color: var(--p31-mid); }
.footer a:hover { color: var(--p31-peak); }
/* Health Banner Space */
body { padding-top: 3rem; }
@media (max-width: 768px) { @media (max-width: 768px) {
body { padding: 1rem; padding-top: 3rem; } .hero { grid-template-columns: 1fr; }
.stats-bar { gap: 1rem; } .modules { grid-template-columns: repeat(3, 1fr); }
.stat { padding: 0.75rem 1rem; } .navbar { padding: 0.75rem 1rem; flex-direction: column; gap: 0.5rem; }
.stat-value { font-size: 1.5rem; } .navbar .nav-links { font-size: 0.8rem; }
} }
</style> </style>
</head> </head>
<body class="crt-light"> <body>
<div class="container">
<header class="header">
<div class="logo-icon">🔐</div>
<h1 class="logo" id="logo-text">SecuBox</h1>
<p class="tagline" id="tagline">Secure Network Appliance — Public Portal</p>
<div class="quick-actions">
<a href="/" class="quick-btn">📊 Dashboard</a>
<a href="/c3box/" class="quick-btn">🌐 Services</a>
<a href="/soc/" class="quick-btn">🛡️ SOC</a>
<a href="/login.html" class="quick-btn primary" id="auth-btn">🔑 Login</a>
</div>
</header>
<div class="stats-bar"> <nav class="navbar">
<div class="stat"> <div class="brand"><a href="/portal/">🛡️ SecuBox</a></div>
<div class="stat-value" id="stat-services">-</div> <div class="nav-links">
<div class="stat-label">Services Online</div> <a href="/portal/">PORTAL</a>
</div> <a href="/soc/">SOC</a>
<div class="stat"> <a href="/metablogizer/">METABLOGS</a>
<div class="stat-value" id="stat-sites">-</div> <a href="/publish/">PUBLISH</a>
<div class="stat-label">Published Sites</div> <a href="/cookies/">COOKIES</a>
</div> <a href="https://github.com/CyberMind-FR/secubox-deb" rel="noopener">REPO</a>
<div class="stat"> </div>
<div class="stat-value" id="stat-health">-</div> <div class="nav-meta" id="nav-hostname"></div>
<div class="stat-label">Health Score</div> </nav>
</div>
<div class="stat"> <div class="container">
<div class="stat-value" id="stat-threats">-</div>
<div class="stat-label">Threats Blocked</div> <!-- Hero -->
</div> <section class="hero">
<div class="hero-score" id="hero-score">
<div class="num" id="hero-score-num"></div>
<div class="label">HEALTH</div>
</div>
<div class="hero-info">
<div class="kv">
<div><div class="k">Hostname</div><div class="v" id="hero-hostname"></div></div>
<div><div class="k">Uptime</div><div class="v" id="hero-uptime"></div></div>
<div><div class="k">SSL</div><div class="v" id="hero-ssl"></div></div>
<div><div class="k">VHosts</div><div class="v" id="hero-vhosts"></div></div>
<div><div class="k">Certificates</div><div class="v" id="hero-certs"></div></div>
<div><div class="k">Bans active</div><div class="v" id="hero-bans"></div></div>
</div> </div>
</div>
<div class="hero-refresh">
<div>last refresh</div>
<div id="hero-refresh"></div>
</div>
</section>
<section class="section"> <!-- Module status LEDs -->
<h2 class="section-title">🛡️ Security Services</h2> <section class="card" style="margin-bottom: 1.5rem;">
<div class="services-grid" id="security-services"></div> <h3>SERVICES</h3>
</section> <div class="modules" id="modules-grid">
<div class="empty" style="grid-column: 1/-1;">Loading…</div>
</div>
</section>
<section class="section"> <div class="grid">
<h2 class="section-title">🌐 Network Services</h2>
<div class="services-grid" id="network-services"></div>
</section>
<section class="section"> <!-- System -->
<h2 class="section-title">📦 Applications</h2> <div class="card">
<div class="services-grid" id="apps-services"></div> <h3>System</h3>
</section> <div id="sys-rows"><div class="empty">Loading…</div></div>
<section class="section">
<h2 class="section-title">🚀 Published Sites</h2>
<div class="published-grid" id="published-sites">
<div class="empty">Loading published sites...</div>
</div>
</section>
<section class="section">
<h2 class="section-title">📡 External Services</h2>
<div class="services-grid" id="external-services"></div>
</section>
<footer class="footer">
<p>SecuBox © 2024-2026 <a href="https://cybermind.fr">CyberMind</a></p>
<p style="margin-top: 0.5rem;">Public Portal • <span id="hostname">-</span></p>
</footer>
</div> </div>
<script> <!-- Certs -->
const services = { <div class="card">
security: [ <h3>Certificates</h3>
{ name: 'CrowdSec', icon: '🛡️', desc: 'IDS/IPS Protection', url: '/crowdsec/', iconClass: 'security' }, <div id="cert-rows"><div class="empty">Loading…</div></div>
{ name: 'WAF', icon: '🔥', desc: 'Web Application Firewall', url: '/waf/', iconClass: 'security' }, </div>
{ name: 'WireGuard', icon: '🔒', desc: 'VPN Server', url: '/wireguard/', iconClass: 'security' },
{ name: 'NAC', icon: '👁️', desc: 'Network Access Control', url: '/nac/', iconClass: 'security' }, <!-- Cookie audit / RGPD -->
], <div class="card">
network: [ <h3>Cookie Audit · RGPD</h3>
{ name: 'HAProxy', icon: '⚡', desc: 'Load Balancer', url: '/haproxy/', iconClass: 'network' }, <div id="cookie-rows"><div class="empty">Loading…</div></div>
{ name: 'VHosts', icon: '🌐', desc: 'Virtual Hosts', url: '/vhost/', iconClass: 'network' }, </div>
{ name: 'DNS', icon: '🌍', desc: 'DNS Server', url: '/dns/', iconClass: 'network' },
{ name: 'QoS', icon: '📶', desc: 'Bandwidth Manager', url: '/qos/', iconClass: 'network' }, <!-- Bans (crowdsec decisions count + WAF %) -->
], <div class="card">
apps: [ <h3>Attacks &amp; Bans</h3>
{ name: 'Publish', icon: '🚀', desc: 'Publishing Hub', url: '/publish/', iconClass: 'apps' }, <div class="tiles" id="bans-tiles">
{ name: 'MetaBlogizer', icon: '📝', desc: 'Static Sites', url: '/metablogizer/', iconClass: 'apps' }, <div class="tile"><div class="v" id="t-bans"></div><div class="l">Active bans</div></div>
{ name: 'Droplet', icon: '📁', desc: 'File Publisher', url: '/droplet/', iconClass: 'apps' }, <div class="tile"><div class="v" id="t-waf"></div><div class="l">WAF blocked %</div></div>
{ name: 'Streamlit', icon: '📊', desc: 'Python Apps', url: '/streamlit/', iconClass: 'apps' }, <div class="tile"><div class="v" id="t-alerts"></div><div class="l">Today alerts</div></div>
], </div>
external: [ </div>
{ name: 'Netdata', icon: '📈', desc: 'Real-time Monitoring', url: '/netdata/', iconClass: 'monitoring', external: true },
{ name: 'CDN', icon: '🌐', desc: 'Cache Server', url: '/cdn/', iconClass: 'network' }, </div>
{ name: 'MediaFlow', icon: '🎬', desc: 'Media Streaming', url: '/mediaflow/', iconClass: 'apps' },
] <div class="grid">
<!-- Vhosts (live-hosts top) -->
<div class="card">
<h3>Top Vhosts (last 60 min)</h3>
<div id="vhosts-table"><div class="empty">Loading…</div></div>
</div>
<!-- Attackers (visitor-origin top ASNs) -->
<div class="card">
<h3>Top Visitor ASN</h3>
<div id="asn-table"><div class="empty">Loading…</div></div>
</div>
</div>
<footer class="footer">
<p>SecuBox © 2024-2026 · <a href="https://cybermind.fr">CyberMind</a> · <a href="https://github.com/CyberMind-FR/secubox-deb">source</a></p>
<p style="margin-top: 0.5rem;">Public Portal · <span id="footer-hostname"></span> · auto-refresh 30 s</p>
</footer>
</div>
<script>
(function () {
'use strict';
const REFRESH_MS = 30000;
const ENDPOINTS = {
health: '/api/v1/metrics/health/summary',
certs: '/api/v1/metrics/cert-status',
vhosts: '/api/v1/metrics/live-hosts',
asns: '/api/v1/metrics/visitor-origin',
cookies: '/api/v1/cookie-audit/summary',
decisions: '/api/v1/crowdsec/decisions',
};
function $(id) { return document.getElementById(id); }
function txt(id, v) { const e = $(id); if (e) e.textContent = (v === undefined || v === null) ? '—' : v; }
function fmtUptime(s) {
if (!s) return '—';
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
return `${d}d ${String(h).padStart(2,'0')}h${String(m).padStart(2,'0')}`;
}
function fmtPct(v) {
if (v === null || v === undefined) return '—';
return Number(v).toFixed(0) + '%';
}
function fmtTime(ts) {
if (!ts) return '—';
try { return new Date(ts).toLocaleTimeString('fr-FR'); } catch (e) { return '—'; }
}
async function fetchSafe(url) {
try {
const r = await fetch(url, { credentials: 'omit' });
if (!r.ok) return null;
return await r.json();
} catch (e) { return null; }
}
function renderHero(h, c) {
if (!h) return;
const score = h.score || 0;
txt('hero-score-num', score + '%');
const sc = $('hero-score');
sc.classList.remove('warn', 'crit');
if (score < 70) sc.classList.add('crit');
else if (score < 85) sc.classList.add('warn');
txt('hero-hostname', location.hostname);
txt('nav-hostname', location.hostname);
txt('footer-hostname', location.hostname);
txt('hero-uptime', fmtUptime(h.system && h.system.uptime));
if (h.ssl) {
const d = h.ssl.days_remaining;
txt('hero-ssl', d == null ? '—' : `${d}j (${h.ssl.status || '?'})`);
}
txt('hero-vhosts', h.counts && h.counts.vhosts);
txt('hero-certs', (c && c.summary && c.summary.total) || (h.counts && h.counts.certificates) || '—');
txt('hero-bans', h.crowdsec && h.crowdsec.active_decisions);
txt('hero-refresh', fmtTime(h.timestamp || new Date().toISOString()));
}
function renderModules(h) {
if (!h || !h.modules) return;
const order = ['waf', 'crowdsec', 'haproxy', 'nginx', 'system'];
const icons = { waf: '🛡️', crowdsec: '👮', haproxy: '🌐', nginx: '📡', system: '💻' };
const el = $('modules-grid');
el.innerHTML = order.map(m => {
const mod = h.modules[m] || {};
const status = mod.status || 'off';
const cls = status === 'ok' ? 'ok' : status === 'warn' ? 'warn' : status === 'error' ? 'err' : '';
return `<div class="module ${cls}">
<span class="led"></span>
<span style="font-size:1.4rem;line-height:1">${icons[m]||'📦'}</span>
<span class="name">${m}</span>
</div>`;
}).join('');
}
function renderSystem(h) {
if (!h || !h.system) return;
const s = h.system;
const row = (k, v, pct) => {
const cls = pct == null ? '' : (pct >= 85 ? 'crit' : pct >= 60 ? 'warn' : 'ok');
return `<div class="row"><span class="k">${k}</span><span class="v ${cls}">${v}</span></div>` +
(pct != null ? `<div class="bar ${cls}"><span style="width:${Math.min(100, pct)}%"></span></div>` : '');
}; };
$('sys-rows').innerHTML =
row('CPU', fmtPct(s.cpu), s.cpu) +
row('Memory', fmtPct(s.memory || s.mem_pct), (s.memory || s.mem_pct)) +
row('Disk', fmtPct(s.disk), s.disk) +
row('Load', s.load || '—', null) +
row('LXC', (h.services && h.services.lxc_running) || 0, null);
}
function renderService(svc, status = null) { function renderCerts(c) {
const isOnline = status === null || status; if (!c || !c.summary) {
const statusClass = svc.external ? 'external' : (isOnline ? 'online' : 'offline'); $('cert-rows').innerHTML = '<div class="empty">cert-status disabled</div>';
const statusText = svc.external ? 'External' : (isOnline ? 'Online' : 'Offline'); return;
return `
<a href="${svc.url}" class="service-card">
<div class="icon ${svc.iconClass}">${svc.icon}</div>
<div class="name">${svc.name}</div>
<div class="desc">${svc.desc}</div>
<div class="status ${statusClass}">${statusText}</div>
</a>
`;
} }
const s = c.summary;
const next = c.next_renewal
? `<div class="row"><span class="k">next renewal</span><span class="v">${c.next_renewal.host} · ${c.next_renewal.days}d</span></div>`
: '';
$('cert-rows').innerHTML = `
<div class="row"><span class="k">Total</span><span class="v">${s.total || 0}</span></div>
<div class="row"><span class="k">Valid</span><span class="v ok">${s.valid || 0}</span></div>
<div class="row"><span class="k">Expiring soon</span><span class="v ${s.expiring_soon ? 'warn' : ''}">${s.expiring_soon || 0}</span></div>
<div class="row"><span class="k">Critical</span><span class="v ${s.expiring_critical ? 'crit' : ''}">${s.expiring_critical || 0}</span></div>
<div class="row"><span class="k">Expired</span><span class="v ${s.expired ? 'crit' : ''}">${s.expired || 0}</span></div>
${next}
`;
}
function renderPublishedSite(site) { function renderCookies(d) {
const icon = site.ssl ? '🔒' : '🌐'; if (!d || !d.enabled || !d.summary) {
const typeClass = site.type === 'static' ? 'static' : 'proxy'; $('cookie-rows').innerHTML = '<div class="empty">cookie audit disabled</div>';
return ` return;
<a href="https://${site.domain}" target="_blank" class="published-card">
<div class="title">${icon} ${site.name || site.domain}</div>
<div class="domain">${site.domain}</div>
<div class="meta">
<span class="badge ${site.ssl ? 'ssl' : ''}">${site.ssl ? 'SSL' : 'HTTP'}</span>
<span class="badge ${typeClass}">${site.type || 'site'}</span>
</div>
</a>
`;
} }
const s = d.summary;
const byCat = s.by_category || {};
const violCls = s.violation_count > 0 ? 'crit' : 'ok';
$('cookie-rows').innerHTML = `
<div class="row"><span class="k">Vhosts surveillés</span><span class="v">${s.host_count || 0}</span></div>
<div class="row"><span class="k">RGPD violations</span><span class="v ${violCls}">${s.violation_count || 0}</span></div>
<div class="row"><span class="k">Strictly necessary</span><span class="v">${byCat.strictly_necessary || 0}</span></div>
<div class="row"><span class="k">Functional</span><span class="v">${byCat.functional || 0}</span></div>
<div class="row"><span class="k">Analytics</span><span class="v ${byCat.analytics ? 'warn' : ''}">${byCat.analytics || 0}</span></div>
<div class="row"><span class="k">Marketing</span><span class="v ${byCat.marketing ? 'warn' : ''}">${byCat.marketing || 0}</span></div>
<div class="row"><span class="k">Unclassified</span><span class="v ${byCat.unclassified ? 'warn' : ''}">${byCat.unclassified || 0}</span></div>
`;
}
async function loadStats() { function renderBans(h, decisions) {
try { const ban = h && h.crowdsec && h.crowdsec.active_decisions;
// Health stats const alerts = h && h.crowdsec && h.crowdsec.alerts_today;
const health = await fetch('/api/v1/metrics/health/summary').then(r => r.json()).catch(() => ({})); const wafPct = h && h.waf && h.waf.blocked_pct;
document.getElementById('stat-health').textContent = health.score !== undefined ? health.score : '-'; const wafCls = wafPct == null ? '' : (wafPct >= 25 ? 'crit' : wafPct >= 10 ? 'warn' : 'ok');
const banCls = ban == null ? '' : (ban >= 50 ? 'warn' : 'ok');
$('t-bans').textContent = ban != null ? ban : '—';
$('t-bans').className = 'v ' + banCls;
$('t-waf').textContent = wafPct != null ? wafPct + '%' : '—';
$('t-waf').className = 'v ' + wafCls;
$('t-alerts').textContent = alerts != null ? alerts : '—';
}
// Services count function renderVhosts(d) {
const c3box = await fetch('/api/v1/c3box/services').then(r => r.json()).catch(() => ({})); if (!d || !d.enabled || !d.entries || !d.entries.length) {
document.getElementById('stat-services').textContent = c3box.running !== undefined ? `${c3box.running}/${c3box.total}` : '-'; $('vhosts-table').innerHTML = '<div class="empty">live-hosts disabled or no data</div>';
return;
// CrowdSec threats
const crowdsec = await fetch('/api/v1/crowdsec/stats').then(r => r.json()).catch(() => ({}));
document.getElementById('stat-threats').textContent = crowdsec.alerts_24h !== undefined ? crowdsec.alerts_24h : '-';
} catch (e) {
console.log('Stats load error:', e);
}
} }
$('vhosts-table').innerHTML = `<table>
<thead><tr><th>Host</th><th style="text-align:right">Requests</th></tr></thead>
<tbody>${d.entries.map(e => `<tr><td>${escapeHtml(e.host)}</td><td class="num">${e.count}</td></tr>`).join('')}</tbody>
</table>`;
}
async function loadPublishedSites() { function renderAsns(d) {
const container = document.getElementById('published-sites'); if (!d || !d.enabled || !d.entries || !d.entries.length) {
try { $('asn-table').innerHTML = '<div class="empty">visitor-origin disabled (missing GeoLite2-ASN.mmdb)</div>';
// Try vhost API return;
const vhosts = await fetch('/api/v1/vhost/list').then(r => r.json()).catch(() => ({ vhosts: [] }));
if (vhosts.vhosts && vhosts.vhosts.length > 0) {
const sites = vhosts.vhosts.map(v => ({
domain: v.domain || v.name,
name: v.name || v.domain,
ssl: v.ssl || v.https || false,
type: v.type || 'proxy'
}));
document.getElementById('stat-sites').textContent = sites.length;
container.innerHTML = sites.map(renderPublishedSite).join('');
} else {
// Fallback: try publish API
const publish = await fetch('/api/v1/publish/sites').then(r => r.json()).catch(() => ({ sites: [] }));
if (publish.sites && publish.sites.length > 0) {
document.getElementById('stat-sites').textContent = publish.sites.length;
container.innerHTML = publish.sites.map(s => renderPublishedSite({
domain: s.domain || s.url,
name: s.name || s.title,
ssl: true,
type: s.type || 'static'
})).join('');
} else {
document.getElementById('stat-sites').textContent = '0';
container.innerHTML = '<div class="empty">No published sites yet</div>';
}
}
} catch (e) {
document.getElementById('stat-sites').textContent = '-';
container.innerHTML = '<div class="empty">Could not load published sites</div>';
}
} }
$('asn-table').innerHTML = `<table>
<thead><tr><th>ASN</th><th>Org</th><th style="text-align:right">Hits</th></tr></thead>
<tbody>${d.entries.map(e => `<tr><td>AS${e.asn}</td><td>${escapeHtml(e.org)}</td><td class="num">${e.count}</td></tr>`).join('')}</tbody>
</table>`;
}
async function loadTheme() { function escapeHtml(s) {
try { const div = document.createElement('div');
const theme = await fetch('/api/v1/portal/theme').then(r => r.json()).catch(() => null); div.textContent = s == null ? '' : String(s);
if (theme && theme.theme) { return div.innerHTML;
document.getElementById('logo-text').textContent = theme.theme.logo_text || 'SecuBox'; }
document.getElementById('tagline').textContent = theme.theme.logo_sub || 'Secure Network Appliance';
}
} catch (e) {}
}
function checkAuth() { async function refresh() {
const token = document.cookie.includes('secubox_token'); const [h, c, v, a, k, dec] = await Promise.all([
if (token) { fetchSafe(ENDPOINTS.health),
const btn = document.getElementById('auth-btn'); fetchSafe(ENDPOINTS.certs),
btn.textContent = '🚪 Logout'; fetchSafe(ENDPOINTS.vhosts),
btn.href = '#'; fetchSafe(ENDPOINTS.asns),
btn.onclick = async (e) => { fetchSafe(ENDPOINTS.cookies),
e.preventDefault(); fetchSafe(ENDPOINTS.decisions),
await fetch('/api/v1/portal/logout', { method: 'POST', credentials: 'include' }); ]);
location.reload(); renderHero(h, c);
}; renderModules(h);
} renderSystem(h);
} renderCerts(c);
renderCookies(k);
renderBans(h, dec);
renderVhosts(v);
renderAsns(a);
}
function render() { refresh();
document.getElementById('security-services').innerHTML = services.security.map(s => renderService(s)).join(''); setInterval(refresh, REFRESH_MS);
document.getElementById('network-services').innerHTML = services.network.map(s => renderService(s)).join(''); })();
document.getElementById('apps-services').innerHTML = services.apps.map(s => renderService(s)).join(''); </script>
document.getElementById('external-services').innerHTML = services.external.map(s => renderService(s)).join('');
document.getElementById('hostname').textContent = location.hostname;
}
// Initialize
render();
loadStats();
loadPublishedSites();
loadTheme();
checkAuth();
// Refresh stats every 30s
setInterval(loadStats, 30000);
</script>
<script src="/shared/health-banner.js" defer></script>
</body> </body>
</html> </html>

View File

@ -1,9 +1,20 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# SecuBox MetaCtl — ISP Home Publish CLI # SPDX-License-Identifier: LicenseRef-CMSD-1.0
# CyberMind — https://cybermind.fr # Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
#
# publishctl — SecuBox ISP Home Publish CLI (issue #180)
#
# Renamed from `metactl` for naming consistency with the rest of the
# SecuBox grammar (haproxyctl/giteactl/mitmproxyctl/metablogizerctl/
# dropletctl/streamlitctl/streamforgectl). The old `metactl` name remains
# as a symlink for backward compatibility — to drop in a future major.
#
# Flat verbs are now also reachable under the `post` noun dispatch
# for grammar consistency (publishctl post upload <file>, etc). Flat
# top-level verbs preserved for backward compatibility.
set -euo pipefail set -euo pipefail
VERSION="1.0.0" VERSION="2.0.0"
API_BASE="${SECUBOX_API_BASE:-http://127.0.0.1/api/v1/publish}" API_BASE="${SECUBOX_API_BASE:-http://127.0.0.1/api/v1/publish}"
METABLOGIZER_API="${SECUBOX_METABLOGIZER_API:-http://127.0.0.1/api/v1/metablogizer}" METABLOGIZER_API="${SECUBOX_METABLOGIZER_API:-http://127.0.0.1/api/v1/metablogizer}"
TOKEN_FILE="${SECUBOX_TOKEN_FILE:-/etc/secubox/secrets/jwt-token}" TOKEN_FILE="${SECUBOX_TOKEN_FILE:-/etc/secubox/secrets/jwt-token}"
@ -408,8 +419,39 @@ cmd_health() {
fi fi
} }
# post noun dispatch (issue #180 — grammar consistency, parallel to
# `giteactl repo`, `mitmproxyctl route`, `dropletctl file`, etc).
# Delegates to the existing flat cmd_* functions; both grammars supported.
cmd_post() {
local act="${1:-}"; shift || true
case "$act" in
upload) cmd_upload "$@" ;;
publish) cmd_publish "$@" ;;
unpublish) cmd_unpublish "$@" ;;
list|ls) cmd_list ;;
download) cmd_download "$@" ;;
qrcode|qr) cmd_qrcode "$@" ;;
health) cmd_health "$@" ;;
*)
cat <<EOF
Post commands (issue #180):
post upload <file.zip> [name] [--domain=D] [--auto-publish]
post publish <name>
post unpublish <name>
post list
post download <name> [output.zip]
post qrcode <name>
post health <domain>
EOF
;;
esac
}
# Main # Main
case "${1:-help}" in case "${1:-help}" in
# noun-verb grammar (issue #180)
post) shift; cmd_post "$@" ;;
# flat verbs (backward-compat — same callbacks)
upload) shift; cmd_upload "$@" ;; upload) shift; cmd_upload "$@" ;;
publish) shift; cmd_publish "$@" ;; publish) shift; cmd_publish "$@" ;;
unpublish) shift; cmd_unpublish "$@" ;; unpublish) shift; cmd_unpublish "$@" ;;
@ -419,10 +461,10 @@ case "${1:-help}" in
status) cmd_status ;; status) cmd_status ;;
health) shift; cmd_health "$@" ;; health) shift; cmd_health "$@" ;;
-h|--help|help) usage ;; -h|--help|help) usage ;;
-v|--version) echo "metactl v${VERSION}" ;; -v|--version) echo "publishctl v${VERSION}" ;;
*) *)
echo -e "${RED}Unknown command:${NC} $1" echo -e "${RED}Unknown command:${NC} $1"
echo "Run 'metactl --help' for usage" echo "Run 'publishctl --help' for usage"
exit 1 exit 1
;; ;;
esac esac

View File

@ -1,3 +1,20 @@
secubox-publish (2.0.0-1~bookworm1) bookworm; urgency=medium
* Rename `metactl` -> `publishctl` for naming consistency with the rest
of the SecuBox ctl grammar (issue #180). The `metactl` name remains
as a symlink for backward compatibility — to drop in a future major.
* publishctl: add `post` noun dispatch so verbs are grouped under a
coherent <noun> <verb> schema parallel to giteactl/dropletctl/
metablogizerctl. Flat top-level verbs preserved as alias.
publishctl post upload <file.zip> [name] [--auto-publish]
publishctl post publish/unpublish <name>
publishctl post list/download/qrcode/health ...
* Bumped to 2.0.0 (CLI surface rename).
-- Gerald KERMA <devel@cybermind.fr> Sun, 17 May 2026 11:38:19 +0200
secubox-publish (1.0.0-1~bookworm1) bookworm; urgency=medium secubox-publish (1.0.0-1~bookworm1) bookworm; urgency=medium
* Initial release * Initial release

View File

@ -12,9 +12,11 @@ override_dh_auto_install:
# Modular nginx config # Modular nginx config
install -d debian/secubox-publish/etc/nginx/secubox.d install -d debian/secubox-publish/etc/nginx/secubox.d
[ -f nginx/publish.conf ] && cp nginx/publish.conf debian/secubox-publish/etc/nginx/secubox.d/ || true [ -f nginx/publish.conf ] && cp nginx/publish.conf debian/secubox-publish/etc/nginx/secubox.d/ || true
# CLI tool # CLI tool — primary `publishctl` + `metactl` symlink for backward compat (#180)
install -d debian/secubox-publish/usr/sbin install -d debian/secubox-publish/usr/sbin
[ -f bin/metactl ] && install -m 755 bin/metactl debian/secubox-publish/usr/sbin/metactl || true [ -f bin/publishctl ] && install -m 755 bin/publishctl debian/secubox-publish/usr/sbin/publishctl || true
[ -f debian/secubox-publish/usr/sbin/publishctl ] && \
ln -sf publishctl debian/secubox-publish/usr/sbin/metactl || true
# Plugins directory # Plugins directory
install -d debian/secubox-publish/srv/secubox/modules/publish/plugins install -d debian/secubox-publish/srv/secubox/modules/publish/plugins
[ -d plugins ] && cp -r plugins/. debian/secubox-publish/srv/secubox/modules/publish/plugins/ || true [ -d plugins ] && cp -r plugins/. debian/secubox-publish/srv/secubox/modules/publish/plugins/ || true

View File

@ -1,3 +1,15 @@
secubox-streamforge (1.0.2-1~bookworm1) bookworm; urgency=medium
* Forge streamforgectl (issue #183). Subcommands: lifecycle (start/stop/
restart/status/logs), Three-fold JSON (components/access), project
noun (create/remove/list/start/stop/restart/info/templates) wrapping
the /api/v1/streamforge/app* endpoints over the Unix socket.
* Paired with streamlitctl on the hosting side — forge -> host workflow
expressible end-to-end (forge create/edit -> export to git -> stream
deploy).
-- Gerald KERMA <devel@cybermind.fr> Sun, 17 May 2026 11:34:28 +0200
secubox-streamforge (1.0.1-1~bookworm1) bookworm; urgency=medium secubox-streamforge (1.0.1-1~bookworm1) bookworm; urgency=medium
* Add dynamic menu system with menu.d JSON definitions * Add dynamic menu system with menu.d JSON definitions

View File

@ -12,3 +12,6 @@ override_dh_auto_install:
# Modular nginx config # Modular nginx config
install -d debian/secubox-streamforge/etc/nginx/secubox.d install -d debian/secubox-streamforge/etc/nginx/secubox.d
[ -f nginx/streamforge.conf ] && cp nginx/streamforge.conf debian/secubox-streamforge/etc/nginx/secubox.d/ || true [ -f nginx/streamforge.conf ] && cp nginx/streamforge.conf debian/secubox-streamforge/etc/nginx/secubox.d/ || true
# streamforgectl (#183)
install -d debian/secubox-streamforge/usr/sbin
install -m 755 sbin/streamforgectl debian/secubox-streamforge/usr/sbin/

View File

@ -0,0 +1,143 @@
#!/bin/bash
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# streamforgectl — SecuBox StreamForge (Streamlit dev workbench) control (#183)
#
# Parallel to streamlitctl (the hosting side). The Forge ↔ Streamlit pair:
# streamforgectl project create <name> --template hello
# streamforgectl project export <name> <gitea_url>
# streamlitctl app deploy <name> <gitea_url>
# Three verbs, two layers (dev → hosting), one expressible workflow.
set -euo pipefail
VERSION="0.1.0"
SOCKET="${STREAMFORGE_SOCKET:-/run/secubox/streamforge.sock}"
API="http://localhost/api/v1/streamforge"
SERVICE="secubox-streamforge.service"
G='\033[0;32m'; Y='\033[1;33m'; R='\033[0;31m'; N='\033[0m'
log() { printf "${G}[FORGE]${N} %s\n" "$*"; }
warn() { printf "${Y}[WARN]${N} %s\n" "$*"; }
error() { printf "${R}[ERROR]${N} %s\n" "$*" >&2; }
api() {
local m="$1" p="$2"; shift 2
[ -S "$SOCKET" ] || { error "socket $SOCKET absent — start $SERVICE"; exit 2; }
curl --unix-socket "$SOCKET" -sS -X "$m" -w "\nHTTP_CODE:%{http_code}\n" "${API}${p}" "$@"
}
api_code() { echo "$1" | grep '^HTTP_CODE:' | cut -d: -f2; }
api_body() { echo "$1" | sed '/^HTTP_CODE:/d'; }
# Lifecycle
cmd_start() { systemctl start "$SERVICE" && log started; }
cmd_stop() { systemctl stop "$SERVICE" && log stopped; }
cmd_restart() { systemctl restart "$SERVICE" && log restarted; }
cmd_status() { systemctl is-active "$SERVICE" >/dev/null && echo active || echo inactive; }
cmd_logs() { journalctl -u "$SERVICE" -n "${1:-50}" --no-pager; }
cmd_components() {
cat <<EOF
{"service":"$SERVICE","socket":"$SOCKET","api_base":"$API","ctl_version":"$VERSION"}
EOF
}
cmd_access() {
cat <<EOF
{"socket":"$SOCKET","endpoints":{
"apps":"GET /apps","templates":"GET /templates",
"create":"POST /app","get":"GET /app/{name}","remove":"DELETE /app/{name}",
"start":"POST /app/{name}/start","stop":"POST /app/{name}/stop","restart":"POST /app/{name}/restart",
"file_get":"GET /app/{name}/file/{path}","file_put":"PUT /app/{name}/file/{path}"
}}
EOF
}
# Project (the noun, #183)
cmd_project() {
local act="${1:-}"; shift || true
case "$act" in
create) project_create "$@" ;;
remove|rm|delete) project_remove "$@" ;;
list|ls) project_list "$@" ;;
start) project_start "$@" ;;
stop) project_stop "$@" ;;
restart) project_restart "$@" ;;
info) project_info "$@" ;;
templates) project_templates ;;
*)
cat <<EOF
Project commands:
project create <name> [--template hello] [--description "..."]
project remove <name>
project list
project start <name> (start the project's streamlit dev server)
project stop <name>
project restart <name>
project info <name>
project templates (list available templates)
EOF
;;
esac
}
project_create() {
local name="$1"; shift || { error "project create <name> required"; return 1; }
local template="hello" desc=""
while [ $# -gt 0 ]; do
case "$1" in
--template) template="$2"; shift ;;
--description) desc="$2"; shift ;;
*) error "unknown flag: $1"; return 1 ;;
esac; shift
done
log "creating project '$name' from template '$template'"
local body
body=$(printf '{"name":"%s","template":"%s","description":"%s"}' "$name" "$template" "$desc")
local out; out=$(api POST "/app" -H "Content-Type: application/json" -d "$body")
[ "$(api_code "$out")" = "200" ] || { error "create failed: $(api_body "$out" | head -2)"; return 1; }
log "created"; api_body "$out"
}
project_remove() {
local name="$1"; [ -z "$name" ] && { error "project remove <name>"; return 1; }
local out; out=$(api DELETE "/app/${name}")
case "$(api_code "$out")" in
200|204) log "removed $name" ;;
*) error "remove failed: $(api_body "$out")"; return 1 ;;
esac
}
project_list() { api_body "$(api GET "/apps")"; }
project_start() { local n="$1"; [ -z "$n" ] && return 1; api_body "$(api POST "/app/${n}/start")"; }
project_stop() { local n="$1"; [ -z "$n" ] && return 1; api_body "$(api POST "/app/${n}/stop")"; }
project_restart() { local n="$1"; [ -z "$n" ] && return 1; api_body "$(api POST "/app/${n}/restart")"; }
project_info() { local n="$1"; [ -z "$n" ] && return 1; api_body "$(api GET "/app/${n}")"; }
project_templates() { api_body "$(api GET "/templates")"; }
show_help() {
cat <<EOF
SecuBox StreamForge Controller v$VERSION (issue #183)
Streamlit dev workbench CLI — paired with streamlitctl (hosting layer)
Lifecycle: start / stop / restart / status / logs
Three-fold: components / access (JSON)
Project (#183): project create / remove / list / start / stop / restart / info / templates
Example workflow (forge → host):
streamforgectl project create dashboard --template basic
streamforgectl project start dashboard
# ...iterate via the webui at /streamforge/...
streamforgectl project export dashboard gitea://secubox/dashboard.git # TODO
streamlitctl app deploy dashboard gitea://secubox/dashboard.git
EOF
}
case "${1:-}" in
start|stop|restart|status) c="$1"; shift; cmd_$c "$@" ;;
logs) shift; cmd_logs "$@" ;;
components) cmd_components ;;
access) cmd_access ;;
project) shift; cmd_project "$@" ;;
help|--help|-h|'') show_help ;;
*) error "unknown: $1"; show_help; exit 1 ;;
esac

View File

@ -1,3 +1,13 @@
secubox-streamlit (1.2.1-1~bookworm1) bookworm; urgency=medium
* streamlitctl: add `app info <name>` and `app restart <name>` verbs
(issue #182). The original audit underestimated the existing ctl
surface — app list/start/stop/deploy/remove/logs were already wired.
What was actually missing: `info` (metadata + runtime state) and
`restart` (stop+start with port preservation from .streamlit.toml).
-- Gerald KERMA <devel@cybermind.fr> Sun, 17 May 2026 11:36:06 +0200
secubox-streamlit (1.2.0-1~bookworm1) bookworm; urgency=medium secubox-streamlit (1.2.0-1~bookworm1) bookworm; urgency=medium
* streamlitctl v1.0.0: Full Debian LXC installation support * streamlitctl v1.0.0: Full Debian LXC installation support

View File

@ -318,6 +318,51 @@ EOF
echo ']}' echo ']}'
} }
# app info <name> — print metadata + runtime state from manifest + pid file (#182)
cmd_app_info() {
local name="$1"
[ -z "$name" ] && { error "Usage: streamlitctl app info <name>"; return 1; }
local d="$APPS_PATH/$name"
[ -d "$d" ] || { error "app not found: $name"; return 1; }
# Entry point detection (same logic as cmd_app_start)
local entry=""
for c in app.py main.py streamlit_app.py; do
[ -f "$d/$c" ] && entry="$c" && break
done
local port=""
[ -f "$d/.streamlit.toml" ] && port=$(grep -E "^port" "$d/.streamlit.toml" 2>/dev/null | cut -d= -f2 | tr -d ' ')
local pidf="/var/run/streamlit-${name}.pid"
local pid="" alive="no"
if lxc_running; then
pid=$(lxc-attach -n "$LXC_NAME" -- cat "$pidf" 2>/dev/null || true)
if [ -n "$pid" ]; then
lxc-attach -n "$LXC_NAME" -- kill -0 "$pid" >/dev/null 2>&1 && alive="yes"
fi
fi
cat <<EOF
name: $name
path: $d
entrypoint: ${entry:-(none)}
port: ${port:-(unset)}
pid_file: $pidf
pid: ${pid:-(none)}
running: $alive
EOF
}
# app restart <name> — stop + start (#182)
cmd_app_restart() {
local name="$1"
[ -z "$name" ] && { error "Usage: streamlitctl app restart <name>"; return 1; }
cmd_app_stop "$name" || true
sleep 1
# Recover the previous port from .streamlit.toml so restart preserves it
local port=""
[ -f "$APPS_PATH/$name/.streamlit.toml" ] && \
port=$(grep -E "^port" "$APPS_PATH/$name/.streamlit.toml" 2>/dev/null | cut -d= -f2 | tr -d ' ')
cmd_app_start "$name" "${port:-8501}"
}
cmd_app_start() { cmd_app_start() {
local name="$1" local name="$1"
local port="${2:-8501}" local port="${2:-8501}"
@ -694,7 +739,9 @@ case "${1:-}" in
deploy) cmd_app_deploy "$3" "$4" ;; deploy) cmd_app_deploy "$3" "$4" ;;
remove) cmd_app_remove "$3" ;; remove) cmd_app_remove "$3" ;;
logs) cmd_app_logs "$3" "$4" ;; logs) cmd_app_logs "$3" "$4" ;;
*) echo "Usage: streamlitctl app {list|start|stop|deploy|remove|logs} [args]" ;; info) cmd_app_info "$3" ;;
restart) cmd_app_restart "$3" ;;
*) echo "Usage: streamlitctl app {list|start|stop|restart|deploy|remove|logs|info} [args]" ;;
esac esac
;; ;;

View File

@ -178,16 +178,30 @@ check_prerequisites() {
fi fi
fi fi
# Charger les modules nécessaires # Charger les modules nécessaires. dwc2 must be loaded FIRST — it creates
# the UDC node that the gadget functions bind to. Historically dwc2 was
# loaded implicitly by secubox-eye-gadget.service's ExecStartPre; with
# that service disabled at boot (storage-only mode is opt-in), the
# gadget chain now owns its own dwc2 modprobe explicitly.
modprobe dwc2 2>/dev/null || true
modprobe libcomposite 2>/dev/null || true modprobe libcomposite 2>/dev/null || true
modprobe usb_f_ecm 2>/dev/null || true modprobe usb_f_ecm 2>/dev/null || true
modprobe usb_f_rndis 2>/dev/null || true modprobe usb_f_rndis 2>/dev/null || true
modprobe usb_f_acm 2>/dev/null || true modprobe usb_f_acm 2>/dev/null || true
modprobe usb_f_mass_storage 2>/dev/null || true modprobe usb_f_mass_storage 2>/dev/null || true
# dwc2 binds asynchronously to the BCM USB controller — wait up to 5s
# for the UDC node to appear (typically <500ms on a Pi Zero W).
for _ in 1 2 3 4 5 6 7 8 9 10; do
if [[ -d /sys/class/udc ]] && [[ -n "$(ls /sys/class/udc 2>/dev/null)" ]]; then
break
fi
sleep 0.5
done
# Vérifier la présence d'un UDC (USB Device Controller) # Vérifier la présence d'un UDC (USB Device Controller)
if [[ ! -d /sys/class/udc ]] || [[ -z "$(ls /sys/class/udc 2>/dev/null)" ]]; then if [[ ! -d /sys/class/udc ]] || [[ -z "$(ls /sys/class/udc 2>/dev/null)" ]]; then
err "Aucun UDC trouvé — ce script doit être exécuté sur un RPi Zero W" err "Aucun UDC trouvé — vérifier dtoverlay=dwc2 dans /boot/config.txt et module dwc2 dans /etc/modules"
return 1 return 1
fi fi

View File

@ -93,10 +93,15 @@ LOGO_PATHS = [
Path("/etc/secubox/eye-remote/assets/phoenix_logo.png"), Path("/etc/secubox/eye-remote/assets/phoenix_logo.png"),
] ]
# Icon paths - module icons # Icon paths - module icons.
# Order matters: first existing path wins per icon name. /var/www/common/
# holds the real brand icons (auth, wall, boot, mind, root, mesh) installed
# by build-eye-remote-image.sh; the local /usr/lib/secubox-eye/assets/icons/
# fallback path holds the round-specific placeholder set.
ICON_PATHS = [ ICON_PATHS = [
Path("/tmp/assets/icons"), Path("/tmp/assets/icons"),
Path("/etc/secubox/eye-remote/assets/icons"), Path("/etc/secubox/eye-remote/assets/icons"),
Path("/var/www/common/assets/icons"),
Path(__file__).parent.parent.parent.parent / "assets" / "icons", Path(__file__).parent.parent.parent.parent / "assets" / "icons",
] ]

View File

@ -650,9 +650,14 @@ mkdir -p "$ROOT_MNT/etc/systemd/system/dnsmasq.service.d"
cp "$SCRIPT_DIR/files/etc/systemd/system/dnsmasq.service.d/secubox-eye.conf" \ cp "$SCRIPT_DIR/files/etc/systemd/system/dnsmasq.service.d/secubox-eye.conf" \
"$ROOT_MNT/etc/systemd/system/dnsmasq.service.d/" "$ROOT_MNT/etc/systemd/system/dnsmasq.service.d/"
# Enable new gadget service # secubox-eye-gadget is STORAGE-only mode for U-Boot rescue. Enabling it
ln -sf /etc/systemd/system/secubox-eye-gadget.service \ # at boot alongside secubox-otg-gadget.service (composite ECM+ACM) caused
"$ROOT_MNT/etc/systemd/system/multi-user.target.wants/" # UDC contention. Install the unit but DO NOT enable at boot.
# Manual U-Boot rescue mode:
# systemctl disable secubox-otg-gadget.service
# systemctl enable --now secubox-eye-gadget.service
# ln -sf /etc/systemd/system/secubox-eye-gadget.service \
# "$ROOT_MNT/etc/systemd/system/multi-user.target.wants/"
# Copy framebuffer dashboard (Pi Zero W has no NEON, can't run Chromium) # Copy framebuffer dashboard (Pi Zero W has no NEON, can't run Chromium)
log "Installing framebuffer dashboard..." log "Installing framebuffer dashboard..."
@ -684,18 +689,34 @@ if [[ -f "$SCRIPT_DIR/secubox-eye-agent.service" && -f "$SCRIPT_DIR/config.toml.
# Install agent service # Install agent service
cp "$SCRIPT_DIR/secubox-eye-agent.service" "$ROOT_MNT/etc/systemd/system/" cp "$SCRIPT_DIR/secubox-eye-agent.service" "$ROOT_MNT/etc/systemd/system/"
# Enable agent service via symlink (atomic, no chroot needed) # The agent depends on Pydantic v2 (pydantic_core, Rust) which has no
# ARMv6 wheel — pip ships an ARMv7 wheel that crashes with SIGILL on
# the Pi Zero W BCM2835 (status=4/ILL). v2.2.1 design moved metrics
# rendering to secubox-fallback-display.service (pure-Python Pillow),
# so we install the unit but DO NOT enable at boot. ARMv7+ boards:
# systemctl enable --now secubox-eye-agent.service
mkdir -p "$ROOT_MNT/etc/systemd/system/multi-user.target.wants" mkdir -p "$ROOT_MNT/etc/systemd/system/multi-user.target.wants"
ln -sf /etc/systemd/system/secubox-eye-agent.service \ # ln -sf /etc/systemd/system/secubox-eye-agent.service \
"$ROOT_MNT/etc/systemd/system/multi-user.target.wants/" # "$ROOT_MNT/etc/systemd/system/multi-user.target.wants/"
# v2.2.0: Install menu system icons for radial menu # v2.2.0: Install menu system icons for radial menu
if [[ -d "$SCRIPT_DIR/assets/icons" ]]; then if [[ -d "$SCRIPT_DIR/assets/icons" ]]; then
log "Installing menu system icons..." log "Installing menu system icons..."
mkdir -p "$ROOT_MNT/usr/lib/secubox-eye/assets/icons" mkdir -p "$ROOT_MNT/usr/lib/secubox-eye/assets/icons"
cp "$SCRIPT_DIR/assets/icons"/*.png "$ROOT_MNT/usr/lib/secubox-eye/assets/icons/" 2>/dev/null || true cp "$SCRIPT_DIR/assets/icons"/*.png "$ROOT_MNT/usr/lib/secubox-eye/assets/icons/" 2>/dev/null || true
# Defense-in-depth: also drop the brand-icon PNGs (auth/wall/boot/
# mind/root/mesh) from remote-ui/common/assets/icons/ so any consumer
# that resolves icons via /usr/lib/secubox-eye/ finds them. The
# fallback_manager also searches /var/www/common/assets/icons/
# directly (where build copies common/ in another step) — this is
# redundant shipping for resilience.
_COMMON_ICONS="$(dirname "$SCRIPT_DIR")/common/assets/icons"
if [[ -d "$_COMMON_ICONS" ]]; then
cp -n "$_COMMON_ICONS"/*.png \
"$ROOT_MNT/usr/lib/secubox-eye/assets/icons/" 2>/dev/null || true
fi
ICON_COUNT=$(ls "$ROOT_MNT/usr/lib/secubox-eye/assets/icons"/*.png 2>/dev/null | wc -l) ICON_COUNT=$(ls "$ROOT_MNT/usr/lib/secubox-eye/assets/icons"/*.png 2>/dev/null | wc -l)
log "Installed $ICON_COUNT menu icons" log "Installed $ICON_COUNT menu icons (round/ + common/ brand)"
fi fi
else else
warn "Eye Agent files not found, skipping installation" warn "Eye Agent files not found, skipping installation"
@ -766,7 +787,10 @@ ln -sf /etc/systemd/system/eye-firstboot-hostname.service "$ROOT_MNT/etc/systemd
ln -sf /etc/systemd/system/hyperpixel2r-init.service "$ROOT_MNT/etc/systemd/system/multi-user.target.wants/" 2>/dev/null || true ln -sf /etc/systemd/system/hyperpixel2r-init.service "$ROOT_MNT/etc/systemd/system/multi-user.target.wants/" 2>/dev/null || true
# Eye Remote services # Eye Remote services
ln -sf /etc/systemd/system/secubox-eye-gadget.service "$ROOT_MNT/etc/systemd/system/multi-user.target.wants/" 2>/dev/null || true # secubox-eye-gadget (storage-only, U-Boot rescue) is NOT enabled at boot
# to avoid UDC contention with secubox-otg-gadget.service. See comment at
# line 653 for the manual rescue-mode recipe.
# ln -sf /etc/systemd/system/secubox-eye-gadget.service "$ROOT_MNT/etc/systemd/system/multi-user.target.wants/" 2>/dev/null || true
# v2.2.1: Use fallback-display instead of eye-agent (3D cube + rainbow rings, stable) # v2.2.1: Use fallback-display instead of eye-agent (3D cube + rainbow rings, stable)
ln -sf /etc/systemd/system/secubox-fallback-display.service "$ROOT_MNT/etc/systemd/system/multi-user.target.wants/" 2>/dev/null || true ln -sf /etc/systemd/system/secubox-fallback-display.service "$ROOT_MNT/etc/systemd/system/multi-user.target.wants/" 2>/dev/null || true
# NOTE: secubox-eye-agent is broken (import errors) - disabled pending fix # NOTE: secubox-eye-agent is broken (import errors) - disabled pending fix

View File

@ -17,15 +17,19 @@ After=systemd-modules-load.service
Before=network-pre.target Before=network-pre.target
Wants=network-pre.target Wants=network-pre.target
# Conditions : seulement sur un périphérique avec UDC (RPi Zero W) # Conditions : configfs disponible (UDC est crééable dynamiquement via dwc2,
ConditionPathIsDirectory=/sys/class/udc # voir ExecStartPre — pas de pré-condition stricte sur /sys/class/udc).
ConditionPathExists=/sys/kernel/config ConditionPathExists=/sys/kernel/config
[Service] [Service]
Type=oneshot Type=oneshot
RemainAfterExit=yes RemainAfterExit=yes
# Chargement des modules nécessaires # Chargement des modules nécessaires. dwc2 est chargé en premier pour créer
# le UDC (sinon le gadget ne peut s'attacher). secubox-eye-gadget.service
# le chargeait historiquement avant; depuis qu'il est désactivé au boot
# (storage-only mode opt-in), on doit le charger ici.
ExecStartPre=/sbin/modprobe dwc2
ExecStartPre=/sbin/modprobe libcomposite ExecStartPre=/sbin/modprobe libcomposite
ExecStartPre=/sbin/modprobe usb_f_ecm ExecStartPre=/sbin/modprobe usb_f_ecm
ExecStartPre=/sbin/modprobe usb_f_acm ExecStartPre=/sbin/modprobe usb_f_acm