mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 09:08:32 +00:00
Compare commits
12 Commits
b62adc5421
...
8905228cbd
| Author | SHA1 | Date | |
|---|---|---|---|
| 8905228cbd | |||
|
|
9a0a9873a7 | ||
|
|
c2f1682c59 | ||
| 382b4cb7dc | |||
| 33f6c1f68e | |||
| d58460ebaf | |||
|
|
4f19c604c7 | ||
|
|
81168ff49a | ||
|
|
199e52b5cb | ||
|
|
b97e36cdeb | ||
|
|
7c273e2132 | ||
|
|
986b18b163 |
|
|
@ -90,6 +90,7 @@ while [[ $# -gt 0 ]]; do
|
|||
--size) IMG_SIZE="$2"; shift 2 ;;
|
||||
--embed-image) EMBED_IMAGE="$2"; shift 2 ;;
|
||||
--local-cache) USE_LOCAL_CACHE=1; shift ;;
|
||||
--slipstream) SLIPSTREAM_DEBS=1; shift ;;
|
||||
--no-slipstream) SLIPSTREAM_DEBS=0; shift ;;
|
||||
--no-compress) NO_COMPRESS=1; shift ;;
|
||||
--no-led) INCLUDE_LED_KERNEL=0; shift ;;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
* Add dynamic menu system with menu.d JSON definitions
|
||||
|
|
|
|||
|
|
@ -12,3 +12,6 @@ override_dh_auto_install:
|
|||
# Modular nginx config
|
||||
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
|
||||
# dropletctl (issue #181)
|
||||
install -d debian/secubox-droplet/usr/sbin
|
||||
install -m 755 sbin/dropletctl debian/secubox-droplet/usr/sbin/
|
||||
|
|
|
|||
225
packages/secubox-droplet/sbin/dropletctl
Normal file
225
packages/secubox-droplet/sbin/dropletctl
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
* Fix startup script: add PATH and HOME environment variables
|
||||
|
|
|
|||
|
|
@ -672,6 +672,277 @@ except Exception as e:
|
|||
" 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
|
||||
# ============================================================================
|
||||
|
|
@ -1093,6 +1364,14 @@ Repos & Mirrors (issue #176, parallel to mitmproxyctl route):
|
|||
repo mirror sync OWNER/NAME
|
||||
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 [name] Create backup
|
||||
restore <file> Restore from backup
|
||||
|
|
@ -1129,6 +1408,7 @@ case "${1:-}" in
|
|||
restart) shift; cmd_restart "$@" ;;
|
||||
user) shift; cmd_user "$@" ;;
|
||||
repo) shift; cmd_repo "$@" ;;
|
||||
runner) shift; cmd_runner "$@" ;;
|
||||
backup) shift; cmd_backup "$@" ;;
|
||||
restore) shift; cmd_restore "$@" ;;
|
||||
migrate) shift; cmd_migrate "$@" ;;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
* Add metablogizerctl three-fold commands: components, access (JSON output)
|
||||
|
|
|
|||
|
|
@ -194,6 +194,155 @@ site_unpublish() {
|
|||
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() {
|
||||
echo "MetaBlogizer Sites:"
|
||||
echo "==================="
|
||||
|
|
@ -387,6 +536,12 @@ Sites:
|
|||
site unpublish <name> Unpublish site
|
||||
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:
|
||||
migrate [host] Migrate from OpenWrt
|
||||
|
||||
|
|
@ -394,6 +549,7 @@ Examples:
|
|||
metablogizerctl components # JSON components
|
||||
metablogizerctl site create myblog blog.example.com
|
||||
metablogizerctl site publish myblog
|
||||
metablogizerctl tor expose myblog # Emancipate via Tor
|
||||
metablogizerctl migrate 192.168.255.1
|
||||
|
||||
EOF
|
||||
|
|
@ -422,6 +578,8 @@ case "${1:-}" in
|
|||
*) echo "Usage: metablogizerctl site create|delete|publish|unpublish|list" ;;
|
||||
esac
|
||||
;;
|
||||
# Tor (Emancipate, issue #184)
|
||||
tor) shift; cmd_tor "$@" ;;
|
||||
migrate) shift; cmd_migrate "$@" ;;
|
||||
help|--help|-h|'') show_help ;;
|
||||
*) error "Unknown: $1"; exit 1 ;;
|
||||
|
|
|
|||
49
packages/secubox-metrics/bin/secubox-geoipupdate-fetch
Executable file
49
packages/secubox-metrics/bin/secubox-geoipupdate-fetch
Executable 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)"
|
||||
|
|
@ -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
|
||||
|
||||
* Add Cookie Audit (RGPD / ePrivacy) endpoints: POST /api/v1/cookie-audit/ingest,
|
||||
|
|
|
|||
|
|
@ -39,3 +39,7 @@ override_dh_install:
|
|||
debian/secubox-metrics/lib/systemd/system/secubox-geoipupdate.service
|
||||
install -D -m 0644 systemd/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
|
||||
|
|
|
|||
|
|
@ -27,6 +27,13 @@ RestartSec=5
|
|||
# ProtectSystem=full
|
||||
NoNewPrivileges=true
|
||||
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]
|
||||
WantedBy=multi-user.target
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
[Unit]
|
||||
Description=SecuBox — refresh GeoLite2 ASN database
|
||||
ConditionPathExists=/etc/secubox/secrets/maxmind.conf
|
||||
Description=SecuBox — refresh GeoLite2-ASN database (MaxMind or DB-IP fallback)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=secubox
|
||||
Group=secubox
|
||||
ExecStart=/usr/bin/geoipupdate -f /etc/secubox/secrets/maxmind.conf -d /var/lib/GeoIP
|
||||
NoNewPrivileges=true
|
||||
# Fetcher must run as root because /var/lib/GeoIP is owned by root and the
|
||||
# DB-IP fallback path uses `install -o secubox` to land the mmdb under the
|
||||
# correct ownership. The helper does not exec anything as root that takes
|
||||
# untrusted input.
|
||||
User=root
|
||||
ExecStart=/usr/bin/secubox-geoipupdate-fetch
|
||||
ProtectSystem=full
|
||||
ReadWritePaths=/var/lib/GeoIP
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
* Add device-specific theming based on detected board
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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 href="https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/shared/crt-light.css">
|
||||
|
|
@ -20,16 +21,13 @@
|
|||
--tube-pale: #c8e6c9;
|
||||
--tube-soft: #a5d6a7;
|
||||
--tube-dark: #1b1b1f;
|
||||
--text-dim: var(--p31-dim);
|
||||
--primary: var(--p31-peak);
|
||||
--cyan: #00d4ff;
|
||||
--green: var(--p31-peak);
|
||||
--red: #ff4466;
|
||||
--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-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-red: 0 0 3px var(--red), 0 0 10px rgba(255,68,102,0.4);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
|
|
@ -38,471 +36,462 @@
|
|||
background-image: radial-gradient(ellipse at 50% 40%, rgba(51,255,102,0.025) 0%, transparent 70%);
|
||||
color: var(--tube-dark);
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
position: fixed; inset: 0;
|
||||
background-image:
|
||||
linear-gradient(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;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
pointer-events: none; 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 */
|
||||
.header { text-align: center; margin-bottom: 3rem; }
|
||||
.logo-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: linear-gradient(135deg, var(--p31-peak), var(--p31-mid));
|
||||
border-radius: 24px;
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 8px 32px rgba(0,221,68,0.3);
|
||||
/* Navbar */
|
||||
.navbar {
|
||||
background: var(--tube-dark);
|
||||
color: var(--p31-peak);
|
||||
padding: 0.75rem 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
border-bottom: 1px solid var(--p31-mid);
|
||||
}
|
||||
.logo {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--p31-hot);
|
||||
.navbar .brand {
|
||||
font-weight: bold; font-size: 1.1rem; letter-spacing: 1px;
|
||||
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 */
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.quick-btn {
|
||||
padding: 0.6rem 1.2rem;
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 2rem; }
|
||||
|
||||
/* Hero */
|
||||
.hero {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.5rem 2rem;
|
||||
background: var(--tube-pale);
|
||||
border: 1px solid var(--tube-soft);
|
||||
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: 1px solid var(--p31-mid);
|
||||
border-radius: 4px;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
box-shadow: 0 0 12px rgba(0,221,68,0.15);
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
.badge.ssl { background: rgba(0,221,68,0.15); color: var(--green); }
|
||||
.badge.static { background: rgba(163,113,247,0.15); color: var(--purple); }
|
||||
.badge.proxy { background: rgba(0,212,255,0.15); color: var(--cyan); }
|
||||
|
||||
/* Empty State */
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
.hero-score {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
min-width: 140px;
|
||||
}
|
||||
.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 {
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--tube-soft);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 3rem; padding-top: 1.5rem;
|
||||
border-top: 1px dashed var(--p31-ghost);
|
||||
text-align: center; font-size: 0.8rem; color: var(--p31-dim);
|
||||
}
|
||||
.footer a { color: var(--p31-mid); text-decoration: none; }
|
||||
.footer a:hover { color: var(--p31-peak); }
|
||||
|
||||
/* Health Banner Space */
|
||||
body { padding-top: 3rem; }
|
||||
|
||||
.footer a { color: var(--p31-mid); }
|
||||
@media (max-width: 768px) {
|
||||
body { padding: 1rem; padding-top: 3rem; }
|
||||
.stats-bar { gap: 1rem; }
|
||||
.stat { padding: 0.75rem 1rem; }
|
||||
.stat-value { font-size: 1.5rem; }
|
||||
.hero { grid-template-columns: 1fr; }
|
||||
.modules { grid-template-columns: repeat(3, 1fr); }
|
||||
.navbar { padding: 0.75rem 1rem; flex-direction: column; gap: 0.5rem; }
|
||||
.navbar .nav-links { font-size: 0.8rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="crt-light">
|
||||
<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>
|
||||
<body>
|
||||
|
||||
<div class="stats-bar">
|
||||
<div class="stat">
|
||||
<div class="stat-value" id="stat-services">-</div>
|
||||
<div class="stat-label">Services Online</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value" id="stat-sites">-</div>
|
||||
<div class="stat-label">Published Sites</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value" id="stat-health">-</div>
|
||||
<div class="stat-label">Health Score</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value" id="stat-threats">-</div>
|
||||
<div class="stat-label">Threats Blocked</div>
|
||||
</div>
|
||||
<nav class="navbar">
|
||||
<div class="brand"><a href="/portal/">🛡️ SecuBox</a></div>
|
||||
<div class="nav-links">
|
||||
<a href="/portal/">PORTAL</a>
|
||||
<a href="/soc/">SOC</a>
|
||||
<a href="/metablogizer/">METABLOGS</a>
|
||||
<a href="/publish/">PUBLISH</a>
|
||||
<a href="/cookies/">COOKIES</a>
|
||||
<a href="https://github.com/CyberMind-FR/secubox-deb" rel="noopener">REPO</a>
|
||||
</div>
|
||||
<div class="nav-meta" id="nav-hostname">—</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<!-- Hero -->
|
||||
<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 class="hero-refresh">
|
||||
<div>last refresh</div>
|
||||
<div id="hero-refresh">—</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section-title">🛡️ Security Services</h2>
|
||||
<div class="services-grid" id="security-services"></div>
|
||||
</section>
|
||||
<!-- Module status LEDs -->
|
||||
<section class="card" style="margin-bottom: 1.5rem;">
|
||||
<h3>SERVICES</h3>
|
||||
<div class="modules" id="modules-grid">
|
||||
<div class="empty" style="grid-column: 1/-1;">Loading…</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section-title">🌐 Network Services</h2>
|
||||
<div class="services-grid" id="network-services"></div>
|
||||
</section>
|
||||
<div class="grid">
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section-title">📦 Applications</h2>
|
||||
<div class="services-grid" id="apps-services"></div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<!-- System -->
|
||||
<div class="card">
|
||||
<h3>System</h3>
|
||||
<div id="sys-rows"><div class="empty">Loading…</div></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const services = {
|
||||
security: [
|
||||
{ name: 'CrowdSec', icon: '🛡️', desc: 'IDS/IPS Protection', url: '/crowdsec/', iconClass: 'security' },
|
||||
{ name: 'WAF', icon: '🔥', desc: 'Web Application Firewall', url: '/waf/', iconClass: 'security' },
|
||||
{ name: 'WireGuard', icon: '🔒', desc: 'VPN Server', url: '/wireguard/', iconClass: 'security' },
|
||||
{ name: 'NAC', icon: '👁️', desc: 'Network Access Control', url: '/nac/', iconClass: 'security' },
|
||||
],
|
||||
network: [
|
||||
{ name: 'HAProxy', icon: '⚡', desc: 'Load Balancer', url: '/haproxy/', iconClass: 'network' },
|
||||
{ name: 'VHosts', icon: '🌐', desc: 'Virtual Hosts', url: '/vhost/', iconClass: 'network' },
|
||||
{ name: 'DNS', icon: '🌍', desc: 'DNS Server', url: '/dns/', iconClass: 'network' },
|
||||
{ name: 'QoS', icon: '📶', desc: 'Bandwidth Manager', url: '/qos/', iconClass: 'network' },
|
||||
],
|
||||
apps: [
|
||||
{ name: 'Publish', icon: '🚀', desc: 'Publishing Hub', url: '/publish/', iconClass: 'apps' },
|
||||
{ name: 'MetaBlogizer', icon: '📝', desc: 'Static Sites', url: '/metablogizer/', iconClass: 'apps' },
|
||||
{ name: 'Droplet', icon: '📁', desc: 'File Publisher', url: '/droplet/', iconClass: 'apps' },
|
||||
{ name: 'Streamlit', icon: '📊', desc: 'Python Apps', url: '/streamlit/', iconClass: 'apps' },
|
||||
],
|
||||
external: [
|
||||
{ name: 'Netdata', icon: '📈', desc: 'Real-time Monitoring', url: '/netdata/', iconClass: 'monitoring', external: true },
|
||||
{ name: 'CDN', icon: '🌐', desc: 'Cache Server', url: '/cdn/', iconClass: 'network' },
|
||||
{ name: 'MediaFlow', icon: '🎬', desc: 'Media Streaming', url: '/mediaflow/', iconClass: 'apps' },
|
||||
]
|
||||
<!-- Certs -->
|
||||
<div class="card">
|
||||
<h3>Certificates</h3>
|
||||
<div id="cert-rows"><div class="empty">Loading…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Cookie audit / RGPD -->
|
||||
<div class="card">
|
||||
<h3>Cookie Audit · RGPD</h3>
|
||||
<div id="cookie-rows"><div class="empty">Loading…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Bans (crowdsec decisions count + WAF %) -->
|
||||
<div class="card">
|
||||
<h3>Attacks & Bans</h3>
|
||||
<div class="tiles" id="bans-tiles">
|
||||
<div class="tile"><div class="v" id="t-bans">—</div><div class="l">Active bans</div></div>
|
||||
<div class="tile"><div class="v" id="t-waf">—</div><div class="l">WAF blocked %</div></div>
|
||||
<div class="tile"><div class="v" id="t-alerts">—</div><div class="l">Today alerts</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<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) {
|
||||
const isOnline = status === null || status;
|
||||
const statusClass = svc.external ? 'external' : (isOnline ? 'online' : 'offline');
|
||||
const statusText = svc.external ? 'External' : (isOnline ? 'Online' : 'Offline');
|
||||
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>
|
||||
`;
|
||||
function renderCerts(c) {
|
||||
if (!c || !c.summary) {
|
||||
$('cert-rows').innerHTML = '<div class="empty">cert-status disabled</div>';
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
const icon = site.ssl ? '🔒' : '🌐';
|
||||
const typeClass = site.type === 'static' ? 'static' : 'proxy';
|
||||
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>
|
||||
`;
|
||||
function renderCookies(d) {
|
||||
if (!d || !d.enabled || !d.summary) {
|
||||
$('cookie-rows').innerHTML = '<div class="empty">cookie audit disabled</div>';
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
try {
|
||||
// Health stats
|
||||
const health = await fetch('/api/v1/metrics/health/summary').then(r => r.json()).catch(() => ({}));
|
||||
document.getElementById('stat-health').textContent = health.score !== undefined ? health.score : '-';
|
||||
function renderBans(h, decisions) {
|
||||
const ban = h && h.crowdsec && h.crowdsec.active_decisions;
|
||||
const alerts = h && h.crowdsec && h.crowdsec.alerts_today;
|
||||
const wafPct = h && h.waf && h.waf.blocked_pct;
|
||||
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
|
||||
const c3box = await fetch('/api/v1/c3box/services').then(r => r.json()).catch(() => ({}));
|
||||
document.getElementById('stat-services').textContent = c3box.running !== undefined ? `${c3box.running}/${c3box.total}` : '-';
|
||||
|
||||
// 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);
|
||||
}
|
||||
function renderVhosts(d) {
|
||||
if (!d || !d.enabled || !d.entries || !d.entries.length) {
|
||||
$('vhosts-table').innerHTML = '<div class="empty">live-hosts disabled or no data</div>';
|
||||
return;
|
||||
}
|
||||
$('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() {
|
||||
const container = document.getElementById('published-sites');
|
||||
try {
|
||||
// Try vhost API
|
||||
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>';
|
||||
}
|
||||
function renderAsns(d) {
|
||||
if (!d || !d.enabled || !d.entries || !d.entries.length) {
|
||||
$('asn-table').innerHTML = '<div class="empty">visitor-origin disabled (missing GeoLite2-ASN.mmdb)</div>';
|
||||
return;
|
||||
}
|
||||
$('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() {
|
||||
try {
|
||||
const theme = await fetch('/api/v1/portal/theme').then(r => r.json()).catch(() => null);
|
||||
if (theme && theme.theme) {
|
||||
document.getElementById('logo-text').textContent = theme.theme.logo_text || 'SecuBox';
|
||||
document.getElementById('tagline').textContent = theme.theme.logo_sub || 'Secure Network Appliance';
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = s == null ? '' : String(s);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function checkAuth() {
|
||||
const token = document.cookie.includes('secubox_token');
|
||||
if (token) {
|
||||
const btn = document.getElementById('auth-btn');
|
||||
btn.textContent = '🚪 Logout';
|
||||
btn.href = '#';
|
||||
btn.onclick = async (e) => {
|
||||
e.preventDefault();
|
||||
await fetch('/api/v1/portal/logout', { method: 'POST', credentials: 'include' });
|
||||
location.reload();
|
||||
};
|
||||
}
|
||||
}
|
||||
async function refresh() {
|
||||
const [h, c, v, a, k, dec] = await Promise.all([
|
||||
fetchSafe(ENDPOINTS.health),
|
||||
fetchSafe(ENDPOINTS.certs),
|
||||
fetchSafe(ENDPOINTS.vhosts),
|
||||
fetchSafe(ENDPOINTS.asns),
|
||||
fetchSafe(ENDPOINTS.cookies),
|
||||
fetchSafe(ENDPOINTS.decisions),
|
||||
]);
|
||||
renderHero(h, c);
|
||||
renderModules(h);
|
||||
renderSystem(h);
|
||||
renderCerts(c);
|
||||
renderCookies(k);
|
||||
renderBans(h, dec);
|
||||
renderVhosts(v);
|
||||
renderAsns(a);
|
||||
}
|
||||
|
||||
function render() {
|
||||
document.getElementById('security-services').innerHTML = services.security.map(s => renderService(s)).join('');
|
||||
document.getElementById('network-services').innerHTML = services.network.map(s => renderService(s)).join('');
|
||||
document.getElementById('apps-services').innerHTML = services.apps.map(s => renderService(s)).join('');
|
||||
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>
|
||||
refresh();
|
||||
setInterval(refresh, REFRESH_MS);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,20 @@
|
|||
#!/usr/bin/env bash
|
||||
# SecuBox MetaCtl — ISP Home Publish CLI
|
||||
# CyberMind — https://cybermind.fr
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# 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
|
||||
|
||||
VERSION="1.0.0"
|
||||
VERSION="2.0.0"
|
||||
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}"
|
||||
TOKEN_FILE="${SECUBOX_TOKEN_FILE:-/etc/secubox/secrets/jwt-token}"
|
||||
|
|
@ -408,8 +419,39 @@ cmd_health() {
|
|||
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
|
||||
case "${1:-help}" in
|
||||
# noun-verb grammar (issue #180)
|
||||
post) shift; cmd_post "$@" ;;
|
||||
# flat verbs (backward-compat — same callbacks)
|
||||
upload) shift; cmd_upload "$@" ;;
|
||||
publish) shift; cmd_publish "$@" ;;
|
||||
unpublish) shift; cmd_unpublish "$@" ;;
|
||||
|
|
@ -419,10 +461,10 @@ case "${1:-help}" in
|
|||
status) cmd_status ;;
|
||||
health) shift; cmd_health "$@" ;;
|
||||
-h|--help|help) usage ;;
|
||||
-v|--version) echo "metactl v${VERSION}" ;;
|
||||
-v|--version) echo "publishctl v${VERSION}" ;;
|
||||
*)
|
||||
echo -e "${RED}Unknown command:${NC} $1"
|
||||
echo "Run 'metactl --help' for usage"
|
||||
echo "Run 'publishctl --help' for usage"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
|
@ -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
|
||||
|
||||
* Initial release
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ override_dh_auto_install:
|
|||
# Modular nginx config
|
||||
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
|
||||
# CLI tool
|
||||
# CLI tool — primary `publishctl` + `metactl` symlink for backward compat (#180)
|
||||
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
|
||||
install -d debian/secubox-publish/srv/secubox/modules/publish/plugins
|
||||
[ -d plugins ] && cp -r plugins/. debian/secubox-publish/srv/secubox/modules/publish/plugins/ || true
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
* Add dynamic menu system with menu.d JSON definitions
|
||||
|
|
|
|||
|
|
@ -12,3 +12,6 @@ override_dh_auto_install:
|
|||
# Modular nginx config
|
||||
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
|
||||
# streamforgectl (#183)
|
||||
install -d debian/secubox-streamforge/usr/sbin
|
||||
install -m 755 sbin/streamforgectl debian/secubox-streamforge/usr/sbin/
|
||||
|
|
|
|||
143
packages/secubox-streamforge/sbin/streamforgectl
Executable file
143
packages/secubox-streamforge/sbin/streamforgectl
Executable 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
|
||||
|
|
@ -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
|
||||
|
||||
* streamlitctl v1.0.0: Full Debian LXC installation support
|
||||
|
|
|
|||
|
|
@ -318,6 +318,51 @@ EOF
|
|||
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() {
|
||||
local name="$1"
|
||||
local port="${2:-8501}"
|
||||
|
|
@ -694,7 +739,9 @@ case "${1:-}" in
|
|||
deploy) cmd_app_deploy "$3" "$4" ;;
|
||||
remove) cmd_app_remove "$3" ;;
|
||||
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
|
||||
;;
|
||||
|
||||
|
|
|
|||
|
|
@ -178,16 +178,30 @@ check_prerequisites() {
|
|||
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 usb_f_ecm 2>/dev/null || true
|
||||
modprobe usb_f_rndis 2>/dev/null || true
|
||||
modprobe usb_f_acm 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)
|
||||
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
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -93,10 +93,15 @@ LOGO_PATHS = [
|
|||
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 = [
|
||||
Path("/tmp/assets/icons"),
|
||||
Path("/etc/secubox/eye-remote/assets/icons"),
|
||||
Path("/var/www/common/assets/icons"),
|
||||
Path(__file__).parent.parent.parent.parent / "assets" / "icons",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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" \
|
||||
"$ROOT_MNT/etc/systemd/system/dnsmasq.service.d/"
|
||||
|
||||
# Enable new gadget service
|
||||
ln -sf /etc/systemd/system/secubox-eye-gadget.service \
|
||||
"$ROOT_MNT/etc/systemd/system/multi-user.target.wants/"
|
||||
# secubox-eye-gadget is STORAGE-only mode for U-Boot rescue. Enabling it
|
||||
# at boot alongside secubox-otg-gadget.service (composite ECM+ACM) caused
|
||||
# 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)
|
||||
log "Installing framebuffer dashboard..."
|
||||
|
|
@ -684,18 +689,34 @@ if [[ -f "$SCRIPT_DIR/secubox-eye-agent.service" && -f "$SCRIPT_DIR/config.toml.
|
|||
# Install agent service
|
||||
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"
|
||||
ln -sf /etc/systemd/system/secubox-eye-agent.service \
|
||||
"$ROOT_MNT/etc/systemd/system/multi-user.target.wants/"
|
||||
# ln -sf /etc/systemd/system/secubox-eye-agent.service \
|
||||
# "$ROOT_MNT/etc/systemd/system/multi-user.target.wants/"
|
||||
|
||||
# v2.2.0: Install menu system icons for radial menu
|
||||
if [[ -d "$SCRIPT_DIR/assets/icons" ]]; then
|
||||
log "Installing menu system 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
|
||||
# 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)
|
||||
log "Installed $ICON_COUNT menu icons"
|
||||
log "Installed $ICON_COUNT menu icons (round/ + common/ brand)"
|
||||
fi
|
||||
else
|
||||
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
|
||||
|
||||
# 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)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -17,15 +17,19 @@ After=systemd-modules-load.service
|
|||
Before=network-pre.target
|
||||
Wants=network-pre.target
|
||||
|
||||
# Conditions : seulement sur un périphérique avec UDC (RPi Zero W)
|
||||
ConditionPathIsDirectory=/sys/class/udc
|
||||
# Conditions : configfs disponible (UDC est crééable dynamiquement via dwc2,
|
||||
# voir ExecStartPre — pas de pré-condition stricte sur /sys/class/udc).
|
||||
ConditionPathExists=/sys/kernel/config
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
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 usb_f_ecm
|
||||
ExecStartPre=/sbin/modprobe usb_f_acm
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user