Compare commits

...

12 Commits

Author SHA1 Message Date
CyberMind
4f19c604c7
feat(giteactl): forge runner noun verbs — LXC-only CI execution (closes #190) (#191)
Some checks failed
License Headers / check (push) Failing after 5s
Sixth routing verb of SecuBox, parallel to:
  haproxyctl   vhost  add/remove    (routing)
  mitmproxyctl route  add/remove    (interception, #173)
  giteactl     repo   mirror add    (replication, #176)
  giteactl     user   add/remove    (identity)
  giteactl     runner add/remove    (CI execution, this — #190)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:32:21 +02:00
CyberMind
39b0665678
feat(giteactl): forge repo mirror add/remove/sync/list verb (closes #176) (#179)
Third routing verb of SecuBox, parallel to:
  haproxyctl vhost add/remove       (routing layer)
  mitmproxyctl route add/remove     (interception layer, #173)
  giteactl repo mirror add/remove   (replication layer, this PR)

Without this verb, setting up a github→gitea pull mirror required a
5-step API dance with three failure modes:
  1. No "convert existing repo to mirror" — must DELETE + migrate
  2. Minimum mirror interval = 10m (returns HTTP 500 below)
  3. Filesystem fetches don't trigger Gitea hooks — DB stays empty
The 5h debug session during today's gitea-actions migration A2 phase
exposed all three.

The new verb wraps the dance into one atomic, idempotent call:
  - probes existing state, idempotent if already mirror (just updates interval)
  - --force converts a non-mirror to mirror (DELETE + migrate) with explicit
    opt-in to data loss
  - normalizes interval to the Gitea 10m floor silently with a warn
  - triggers mirror-sync after migrate so the DB picks up the content
  - generates a short-lived admin token via gitea CLI inside the LXC,
    revokes after use (no token stored at rest)

Also fixes pre-existing alignment gaps:
  - LXC_PATH default: /srv/lxc -> auto-detect across /data/lxc /srv/lxc
    /var/lib/lxc (board reality is /data/lxc, not /srv/lxc)
  - LXC_IP default: 192.168.255.40 -> 10.100.0.40 (board reality)
  - Adds GITEA_APP_INI + ADMIN_USER as configurable defaults

Subcommands wired:
  giteactl repo create OWNER/NAME [--private] [--default-branch BR]
  giteactl repo delete OWNER/NAME --force
  giteactl repo list [OWNER]
  giteactl repo mirror add OWNER/NAME GITHUB_URL [--interval 10m] [--force]
  giteactl repo mirror remove OWNER/NAME
  giteactl repo mirror sync OWNER/NAME
  giteactl repo mirror list

Live-tested on gk2 board: command unblocked the gitea-actions migration
A2 phase that had been stuck for hours. github→gitea pull mirror for
secubox/secubox-deb went from setup-to-content in one command call.

  giteactl repo mirror add secubox/secubox-deb \
    https://github.com/CyberMind-FR/secubox-deb.git --interval 10m --force

  --> mirror live, sync every 10m, master HEAD picked up github's
      5e8a2b02 ("ci: mirror .github/workflows -> .gitea/workflows")
      within 60s of setup.

Bumped to 1.5.0.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:02:40 +02:00
CyberMind
5e8a2b02c1
ci: mirror .github/workflows -> .gitea/workflows (closes #177) (#178)
Phase A3 of the GitHub Actions -> Gitea Actions migration. All 12
workflows copied byte-identical from .github/workflows/ so Gitea Actions
on the self-hosted instance at gitea.gk2.secubox.in picks them up on
every push (once the pull mirror, blocked on #176, is live).

Compatibility:
  - runs-on: ubuntu-latest         matches act_runner ubuntu-latest label
  - uses: actions/checkout@v4 etc  auto-fetched via DEFAULT_ACTIONS_URL=github
                                   (set in /var/lib/gitea/custom/conf/app.ini
                                    during Phase A1)
  - Secrets NOT auto-imported by mirror — re-add per repo in Gitea
    Settings -> Actions -> Secrets (GPG_PRIVATE_KEY, DEPLOY_SSH_KEY, ...)
  - Workflows that shell out to `gh` (GitHub CLI) will fail until
    migrated to `tea` (Gitea CLI) — per-workflow follow-ups.

Source of truth remains .github/workflows/; this is a mirror. Divergence
between the two trees is a bug. README.md in the new dir captures the
contract.

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 10:56:49 +02:00
531fd878a6 fix(sync): drop 10.100.0.10 from DEAD_CONTAINER_IPS — mail LXC lives there
The mail Phase 1 LXC went live at 10.100.0.10 (see #136, PR #141,
35ba4c2c). The 'dead container fix' was still rewriting routes that
target 10.100.0.10 to 10.100.0.1:9080, which would have clobbered
mail.gk2 / webmail.gk2 / rspamd.gk2 / autoconfig.gk2 routes. The
production gk2 board already had this correction in its local copy
(diffed 2026-05-17); bringing the repo back in sync so deploying
the script doesn't regress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 10:42:07 +02:00
4a73cc245c fix(sync): mitmproxy-routes regex anchored at \s|$ instead of space-only
The previous regex `host_\K[a-z0-9_]+(?= )` required a trailing SPACE
after the hostname. HAProxy `use_backend mitmproxy_inspector if
host_<dotted_domain>` lines end at the hostname with no trailing space,
so the regex matched ZERO domains. Sync silently never auto-populated
mitmproxy routes for any haproxyctl-added vhost.

Caught 2026-05-17 when ckwa.gk2.secubox.in returned 502: the vhost was
added via `haproxyctl vhost add ckwa.gk2.secubox.in mitmproxy_inspector
ssl` (HAProxy ACL created correctly), but sync didn't write the
mitmproxy route entry, so the request landed at mitmproxy's default
(127.0.0.1:9080 placeholder) which doesn't exist inside the mitmproxy
LXC.

Fix: change lookahead to `(?=\s|$)` to also match end-of-line. Verified:

  $ printf '    use_backend ... if host_x_y_z\n' | \
    grep -oP 'host_\K[a-z0-9_]+(?=\s|$)' | sed 's/_/./g'
  x.y.z

Live patch applied on gk2 (mitmproxy LXC) to restore ckwa to 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 10:39:44 +02:00
8e8708f95d ci+packaging: unblock v2.9.0 release — 4 package fixes + partial-release resilience
Five package builds failed on v2.9.0 first attempt, and the release
pipeline cascaded to skip all downstream jobs (collect/publish/images/
live-usb/create-release) because GH Actions treats *any* matrix failure
as a global failure for `needs:` purposes. This commit fixes both
root causes:

Package fixes:
- secubox-eye-square: drop debian/compat (conflicted with control's
  `Build-Depends: debhelper-compat (= 13)`)
- secubox-defaults: same — drop debian/compat
- secubox-metoblizer: switch from legacy `Build-Depends: debhelper
  (>= 13)` (which then requires a debian/compat file) to the modern
  `Build-Depends: debhelper-compat (= 13)` virtual package
- secubox-haproxy: override dh_usrlocal — it can only rehome /usr/local
  directories (Policy 9.1.2), not the individual admin tools we
  deliberately drop there per issue #44

Pipeline resilience:
- build-packages.yml `collect` job: `if: always() && != cancelled` so we
  bundle whatever .deb files the matrix produced even when entries failed
- build-packages.yml `publish` job: predicate now reads collect's success
  rather than the matrix's overall conclusion
- release.yml `build-images`, `build-live-usb`, `publish`,
  `create-release`: all gain `if: always() && needs.build-packages.result
  != 'cancelled'` so a partial build matrix doesn't black-hole the
  release. `create-release` already gracefully skips its
  `if: needs.build-images.result == 'success'` download steps when
  images failed, so the partial release ships what's available.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 10:14:50 +02:00
CyberMind
b21b2000d8
feat(mitmproxyctl): align with LXC reality + forge route verb (closes #173) (#174)
mitmproxyctl is SecuBox's third routing verb, parallel to:
  haproxyctl vhost add/remove   (routing layer)
  giteactl   user add/remove    (identity layer)
  mitmproxyctl route add/remove (interception layer)  ← new

Without this verb, every WAF route change required hand-editing
/srv/mitmproxy/haproxy-routes.json + manual restart. The interception
organ existed but could not be commanded.

Changes:
  * Defaults now match board reality:
      CONTAINER_NAME : "mitmproxy-waf" -> "mitmproxy"
      LXC_PATH       : "/var/lib/lxc"  -> "/data/lxc"
    All overridable via [container] in /etc/secubox/mitmproxy.toml.
  * Added --config flag to load any TOML.
  * New `route` subcommand:
      mitmproxyctl route list
      mitmproxyctl route add HOST IP PORT [--no-restart]
      mitmproxyctl route remove HOST       [--no-restart]
    Atomic writes via .tmp + replace, mirror to the LXC-rootfs copy of
    haproxy-routes.json automatically, restart prefers in-LXC systemd
    `mitmproxy.service` reload over full container bounce.
  * `restart` is now reload-friendly when an in-LXC systemd unit exists
    (the common case on the live board); falls back to stop+start otherwise.
  * `logs` reads via `journalctl -u mitmproxy` in-LXC (was `tail` on a
    file path that didn't exist in practice).

After this lands, the gitea WAF unbypass (done by hand today during the
#156 cascade) reduces to:
  haproxyctl vhost add gitea.gk2.secubox.in mitmproxy_inspector ssl
  mitmproxyctl route add gitea.gk2.secubox.in 192.168.1.200 9080

Three verbs, three layers, one operation. The grammar closes.

Out of scope (separate ticket): the package's debian/postinst still
assumes host-installed mitmproxy under /srv/mitmproxy-waf/, while the
modern board runs the LXC topology this ctl now targets. That refactor
will follow as a postinst -> LXC-bootstrap migration similar to mailctl.

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

View File

@ -0,0 +1,38 @@
# Gitea Actions workflows
This directory mirrors `.github/workflows/` for the self-hosted Gitea
Actions runner at `gitea.gk2.secubox.in` (board-internal CI).
## Source of truth
`.github/workflows/*.yml` is the source of truth — workflows are edited
there and mirrored here. The two trees should stay byte-identical;
divergence is a bug.
Eventual goal: a single CI definition tree consumed by both runners.
For now, the mirror is mechanical (cp) and tracked in PRs that touch
both directories together.
## Compatibility notes
- `runs-on: ubuntu-latest` works because the `act_runner` registered on
the dev box advertises the `ubuntu-latest` label (Phase B of the
migration, separate ticket).
- `uses: actions/*` references are auto-fetched from `github.com` thanks
to `DEFAULT_ACTIONS_URL = github` in
`/var/lib/gitea/custom/conf/app.ini` (set during Phase A1).
- Secrets are NOT carried by repo mirroring — re-add in
Gitea Settings → Actions → Secrets per repo:
`GPG_PRIVATE_KEY`, `DEPLOY_SSH_KEY`, `DEPLOY_KNOWN_HOSTS`, etc.
- Some workflows use `gh` (GitHub CLI) which is not installed by default
on `act_runner`. Those jobs will fail visibly until they are migrated
to `tea` (Gitea CLI) — to be addressed per-workflow.
## Refs
- Phase A1: Gitea Actions enabled in app.ini (done 2026-05-17)
- Phase A2: pull mirror github → gitea — blocked on
[#176](https://github.com/CyberMind-FR/secubox-deb/issues/176)
(missing `giteactl repo mirror` verb)
- Phase A3: this directory ([#177](https://github.com/CyberMind-FR/secubox-deb/issues/177))
- Phase B: install + register `act_runner` (separate ticket TBD)

View File

@ -0,0 +1,324 @@
name: Build SecuBox Live USB (All Platforms)
on:
workflow_call:
inputs:
platform:
description: 'Target platform(s)'
type: string
default: 'all'
secrets:
GPG_PRIVATE_KEY:
required: false
workflow_dispatch:
inputs:
platform:
description: 'Target platform'
type: choice
options:
- all
- x64
- mochabin
- rpi400
default: all
compress:
description: 'Compress output (gzip)'
type: boolean
default: true
push:
tags:
- 'v*'
env:
DEBIAN_SUITE: bookworm
jobs:
# Matrix build for all platforms
build-live-usb:
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
# x64 (amd64) - GRUB UEFI/BIOS boot
- platform: x64
arch: amd64
script: build-live-usb.sh
artifact_name: secubox-live-amd64
output_pattern: "secubox-live-amd64-*.img*"
needs_qemu: false
embed_image: false
# MOCHAbin (arm64) - U-Boot distroboot
- platform: mochabin
arch: arm64
script: build-mochabin-live-usb.sh
artifact_name: secubox-mochabin-live-usb
output_pattern: "secubox-mochabin-live-usb.img*"
needs_qemu: true
embed_image: true
# Raspberry Pi 400 (arm64) - Native Pi firmware boot
- platform: rpi400
arch: arm64
script: build-rpi-usb.sh
artifact_name: secubox-rpi-arm64
output_pattern: "secubox-rpi-arm64-*.img*"
needs_qemu: true
embed_image: false
steps:
# Skip job if platform doesn't match (workflow_dispatch only)
- name: Check platform filter
id: filter
run: |
if [[ "${{ github.event_name }}" == "push" ]]; then
echo "skip=false" >> $GITHUB_OUTPUT
elif [[ "${{ inputs.platform }}" == "all" || "${{ inputs.platform }}" == "" || "${{ inputs.platform }}" == "${{ matrix.platform }}" ]]; then
echo "skip=false" >> $GITHUB_OUTPUT
else
echo "skip=true" >> $GITHUB_OUTPUT
echo "Skipping ${{ matrix.platform }} (requested: ${{ inputs.platform }})"
fi
- name: Checkout
if: steps.filter.outputs.skip != 'true'
uses: actions/checkout@v4
- name: Free disk space
if: steps.filter.outputs.skip != 'true'
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
sudo apt-get clean
df -h
- name: Setup QEMU for ARM64 cross-compilation
if: steps.filter.outputs.skip != 'true' && matrix.needs_qemu
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Install build dependencies
if: steps.filter.outputs.skip != 'true'
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq \
debootstrap qemu-user-static binfmt-support \
parted dosfstools e2fsprogs squashfs-tools \
grub-efi-amd64-bin grub-pc-bin grub-efi-amd64-signed shim-signed \
mtools rsync pv xorriso curl wget u-boot-tools
- name: Download SecuBox packages
if: steps.filter.outputs.skip != 'true'
uses: dawidd6/action-download-artifact@v3
with:
workflow: build-packages.yml
name: secubox-debs-all
path: output/debs/
if_no_artifact_found: warn
continue-on-error: true
- name: List available packages
if: steps.filter.outputs.skip != 'true'
run: |
echo "=== Available SecuBox packages ==="
ls output/debs/secubox-*.deb 2>/dev/null | wc -l || echo "0"
ls output/debs/secubox-*.deb 2>/dev/null | xargs -I{} basename {} | sed 's/_.*$//' | sort -u || echo "No packages found"
- name: Build embedded eMMC image (MOCHAbin only)
if: steps.filter.outputs.skip != 'true' && matrix.embed_image
run: |
echo "=== Building embedded eMMC target image ==="
sudo -E bash image/build-image.sh \
--board mochabin \
--suite ${{ env.DEBIAN_SUITE }} \
--out output/ \
--slipstream
echo "=== Checking eMMC image for embedding ==="
# build-image.sh already compresses to .img.gz, just verify it exists
if [[ -f output/secubox-mochabin-bookworm.img.gz ]]; then
ls -lh output/secubox-mochabin-bookworm.img.gz
elif [[ -f output/secubox-mochabin-bookworm.img ]]; then
echo "Compressing uncompressed image..."
gzip -k output/secubox-mochabin-bookworm.img
ls -lh output/secubox-mochabin-bookworm.img.gz
else
echo "ERROR: No eMMC image found!"
ls -la output/
exit 1
fi
timeout-minutes: 60
- name: Build ${{ matrix.platform }} Live USB
if: steps.filter.outputs.skip != 'true'
env:
DEBIAN_FRONTEND: noninteractive
run: |
echo "=== Building ${{ matrix.platform }} Live USB ==="
COMPRESS_OPT=""
if [[ "${{ inputs.compress }}" == "false" ]]; then
COMPRESS_OPT="--no-compress"
fi
SLIPSTREAM_OPT=""
if ls output/debs/secubox-*.deb >/dev/null 2>&1; then
SLIPSTREAM_OPT="--slipstream"
echo "Slipstream enabled: $(ls output/debs/secubox-*.deb | wc -l) packages"
fi
sudo -E bash image/${{ matrix.script }} \
--suite ${{ env.DEBIAN_SUITE }} \
$SLIPSTREAM_OPT \
$COMPRESS_OPT
sudo chown -R $(id -u):$(id -g) output/
timeout-minutes: 120
- name: List output files
if: steps.filter.outputs.skip != 'true'
run: |
echo "=== Build output ==="
ls -lh output/
- name: Generate checksums
if: steps.filter.outputs.skip != 'true'
run: |
cd output/
echo "=== Generating SHA256 checksums ==="
for f in ${{ matrix.output_pattern }}; do
if [[ -f "$f" ]]; then
sha256sum "$f" >> SHA256SUMS
echo " $f"
fi
done
cat SHA256SUMS
- name: Upload artifact
if: steps.filter.outputs.skip != 'true'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}-${{ env.DEBIAN_SUITE }}
path: |
output/${{ matrix.output_pattern }}
output/SHA256SUMS
if-no-files-found: error
retention-days: 30
# Collect all artifacts and create release on tags
release:
needs: build-live-usb
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: live-usb/
merge-multiple: true
- name: List downloaded files
run: |
echo "=== Downloaded artifacts ==="
ls -lh live-usb/
- name: Generate combined checksums
run: |
cd live-usb/
rm -f SHA256SUMS
echo "=== Generating combined SHA256SUMS ==="
sha256sum *.img* 2>/dev/null | sort > SHA256SUMS || echo "No images found"
cat SHA256SUMS
- name: Sign checksums
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
if: env.GPG_PRIVATE_KEY != ''
run: |
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
cd live-usb/
gpg --clearsign SHA256SUMS
mv SHA256SUMS.asc SHA256SUMS.gpg
echo "=== Checksums signed ==="
- name: Delete existing release assets (if any)
run: |
VERSION="${{ github.ref_name }}"
if gh release view "$VERSION" &>/dev/null; then
echo "Release $VERSION exists, deleting duplicate assets..."
for file in live-usb/*.img* live-usb/SHA256SUMS live-usb/SHA256SUMS.gpg; do
[ -f "$file" ] || continue
name=$(basename "$file")
gh release delete-asset "$VERSION" "$name" --yes 2>/dev/null || true
done
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
files: |
live-usb/*.img*
live-usb/SHA256SUMS
live-usb/SHA256SUMS.gpg
body: |
## SecuBox Live USB Images ${{ github.ref_name }}
Bootable live USB images with integrated disk flasher tools for permanent installation.
### Available Images
| Image | Platform | Architecture | Boot Method | Flasher Tool |
|-------|----------|--------------|-------------|--------------|
| `secubox-live-amd64-bookworm.img.gz` | x64 PC | amd64 | GRUB UEFI/BIOS | `secubox-flash-disk` (press `d`) |
| `secubox-mochabin-live-usb.img` | MOCHAbin | arm64 | U-Boot extlinux | `secubox-flash-emmc` (press `f`) |
| `secubox-rpi-arm64-bookworm.img.gz` | Raspberry Pi 400 | arm64 | Pi firmware | Manual install |
### Flash to USB Drive
```bash
# x64 PC
zcat secubox-live-amd64-bookworm.img.gz | sudo dd of=/dev/sdX bs=4M status=progress
# MOCHAbin
sudo dd if=secubox-mochabin-live-usb.img of=/dev/sdX bs=4M status=progress
# Raspberry Pi 400
zcat secubox-rpi-arm64-bookworm.img.gz | sudo dd of=/dev/sdX bs=4M status=progress
```
### Boot & Install to Internal Storage
| Platform | Boot Method | Install Command |
|----------|-------------|-----------------|
| **x64** | Boot USB, select "SecuBox Live" | Press `d` in TUI or run `secubox-flash-disk` |
| **MOCHAbin** | Serial console (115200 8N1), U-Boot auto-boots | Press `f` in TUI or run `secubox-flash-emmc` |
| **Raspberry Pi** | HDMI/serial console, auto-boots | Manual: `dd` to SD card |
### Default Credentials
- **Console/SSH:** `root` / `secubox`
- **Web UI:** `https://localhost` (or LAN IP)
### Verification
```bash
# Verify checksums
sha256sum -c SHA256SUMS
# Verify GPG signature (if available)
gpg --verify SHA256SUMS.gpg
```
### Features
- **All SecuBox packages** pre-installed (40+ security modules)
- **Persistent storage** partition for configuration
- **Kiosk mode** with Chromium dashboard
- **Console TUI** for headless operation
- **Network auto-detection** (DHCP on all interfaces)
draft: false
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }}

View File

@ -0,0 +1,190 @@
name: Build Eye Remote Image
on:
workflow_dispatch:
inputs:
wifi_ssid:
description: 'WiFi SSID (optional)'
required: false
default: ''
wifi_psk:
description: 'WiFi password (optional)'
required: false
default: ''
hostname:
description: 'Device hostname'
required: false
default: 'secubox-round'
create_release:
description: 'Create GitHub release'
required: false
default: 'false'
type: choice
options:
- 'true'
- 'false'
push:
tags:
- 'eye-remote-v*'
paths:
- 'remote-ui/round/**'
pull_request:
paths:
- 'remote-ui/round/**'
env:
VERSION: '2.2.1'
RPI_OS_URL: 'https://downloads.raspberrypi.com/raspios_lite_armhf/images/raspios_lite_armhf-2024-11-19/2024-11-19-raspios-bookworm-armhf-lite.img.xz'
jobs:
# Run menu system tests first
test-menu-system:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install test dependencies
run: |
pip install pytest pytest-asyncio pillow
- name: Run menu system tests
run: |
cd remote-ui/round
python -m pytest tests/ -v --tb=short
continue-on-error: true # Don't block build on test failures for now
build-eye-remote:
needs: test-menu-system
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
df -h
- name: Install build tools
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq \
parted dosfstools e2fsprogs \
xz-utils wget rsync \
qemu-user-static binfmt-support
# Ensure ARM binfmt is registered
sudo systemctl restart binfmt-support || true
sudo update-binfmts --enable qemu-arm || true
# Verify QEMU is working
if [ -f /proc/sys/fs/binfmt_misc/qemu-arm ]; then
echo "✓ QEMU ARM binfmt registered"
else
echo "⚠ QEMU ARM binfmt not found, attempting manual registration"
sudo update-binfmts --import qemu-arm || true
fi
- name: Download Raspberry Pi OS Lite
run: |
echo "Downloading Raspberry Pi OS Lite (armhf)..."
wget -q -O /tmp/raspios-lite.img.xz "${{ env.RPI_OS_URL }}"
ls -lh /tmp/raspios-lite.img.xz
- name: Build Eye Remote image (OFFLINE MODE)
run: |
cd remote-ui/round
# Build options
BUILD_ARGS="-i /tmp/raspios-lite.img.xz -o /tmp"
if [ -n "${{ github.event.inputs.wifi_ssid }}" ]; then
BUILD_ARGS="$BUILD_ARGS -s '${{ github.event.inputs.wifi_ssid }}' -p '${{ github.event.inputs.wifi_psk }}'"
fi
HOSTNAME="${{ github.event.inputs.hostname || 'secubox-round' }}"
BUILD_ARGS="$BUILD_ARGS -h $HOSTNAME"
echo "Building with: $BUILD_ARGS"
sudo bash ./build-eye-remote-image.sh $BUILD_ARGS
sudo chown $(id -u):$(id -g) /tmp/secubox-eye-remote-*.img
timeout-minutes: 60
- name: Compress image
run: |
cd /tmp
echo "Compressing Eye Remote image..."
xz -9 -v secubox-eye-remote-${{ env.VERSION }}.img
ls -lh secubox-eye-remote-${{ env.VERSION }}.img.xz
- name: Generate checksums
run: |
cd /tmp
sha256sum secubox-eye-remote-${{ env.VERSION }}.img.xz > secubox-eye-remote-${{ env.VERSION }}.sha256
cat secubox-eye-remote-${{ env.VERSION }}.sha256
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: secubox-eye-remote-${{ env.VERSION }}
path: |
/tmp/secubox-eye-remote-${{ env.VERSION }}.img.xz
/tmp/secubox-eye-remote-${{ env.VERSION }}.sha256
retention-days: 30
- name: Upload to release (on tag or manual)
if: startsWith(github.ref, 'refs/tags/eye-remote-v') || github.event.inputs.create_release == 'true'
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('eye-remote-v{0}', env.VERSION) }}
name: Eye Remote v${{ env.VERSION }}
files: |
/tmp/secubox-eye-remote-${{ env.VERSION }}.img.xz
/tmp/secubox-eye-remote-${{ env.VERSION }}.sha256
body: |
## Eye Remote v${{ env.VERSION }} — Radial Menu Edition
### 🎯 What's New
- **Radial Menu System** — 6-slice pie menu for touchscreen control
- **Touch Handler** — Full gesture support (tap, long-press, swipe)
- **Action Executor** — Modular command dispatcher
- **Local Settings API** — Display brightness, network config, system info
### 📦 Image Details
| Image | Hardware | Description |
|-------|----------|-------------|
| `secubox-eye-remote-${{ env.VERSION }}.img.xz` | RPi Zero W + HyperPixel 2.1 Round | USB Gadget Controller |
**🔌 OFFLINE MODE:** All packages pre-installed. No internet required at boot!
### 🎮 Radial Menu Gestures
| Gesture | Action |
|---------|--------|
| Long-press center | Enter menu mode |
| Tap slice | Select item |
| Tap center | Go back |
| 3-finger tap | Emergency exit |
### 💾 Flash to SD card
```bash
xzcat secubox-eye-remote-${{ env.VERSION }}.img.xz | sudo dd of=/dev/sdX bs=4M status=progress
```
### 🔐 Default credentials
- User: `pi`
- Password: `raspberry`
### ⏱️ First boot
~60s (no package download needed)
See [Eye-Remote Wiki](https://github.com/CyberMind-FR/secubox-deb/wiki/Eye-Remote) for full documentation.

View File

@ -0,0 +1,274 @@
name: Build SecuBox System Images
on:
workflow_call:
inputs:
board:
description: 'Target board'
required: false
type: string
default: 'all'
size:
description: 'Image size (empty = use board default)'
required: false
type: string
default: ''
secrets:
GPG_PRIVATE_KEY:
required: false
workflow_dispatch:
inputs:
board:
description: 'Target board'
required: true
type: choice
options:
- mochabin
- espressobin-v7
- espressobin-ultra
- vm-x64
- vm-arm64
- rpi400
- all
default: vm-x64
size:
description: 'Image size (empty = use board default)'
required: false
default: ''
push:
tags:
- 'v*'
env:
DEBIAN_SUITE: bookworm
jobs:
# Build system images
build-image:
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
# Handle all event types: push (tags), workflow_call, workflow_dispatch
board: ${{ (github.event_name == 'push' || (inputs.board == 'all' || inputs.board == '')) && fromJson('["mochabin","espressobin-v7","espressobin-ultra","vm-x64","rpi400"]') || (github.event.inputs.board == 'all' && fromJson('["mochabin","espressobin-v7","espressobin-ultra","vm-x64","rpi400"]') || fromJson(format('["{0}"]', inputs.board || github.event.inputs.board || 'vm-x64'))) }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
df -h
- name: Setup QEMU + binfmt
if: contains(matrix.board, 'arm') || contains(matrix.board, 'espresso') || contains(matrix.board, 'mocha')
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Install build tools
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq \
debootstrap qemu-user-static binfmt-support \
parted dosfstools e2fsprogs rsync \
gzip pigz xz-utils
- name: Download package artifacts
uses: dawidd6/action-download-artifact@v3
with:
workflow: build-packages.yml
name: secubox-debs-all
path: output/debs/
if_no_artifact_found: warn
continue-on-error: true
- name: List available packages
run: |
echo "=== SecuBox packages to slipstream ==="
ls output/debs/secubox-*.deb 2>/dev/null | wc -l || echo "0"
ls output/debs/secubox-*.deb 2>/dev/null | xargs -I{} basename {} | sed 's/_.*$//' | sort -u || echo "No packages found"
- name: Determine architecture
id: arch
run: |
case "${{ matrix.board }}" in
vm-x64)
echo "arch=amd64" >> $GITHUB_OUTPUT
echo "is_arm=false" >> $GITHUB_OUTPUT
;;
*)
echo "arch=arm64" >> $GITHUB_OUTPUT
echo "is_arm=true" >> $GITHUB_OUTPUT
;;
esac
- name: Build image for ${{ matrix.board }}
env:
DEBIAN_FRONTEND: noninteractive
run: |
# Use slipstream if packages are available
SLIPSTREAM_OPT=""
if ls output/debs/secubox-*.deb >/dev/null 2>&1; then
SLIPSTREAM_OPT="--slipstream"
echo "Slipstream enabled: $(ls output/debs/secubox-*.deb | wc -l) packages"
fi
# Build with board-specific size (from board/*/config.mk)
SIZE_OPT=""
if [ -n "${{ inputs.size || github.event.inputs.size }}" ]; then
SIZE_OPT="--size ${{ inputs.size || github.event.inputs.size }}"
fi
sudo -E bash image/build-image.sh \
--board ${{ matrix.board }} \
--suite ${{ env.DEBIAN_SUITE }} \
--out output/ \
$SIZE_OPT \
$SLIPSTREAM_OPT
sudo chown -R $(id -u):$(id -g) output/
timeout-minutes: 90
- name: List output
run: |
ls -lh output/
- name: Generate checksums
run: |
cd output/
sha256sum *.img.gz > SHA256SUMS 2>/dev/null || echo "No images found" > SHA256SUMS
cat SHA256SUMS
- name: Upload image artifact
uses: actions/upload-artifact@v4
with:
name: secubox-${{ matrix.board }}-${{ env.DEBIAN_SUITE }}
path: |
output/*.img.gz
output/SHA256SUMS
if-no-files-found: error
retention-days: 30
# Collect all images and create release
release:
needs: build-image
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: images/
merge-multiple: true
- name: Generate combined checksums
run: |
cd images/
# Remove individual SHA256SUMS files
rm -f SHA256SUMS
# Generate combined checksums
sha256sum *.img.gz 2>/dev/null | sort > SHA256SUMS || echo "No images" > SHA256SUMS
cat SHA256SUMS
- name: Sign checksums
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
run: |
if [ -n "$GPG_PRIVATE_KEY" ]; then
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
cd images/
gpg --clearsign SHA256SUMS
mv SHA256SUMS.asc SHA256SUMS.gpg
fi
- name: Checkout for scripts
uses: actions/checkout@v4
with:
sparse-checkout: image
- name: Delete existing release assets (if any)
run: |
VERSION="${{ github.ref_name }}"
if gh release view "$VERSION" &>/dev/null; then
echo "Release $VERSION exists, deleting duplicate assets..."
for file in images/*.img.gz images/SHA256SUMS images/SHA256SUMS.gpg image/create-qemu-arm64-vm.sh image/create-vbox-vm.sh; do
[ -f "$file" ] || continue
name=$(basename "$file")
gh release delete-asset "$VERSION" "$name" --yes 2>/dev/null || true
done
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
files: |
images/*.img.gz
images/SHA256SUMS
images/SHA256SUMS.gpg
image/create-qemu-arm64-vm.sh
image/create-vbox-vm.sh
body: |
## SecuBox-DEB ${{ github.ref_name }} System Images
Flashable system images for SecuBox appliances.
### Available Images
| Image | Board | Architecture | Description |
|-------|-------|--------------|-------------|
| `secubox-mochabin-bookworm.img.gz` | MOCHAbin | arm64 | Marvell Armada 7040 (Pro) |
| `secubox-espressobin-v7-bookworm.img.gz` | ESPRESSObin v7 | arm64 | Marvell Armada 3720 (Lite) |
| `secubox-espressobin-ultra-bookworm.img.gz` | ESPRESSObin Ultra | arm64 | Marvell Armada 3720 (Lite+) |
| `secubox-rpi400-bookworm.img.gz` | Raspberry Pi 400 | arm64 | Pi 400 / Pi 4 |
| `secubox-vm-x64-bookworm.img.gz` | VirtualBox/QEMU | amd64 | VM for testing |
| `create-qemu-arm64-vm.sh` | QEMU ARM64 | script | Run ARM64 on x86 hosts |
### Installation
**ARM64 boards (MOCHAbin, ESPRESSObin):**
```bash
# Flash to SD card or eMMC
gunzip -c secubox-mochabin-bookworm.img.gz | sudo dd of=/dev/sdX bs=4M status=progress
sync
```
**VirtualBox (x64):**
```bash
# Convert to VDI
gunzip secubox-vm-x64-bookworm.img.gz
VBoxManage convertfromraw secubox-vm-x64-bookworm.img secubox.vdi --format VDI
# Create VM
VBoxManage createvm --name SecuBox --ostype Debian_64 --register
VBoxManage modifyvm SecuBox --memory 2048 --cpus 2 --nic1 nat
VBoxManage storagectl SecuBox --name SATA --add sata
VBoxManage storageattach SecuBox --storagectl SATA --port 0 --device 0 --type hdd --medium secubox.vdi
```
### Verification
```bash
sha256sum -c SHA256SUMS
gpg --verify SHA256SUMS.gpg
```
### Default Credentials
- **SSH:** root / (SSH key only, set during firstboot)
- **Web UI:** https://secubox.local:8443 (admin / generated at firstboot)
### Included Packages
**124 SecuBox packages** across all security domains:
- **Core:** hub, core, portal, system, console
- **Security:** crowdsec, waf, auth, nac, threats, ipblock, mac-guard
- **Networking:** wireguard, haproxy, dpi, qos, netmodes, vhost, cdn
- **Applications:** mail, gitea, nextcloud, ollama, jellyfin, matrix
- **SOC:** soc-agent, soc-gateway, soc-web
- **Intel:** device-intel, vortex-dns, vortex-firewall, ai-insights
draft: false
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }}

View File

@ -0,0 +1,351 @@
# SecuBox — Installer ISO Build Workflow
# Builds hybrid live/installer ISO with wiki documentation
name: Build Installer ISO
on:
workflow_dispatch:
inputs:
preseed:
description: 'Include preseed config'
type: boolean
default: false
name:
description: 'ISO name prefix'
required: false
default: 'secubox-installer'
push:
tags:
- 'v*'
workflow_run:
workflows: ["Build SecuBox .deb packages"]
types: [completed]
branches: [main, master]
env:
DEBIAN_SUITE: bookworm
jobs:
build-iso:
runs-on: ubuntu-24.04
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'push' || github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
sudo apt-get clean
df -h
- name: Download package artifacts
if: github.event_name == 'workflow_run'
uses: actions/download-artifact@v4
with:
name: secubox-debs-all
path: output/debs/
run-id: ${{ github.event.workflow_run.id }}
continue-on-error: true
- name: Download packages from latest build
if: github.event_name != 'workflow_run'
uses: dawidd6/action-download-artifact@v3
with:
workflow: build-packages.yml
name: secubox-debs-all
path: output/debs/
if_no_artifact_found: warn
continue-on-error: true
- name: List available packages
run: |
echo "=== Available .deb packages ==="
ls -lh output/debs/*.deb 2>/dev/null || echo "No packages found"
echo "=== Package count ==="
ls output/debs/*.deb 2>/dev/null | wc -l || echo "0"
- name: Install build dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq \
debootstrap parted dosfstools e2fsprogs \
squashfs-tools grub-efi-amd64-bin grub-pc-bin \
xorriso mtools rsync curl wget \
keyboard-configuration console-setup kbd locales
- name: Build Installer ISO
run: |
SLIPSTREAM_OPT=""
if ls output/debs/*.deb >/dev/null 2>&1; then
SLIPSTREAM_OPT="--slipstream"
echo "Slipstream enabled: $(ls output/debs/*.deb | wc -l) packages"
fi
ISO_NAME="${{ github.event.inputs.name || 'secubox-installer' }}"
sudo bash image/build-installer-iso.sh \
--name "$ISO_NAME" \
$SLIPSTREAM_OPT
sudo chown -R $(id -u):$(id -g) output/
timeout-minutes: 60
- name: Generate checksums
run: |
cd output/
sha256sum *.iso *.img 2>/dev/null > SHA256SUMS || true
cat SHA256SUMS
- name: List output
run: |
ls -lh output/
echo "=== ISO Info ==="
file output/*.iso 2>/dev/null || true
- name: Upload ISO artifact
uses: actions/upload-artifact@v4
with:
name: secubox-installer-iso-amd64
path: |
output/*.iso
output/*.img
output/SHA256SUMS
if-no-files-found: error
retention-days: 30
# Build wiki documentation
build-wiki:
runs-on: ubuntu-latest
needs: build-iso
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Checkout wiki
uses: actions/checkout@v4
with:
repository: ${{ github.repository }}.wiki
path: wiki-repo
continue-on-error: true
- name: Generate API documentation
run: |
mkdir -p wiki-output
# Copy existing wiki docs
if [ -d "wiki" ]; then
cp -r wiki/* wiki-output/
fi
# Copy docs
if [ -d "docs" ]; then
cp -r docs/*.md wiki-output/ 2>/dev/null || true
fi
# Generate module list
cat > wiki-output/Modules.md << 'EOF'
# SecuBox Modules
## Core
| Module | Description | API Endpoints |
|--------|-------------|---------------|
| secubox-hub | Central dashboard | /api/v1/hub/* |
| secubox-core | Shared library | - |
| secubox-portal | Login portal | /api/v1/portal/* |
| secubox-system | System management | /api/v1/system/* |
## Security
| Module | Description | API Endpoints |
|--------|-------------|---------------|
| secubox-crowdsec | Intrusion prevention | /api/v1/crowdsec/* |
| secubox-waf | Web application firewall | /api/v1/waf/* |
| secubox-auth | OAuth2/OIDC | /api/v1/auth/* |
| secubox-nac | Network access control | /api/v1/nac/* |
## Network
| Module | Description | API Endpoints |
|--------|-------------|---------------|
| secubox-wireguard | VPN management | /api/v1/wireguard/* |
| secubox-haproxy | Load balancer | /api/v1/haproxy/* |
| secubox-netmodes | Network modes | /api/v1/netmodes/* |
| secubox-qos | Traffic shaping | /api/v1/qos/* |
| secubox-dpi | Deep packet inspection | /api/v1/dpi/* |
| secubox-vhost | Virtual hosts | /api/v1/vhost/* |
| secubox-cdn | CDN cache | /api/v1/cdn/* |
## Applications
| Module | Description | API Endpoints |
|--------|-------------|---------------|
| secubox-mail | Email server | /api/v1/mail/* |
| secubox-dns | DNS management | /api/v1/dns/* |
| secubox-users | User management | /api/v1/users/* |
| secubox-gitea | Git server | /api/v1/gitea/* |
| secubox-nextcloud | File sync | /api/v1/nextcloud/* |
## Monitoring
| Module | Description | API Endpoints |
|--------|-------------|---------------|
| secubox-netdata | Real-time metrics | /api/v1/netdata/* |
| secubox-metrics | Dashboard metrics | /api/v1/metrics/* |
| secubox-soc | Security operations | /api/v1/soc/* |
EOF
- name: Upload wiki artifact
uses: actions/upload-artifact@v4
with:
name: secubox-wiki
path: wiki-output/
retention-days: 30
# Create release on tag
release:
needs: [build-iso, build-wiki]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download ISO artifact
uses: actions/download-artifact@v4
with:
name: secubox-installer-iso-amd64
path: release/
- name: Download wiki artifact
uses: actions/download-artifact@v4
with:
name: secubox-wiki
path: wiki/
- name: Compress ISO
run: |
cd release/
for f in *.iso *.img; do
[ -f "$f" ] && gzip -9 "$f" && echo "Compressed: $f"
done
ls -lh
- name: Generate final checksums
run: |
cd release/
sha256sum *.gz 2>/dev/null > SHA256SUMS || true
cat SHA256SUMS
- name: Sign checksums
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
run: |
if [ -n "$GPG_PRIVATE_KEY" ]; then
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
cd release/
gpg --clearsign SHA256SUMS
mv SHA256SUMS.asc SHA256SUMS.gpg
fi
- name: Get version info
id: version
run: |
VERSION="${{ github.ref_name }}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "date=$(date -u +%Y-%m-%d)" >> $GITHUB_OUTPUT
- name: Delete existing release assets (if any)
run: |
VERSION="${{ github.ref_name }}"
if gh release view "$VERSION" &>/dev/null; then
echo "Release $VERSION exists, deleting duplicate assets..."
for file in release/*.iso.gz release/*.img.gz release/SHA256SUMS release/SHA256SUMS.gpg; do
[ -f "$file" ] || continue
name=$(basename "$file")
gh release delete-asset "$VERSION" "$name" --yes 2>/dev/null || true
done
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
name: SecuBox Installer ${{ steps.version.outputs.version }}
body: |
## SecuBox Installer ISO ${{ steps.version.outputs.version }}
Release date: ${{ steps.version.outputs.date }}
### Downloads
| File | Description |
|------|-------------|
| `secubox-installer-amd64-bookworm.iso.gz` | Bootable ISO (UEFI/BIOS) |
| `secubox-installer-amd64-bookworm.img.gz` | Raw USB image |
### Features
- **Hybrid Boot**: Works as Live USB or Installer
- **UEFI + Legacy BIOS**: Compatible with all systems
- **French AZERTY keyboard**: Pre-configured
- **DHCP networking**: Auto-configured on boot
- **51 SecuBox packages**: Pre-installed
- **Preseed support**: Clone existing installations
### Boot Options
| Option | Description |
|--------|-------------|
| SecuBox Live | Boot live system with persistence |
| SecuBox Install | Headless auto-install to first disk |
| SecuBox Live (To RAM) | Load entire system to memory |
| SecuBox Debug (Rescue) | Minimal rescue shell |
### Flash to USB
```bash
# Download and extract
gunzip secubox-installer-amd64-bookworm.img.gz
# Flash (replace sdX with your USB device)
sudo dd if=secubox-installer-amd64-bookworm.img of=/dev/sdX bs=4M status=progress conv=fsync
```
### Credentials
| Service | Username | Password |
|---------|----------|----------|
| SSH | root | secubox |
| SSH | secubox | secubox |
| Web UI | admin | admin |
### Verification
```bash
sha256sum -c SHA256SUMS
gpg --verify SHA256SUMS.gpg
```
### Documentation
- [Installation Guide](https://github.com/${{ github.repository }}/wiki/Installation)
- [API Reference](https://github.com/${{ github.repository }}/wiki/API-Reference)
- [Module Documentation](https://github.com/${{ github.repository }}/wiki/Modules)
files: |
release/*.iso.gz
release/*.img.gz
release/SHA256SUMS
release/SHA256SUMS.gpg
draft: false
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }}
- name: Update wiki
if: success()
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Wiki artifacts ready for manual deployment"
ls -la wiki/

View File

@ -0,0 +1,37 @@
# DEPRECATED: Use build-all-live-usb.yml for all live USB builds
# This workflow is maintained for backward compatibility only.
# It calls the unified workflow with platform=x64.
name: Build SecuBox Live USB (x64 only - DEPRECATED)
on:
workflow_dispatch:
inputs:
compress:
description: 'Compress output (gzip)'
type: boolean
default: true
jobs:
# Redirect to unified workflow
build-x64-live-usb:
uses: ./.github/workflows/build-all-live-usb.yml
with:
platform: x64
secrets: inherit
# Note about deprecation
deprecation-notice:
runs-on: ubuntu-latest
steps:
- name: Deprecation Warning
run: |
echo "::warning::This workflow is DEPRECATED. Use build-all-live-usb.yml instead."
echo ""
echo "The unified workflow supports all platforms:"
echo " - x64 (amd64) - GRUB UEFI/BIOS"
echo " - espressobin-v7 (arm64) - U-Boot"
echo " - rpi400 (arm64) - Pi firmware"
echo ""
echo "To build all platforms:"
echo " gh workflow run build-all-live-usb.yml -f platform=all"

View File

@ -0,0 +1,280 @@
name: Build Multiboot Live Image
on:
workflow_dispatch:
inputs:
image_size:
description: 'Image size in GB'
required: false
default: '16'
type: choice
options:
- '8'
- '16'
- '32'
include_desktop:
description: 'Include desktop environment'
required: false
default: 'false'
type: choice
options:
- 'true'
- 'false'
create_release:
description: 'Create GitHub release'
required: false
default: 'false'
type: choice
options:
- 'true'
- 'false'
prerelease:
description: 'Mark as prerelease'
required: false
default: 'true'
type: choice
options:
- 'true'
- 'false'
push:
tags:
- 'multiboot-v*'
paths:
- 'image/multiboot/**'
pull_request:
paths:
- 'image/multiboot/**'
env:
VERSION: '2.2.4-pre1'
jobs:
# Build all .deb packages first
build-packages:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache
df -h
- name: Install build dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq \
build-essential devscripts debhelper \
dh-python python3-all python3-setuptools \
crossbuild-essential-arm64
- name: Build SecuBox packages
run: |
mkdir -p output/debs
# Build each package
for pkg in packages/secubox-*/; do
if [ -d "$pkg/debian" ]; then
echo "Building $(basename $pkg)..."
cd "$pkg"
dpkg-buildpackage -us -uc -b --host-arch=arm64 2>/dev/null || \
dpkg-buildpackage -us -uc -b 2>/dev/null || \
echo "WARNING: Failed to build $(basename $pkg)"
cd - >/dev/null
fi
done
# Collect all .deb files
find packages/ -name "*.deb" -exec cp {} output/debs/ \;
ls -la output/debs/ || echo "No packages built"
- name: Upload packages artifact
uses: actions/upload-artifact@v4
with:
name: secubox-packages
path: output/debs/
retention-days: 1
# Build the multiboot image
build-multiboot:
needs: build-packages
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache
df -h
- name: Install build tools
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq \
parted dosfstools e2fsprogs \
debootstrap qemu-user-static binfmt-support \
squashfs-tools xz-utils rsync grub-efi-amd64-bin \
grub-pc-bin u-boot-tools
# Ensure ARM64 binfmt is registered
sudo systemctl restart binfmt-support || true
sudo update-binfmts --enable qemu-aarch64 || true
# Verify QEMU is working
if [ -f /proc/sys/fs/binfmt_misc/qemu-aarch64 ]; then
echo "✓ QEMU ARM64 binfmt registered"
else
echo "⚠ QEMU ARM64 binfmt not found, attempting manual registration"
sudo update-binfmts --import qemu-aarch64 || true
fi
- name: Download packages artifact
uses: actions/download-artifact@v4
with:
name: secubox-packages
path: output/debs/
- name: List available packages
run: |
echo "SecuBox packages available for slipstream:"
ls -la output/debs/ || echo "No packages found"
- name: Build multiboot image
run: |
IMAGE_SIZE="${{ github.event.inputs.image_size || '16' }}"
DESKTOP="${{ github.event.inputs.include_desktop || 'false' }}"
mkdir -p output
BUILD_ARGS="--output output/secubox-multiboot-${{ env.VERSION }}.img"
BUILD_ARGS="$BUILD_ARGS --size ${IMAGE_SIZE}G"
if [ "$DESKTOP" = "true" ]; then
BUILD_ARGS="$BUILD_ARGS --desktop"
fi
echo "Building multiboot image with: $BUILD_ARGS"
sudo bash image/multiboot/build-multiboot.sh $BUILD_ARGS
sudo chown $(id -u):$(id -g) output/secubox-multiboot-${{ env.VERSION }}.img
timeout-minutes: 120
- name: Verify boot files
run: |
# Mount EFI partition and verify kernel files exist
LOOP_DEV=$(sudo losetup -f --show -P output/secubox-multiboot-${{ env.VERSION }}.img)
sudo mkdir -p /tmp/verify-efi
sudo mount "${LOOP_DEV}p1" /tmp/verify-efi
echo "=== EFI Partition Boot Files ==="
ls -la /tmp/verify-efi/
# Check for required files
MISSING=0
for f in Image vmlinuz initrd.img initrd-amd64.img boot.scr; do
if [ -f "/tmp/verify-efi/$f" ]; then
echo "✓ $f present ($(du -h /tmp/verify-efi/$f | cut -f1))"
else
echo "✗ $f MISSING!"
MISSING=1
fi
done
# Check DTBs
DTB_COUNT=$(ls /tmp/verify-efi/dtbs/marvell/*.dtb 2>/dev/null | wc -l)
if [ "$DTB_COUNT" -gt 0 ]; then
echo "✓ DTBs present ($DTB_COUNT files)"
else
echo "✗ DTBs MISSING!"
MISSING=1
fi
sudo umount /tmp/verify-efi
sudo losetup -d "$LOOP_DEV"
if [ "$MISSING" -eq 1 ]; then
echo "ERROR: Required boot files missing!"
exit 1
fi
- name: Compress image
run: |
cd output
echo "Compressing multiboot image..."
xz -9 -v -T0 secubox-multiboot-${{ env.VERSION }}.img
ls -lh secubox-multiboot-${{ env.VERSION }}.img.xz
- name: Generate checksums
run: |
cd output
sha256sum secubox-multiboot-${{ env.VERSION }}.img.xz > secubox-multiboot-${{ env.VERSION }}.sha256
cat secubox-multiboot-${{ env.VERSION }}.sha256
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: secubox-multiboot-${{ env.VERSION }}
path: |
output/secubox-multiboot-${{ env.VERSION }}.img.xz
output/secubox-multiboot-${{ env.VERSION }}.sha256
retention-days: 30
- name: Upload to release (on tag or manual)
if: startsWith(github.ref, 'refs/tags/multiboot-v') || github.event.inputs.create_release == 'true'
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('multiboot-v{0}', env.VERSION) }}
name: SecuBox Multiboot v${{ env.VERSION }}
prerelease: ${{ github.event.inputs.prerelease == 'true' || contains(env.VERSION, 'pre') }}
files: |
output/secubox-multiboot-${{ env.VERSION }}.img.xz
output/secubox-multiboot-${{ env.VERSION }}.sha256
body: |
## SecuBox Multiboot v${{ env.VERSION }} — Live RAM OS
### 🎯 What's New
- **Dual Boot Menu** — Interactive menu with 5s timeout: Live RAM Boot (default) or Flash to eMMC
- **Dual Architecture** — Boot ARM64 (MOCHAbin/ESPRESSObin) or AMD64 (x86_64 PC)
- **RAM-based Live OS** — Reduced I/O, ideal for USB gadget devices
- **Auto Kernel Install** — ARM64 kernel, DTBs, and initrd properly installed on EFI partition
- **SecuBox Pre-installed** — All modules slipstreamed, ready to use
- **Shared Data Partition** — Persistent storage accessible from both architectures
### 📦 Image Layout
| Partition | Size | Type | Description |
|-----------|------|------|-------------|
| EFI | 512MB | FAT32 | GRUB + U-Boot for multi-arch boot |
| ARM64 | ~4GB | ext4 | Debian bookworm arm64 rootfs |
| AMD64 | ~4GB | ext4 | Debian bookworm amd64 rootfs |
| Data | ~7GB | ext4 | Shared persistent storage |
### 🔧 SecuBox Modules Included
- secubox-core, secubox-hub
- secubox-crowdsec, secubox-haproxy
- secubox-system, secubox-hardening
- secubox-ipblock, and more...
### 💾 Flash to SD/USB
```bash
xzcat secubox-multiboot-${{ env.VERSION }}.img.xz | sudo dd of=/dev/sdX bs=4M status=progress conv=fsync
```
### 🔐 Default credentials
- User: `secubox`
- Password: `secubox`
- Root: `secubox`
### 🎯 Use Cases
- **Demo/Recovery** — Boot from USB to demo or repair SecuBox installations
- **Factory Reset** — Clone to eMMC/SD for fresh installations
- **Pi Zero Eye Remote** — USB gadget boot media with reduced I/O
### ⏱️ First boot
~90s (all packages pre-installed, no internet required)
See [Multiboot Wiki](https://github.com/CyberMind-FR/secubox-deb/wiki/Multiboot) for full documentation.

View File

@ -0,0 +1,341 @@
name: Build SecuBox .deb packages
on:
push:
branches: [main, master]
tags: ['v*']
pull_request:
branches: [main, master]
workflow_call:
secrets:
GPG_PRIVATE_KEY:
required: false
DEPLOY_SSH_KEY:
required: false
DEPLOY_KNOWN_HOSTS:
required: false
workflow_dispatch:
inputs:
package:
description: 'Package to build (empty = all)'
required: false
default: ''
arch:
description: 'Architecture'
required: true
default: 'amd64'
type: choice
options:
- amd64
- arm64
- both
jobs:
# Discover all packages dynamically.
#
# Emits a single flat matrix of {package, arch} objects pre-filtered to skip
# arch-all+arm64 combos. This avoids a cross-product expansion that would
# exceed GitHub Actions' hard 256-jobs-per-matrix cap once the package
# catalog grows past ~128 entries (we hit 134 in v2.9.0).
discover:
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.find.outputs.packages }}
matrix: ${{ steps.find.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Find packages + build flat matrix
id: find
env:
REQUESTED_ARCH: ${{ github.event.inputs.arch }}
REQUESTED_PKG: ${{ github.event.inputs.package }}
run: |
set -euo pipefail
# Still emit the legacy `packages` output for backwards compat.
packages=$(find packages/secubox-* -path "*/debian/control" -not -path "*/debian/*/DEBIAN/control" \
| xargs dirname | xargs dirname | xargs -n1 basename \
| sort | jq -R -s -c 'split("\n") | map(select(length > 0))')
echo "packages=$packages" >> "$GITHUB_OUTPUT"
echo "Found packages: $packages"
# Build the flat {package, arch} matrix. Honour the workflow_dispatch
# `arch` and `package` filters if set (empty on `push: tags` events).
requested_arch="${REQUESTED_ARCH:-}"
requested_pkg="${REQUESTED_PKG:-}"
combos=$(find packages/secubox-* -path "*/debian/control" -not -path "*/debian/*/DEBIAN/control" \
| sort | while read -r ctrl; do
pkg=$(basename "$(dirname "$(dirname "$ctrl")")")
if [ -n "$requested_pkg" ] && [ "$requested_pkg" != "$pkg" ]; then
continue
fi
is_all=0
if grep -q "^Architecture:[[:space:]]*all" "$ctrl"; then
is_all=1
fi
# amd64 always, unless arch=arm64 was requested explicitly.
if [ -z "$requested_arch" ] || [ "$requested_arch" = "amd64" ]; then
echo "{\"package\":\"$pkg\",\"arch\":\"amd64\"}"
fi
# arm64 only for arch-specific packages, unless arch=amd64 requested.
if [ "$is_all" = "0" ] && { [ -z "$requested_arch" ] || [ "$requested_arch" = "arm64" ]; }; then
echo "{\"package\":\"$pkg\",\"arch\":\"arm64\"}"
fi
done | jq -s -c .)
echo "matrix=$combos" >> "$GITHUB_OUTPUT"
# Print the size so over-cap regressions are visible in the log.
echo "Matrix size: $(echo "$combos" | jq 'length') combos"
# Build packages — one job per {package, arch} entry from discover.
build:
needs: discover
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.discover.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Skip if specific package requested
if: ${{ github.event.inputs.package != '' && github.event.inputs.package != matrix.package }}
run: echo "Skipping ${{ matrix.package }}" && exit 0
- name: Check if arch-all package
id: check-arch
run: |
if [ -f "packages/${{ matrix.package }}/debian/control" ]; then
if grep -q "Architecture: all" "packages/${{ matrix.package }}/debian/control"; then
echo "is_arch_all=true" >> $GITHUB_OUTPUT
else
echo "is_arch_all=false" >> $GITHUB_OUTPUT
fi
else
echo "is_arch_all=true" >> $GITHUB_OUTPUT
fi
- name: Skip arm64 for arch-all packages
if: matrix.arch == 'arm64' && steps.check-arch.outputs.is_arch_all == 'true'
run: |
echo "⏭ Skipping arm64 build for Architecture: all package"
echo " arch-all packages are architecture-independent"
exit 0
- name: Install build dependencies
if: "!(matrix.arch == 'arm64' && steps.check-arch.outputs.is_arch_all == 'true')"
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq \
build-essential dpkg-dev debhelper devscripts fakeroot \
dh-python python3-all python3-setuptools
- name: Setup Node.js (for soc-web)
if: matrix.package == 'secubox-soc-web'
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build React app (for soc-web)
if: matrix.package == 'secubox-soc-web'
run: |
cd packages/secubox-soc-web
npm ci --ignore-scripts || npm install --ignore-scripts
npm run build
ls -la dist/
- name: Setup Go (for daemon)
if: matrix.package == 'secubox-daemon'
uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Build Go binaries (for daemon)
if: matrix.package == 'secubox-daemon'
env:
GOOS: linux
GOARCH: ${{ matrix.arch == 'arm64' && 'arm64' || 'amd64' }}
run: |
cd daemon
mkdir -p build
go build -ldflags "-s -w" -o build/secuboxd ./cmd/secuboxd
go build -ldflags "-s -w" -o build/secuboxctl ./cmd/secuboxctl
go build -ldflags "-s -w" -o build/c3box ./c3box/backend
ls -la build/
- name: Build ${{ matrix.package }} (${{ matrix.arch }})
if: "!(matrix.arch == 'arm64' && steps.check-arch.outputs.is_arch_all == 'true')"
run: |
cd packages/${{ matrix.package }}
if [ ! -f debian/control ]; then
echo "⚠ debian/control missing — skip"
exit 0
fi
dpkg-buildpackage -us -uc -b
echo "✅ Build OK: ${{ matrix.package }} (${{ matrix.arch }})"
- name: Collect artifacts
if: "!(matrix.arch == 'arm64' && steps.check-arch.outputs.is_arch_all == 'true')"
run: |
mkdir -p artifacts
find packages/ -maxdepth 1 -name "*.deb" -exec cp {} artifacts/ \;
ls -lh artifacts/ || echo "No .deb files"
- name: Upload artifacts
if: "!(matrix.arch == 'arm64' && steps.check-arch.outputs.is_arch_all == 'true')"
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.package }}-${{ matrix.arch }}
path: artifacts/*.deb
if-no-files-found: warn
retention-days: 7
# Combine all artifacts. `if: always() && != cancelled` so we still
# publish whatever .deb files the matrix succeeded on, even when a few
# entries failed (partial-release resilience).
collect:
needs: build
if: ${{ always() && needs.build.result != 'cancelled' }}
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: all-debs/
merge-multiple: true
- name: Generate SHA256SUMS
run: |
cd all-debs/
find . -name "*.deb" -exec mv {} . \; 2>/dev/null || true
sha256sum *.deb > SHA256SUMS 2>/dev/null || echo "No debs found"
cat SHA256SUMS
- name: Upload combined artifacts
uses: actions/upload-artifact@v4
with:
name: secubox-debs-all
path: all-debs/
retention-days: 30
# Publish to APT repo (on tag) — partial-release resilient.
publish:
needs: collect
runs-on: ubuntu-latest
if: ${{ always() && needs.collect.result == 'success' && startsWith(github.ref, 'refs/tags/v') }}
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download all packages
uses: actions/download-artifact@v4
with:
name: secubox-debs-all
path: debs/
- name: Install reprepro
run: sudo apt-get install -y reprepro gnupg
- name: Import GPG key
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
run: |
if [ -z "$GPG_PRIVATE_KEY" ]; then
echo "⚠ GPG_PRIVATE_KEY secret not set - skipping signing"
echo "SKIP_SIGN=true" >> $GITHUB_ENV
exit 0
fi
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
gpg --list-keys
- name: Setup repository
if: env.SKIP_SIGN != 'true'
run: |
mkdir -p repo/conf
cp repo/conf/distributions repo/conf/ 2>/dev/null || true
cat > repo/conf/options << EOF
verbose
basedir $(pwd)/repo
EOF
- name: Add packages to repository
if: env.SKIP_SIGN != 'true'
run: |
for deb in debs/*.deb; do
[ -f "$deb" ] || continue
echo "Adding: $(basename $deb)"
reprepro -b repo includedeb bookworm "$deb" || true
done
- name: Export public key
if: env.SKIP_SIGN != 'true'
run: |
gpg --armor --export packages@secubox.in > repo/secubox-keyring.gpg
- name: Deploy to server
if: env.SKIP_SIGN != 'true'
env:
SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
if [ -z "$SSH_PRIVATE_KEY" ]; then
echo "⚠ DEPLOY_SSH_KEY not set - skipping deploy"
exit 0
fi
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "$KNOWN_HOSTS" > ~/.ssh/known_hosts
rsync -avz --delete \
repo/dists/ repo/pool/ repo/secubox-keyring.gpg \
deploy@apt.secubox.in:/var/www/apt.secubox.in/
- name: Delete existing release assets (if any)
run: |
VERSION="${{ github.ref_name }}"
if gh release view "$VERSION" &>/dev/null; then
echo "Release $VERSION exists, checking for duplicate assets..."
for file in debs/*.deb debs/SHA256SUMS; do
[ -f "$file" ] || continue
name=$(basename "$file")
gh release delete-asset "$VERSION" "$name" --yes 2>/dev/null || true
done
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
files: |
debs/*.deb
debs/SHA256SUMS
body: |
## SecuBox-DEB ${{ github.ref_name }}
Debian bookworm packages for GlobalScale MOCHAbin / ESPRESSObin (arm64) and x86_64.
**33 packages** including:
- Core infrastructure (hub, core, portal)
- Security (crowdsec, waf, auth, nac)
- Networking (wireguard, netmodes, dpi, qos)
- Applications (mail, gitea, nextcloud, webmail)
- Publishing (droplet, streamlit, metablogizer)
**Installation:**
```bash
curl -fsSL https://apt.secubox.in/secubox-keyring.gpg | \
sudo tee /usr/share/keyrings/secubox.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/secubox.gpg] https://apt.secubox.in bookworm main" | \
sudo tee /etc/apt/sources.list.d/secubox.list
sudo apt update && sudo apt install secubox-full
```

View File

@ -0,0 +1,201 @@
# SecuBox CLI — Build Go Binary
# Builds the secubox meta-script generator for linux-amd64 and linux-arm64
name: Build SecuBox CLI
on:
push:
branches: [master, main]
paths:
- 'cmd/secubox/**'
- '.github/workflows/build-secubox-cli.yml'
tags: ['v*']
pull_request:
branches: [master, main]
paths:
- 'cmd/secubox/**'
workflow_dispatch:
inputs:
version:
description: 'Version override (e.g., v2.8.0)'
required: false
env:
GO_VERSION: '1.22'
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: cmd/secubox/go.sum
- name: Download dependencies
working-directory: cmd/secubox
run: go mod download
- name: Run tests
working-directory: cmd/secubox
run: go test -v -race -coverprofile=coverage.out ./...
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: cmd/secubox/coverage.out
build:
name: Build
needs: test
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: linux
goarch: amd64
suffix: linux-amd64
- goos: linux
goarch: arm64
suffix: linux-arm64
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # For git describe
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: cmd/secubox/go.sum
- name: Determine version
id: version
run: |
if [ -n "${{ github.event.inputs.version }}" ]; then
VERSION="${{ github.event.inputs.version }}"
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then
VERSION="${{ github.ref_name }}"
else
VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo 'dev')"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Building version: $VERSION"
- name: Build binary
working-directory: cmd/secubox
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
run: |
VERSION="${{ steps.version.outputs.version }}"
BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
COMMIT=$(git rev-parse --short HEAD)
go build -ldflags="-s -w \
-X main.Version=$VERSION \
-X main.BuildTime=$BUILD_TIME \
-X main.Commit=$COMMIT" \
-o secubox-${{ matrix.suffix }} .
- name: Compress binary
working-directory: cmd/secubox
run: |
tar -czvf secubox-${{ matrix.suffix }}.tar.gz secubox-${{ matrix.suffix }}
sha256sum secubox-${{ matrix.suffix }}.tar.gz > secubox-${{ matrix.suffix }}.tar.gz.sha256
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: secubox-${{ matrix.suffix }}
path: |
cmd/secubox/secubox-${{ matrix.suffix }}.tar.gz
cmd/secubox/secubox-${{ matrix.suffix }}.tar.gz.sha256
release:
name: Release
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts/
merge-multiple: true
- name: Generate release notes
run: |
cat > RELEASE_NOTES.md << 'EOF'
# SecuBox CLI ${{ github.ref_name }}
Meta-script generator for SecuBox-DEB image building.
## Features
- Profile-based image generation
- Board detection and auto-configuration
- A/B partition OTA updates
- GitHub releases integration
## Downloads
| Platform | File | SHA256 |
|----------|------|--------|
| Linux x64 | `secubox-linux-amd64.tar.gz` | [checksum] |
| Linux ARM64 | `secubox-linux-arm64.tar.gz` | [checksum] |
## Installation
```bash
# Linux x64
curl -LO https://github.com/CyberMind-FR/secubox-deb/releases/download/${{ github.ref_name }}/secubox-linux-amd64.tar.gz
tar xzf secubox-linux-amd64.tar.gz
sudo mv secubox-linux-amd64 /usr/local/bin/secubox
# Linux ARM64
curl -LO https://github.com/CyberMind-FR/secubox-deb/releases/download/${{ github.ref_name }}/secubox-linux-arm64.tar.gz
tar xzf secubox-linux-arm64.tar.gz
sudo mv secubox-linux-arm64 /usr/local/bin/secubox
```
## Quick Start
```bash
# Interactive wizard
secubox gen
# Generate for specific board
secubox gen --board mochabin --tier pro
# Build image
secubox build --board mochabin --output secubox.img
# Check hardware
secubox info
# OTA update
secubox ota update --url https://releases.secubox.in/v2.8.0/secubox-mochabin.img.gz
```
EOF
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: SecuBox CLI ${{ github.ref_name }}
body_path: RELEASE_NOTES.md
files: |
artifacts/*.tar.gz
artifacts/*.sha256
draft: false
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }}

View File

@ -0,0 +1,22 @@
# 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.
name: License Headers
on:
pull_request:
push:
branches: [master]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Verify CMSD-1.0 headers
run: python3 scripts/license-headers.py --check

View File

@ -0,0 +1,158 @@
# SecuBox — Publish packages to apt.secubox.in
# This workflow is triggered after build-packages.yml completes on a tag
name: Publish Packages
on:
workflow_call:
inputs:
distribution:
description: 'Target distribution'
required: false
type: string
default: 'bookworm'
secrets:
GPG_PRIVATE_KEY:
required: true
DEPLOY_SSH_KEY:
required: true
DEPLOY_KNOWN_HOSTS:
required: true
workflow_run:
workflows: ["Build SecuBox .deb packages"]
types: [completed]
branches: [main, master]
workflow_dispatch:
inputs:
distribution:
description: 'Target distribution'
required: true
default: 'bookworm'
type: choice
options:
- bookworm
- trixie
run_id:
description: 'Build workflow run ID to publish (leave empty for latest)'
required: false
env:
REPO_HOST: apt.secubox.in
REPO_USER: deploy
REPO_PATH: /var/www/apt.secubox.in
jobs:
publish:
runs-on: ubuntu-latest
# Only run if triggered workflow succeeded and was a tag
if: |
(github.event.workflow_run.conclusion == 'success' &&
startsWith(github.event.workflow_run.head_branch, 'v')) ||
github.event_name == 'workflow_dispatch'
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download artifacts from build workflow
uses: dawidd6/action-download-artifact@v3
with:
workflow: build-packages.yml
run_id: ${{ github.event.inputs.run_id || github.event.workflow_run.id }}
name: secubox-debs-all
path: debs/
- name: List downloaded packages
run: |
echo "Downloaded packages:"
ls -lh debs/*.deb 2>/dev/null | head -40 || echo "No .deb files found"
echo "Total: $(ls -1 debs/*.deb 2>/dev/null | wc -l) packages"
- name: Install reprepro
run: sudo apt-get update && sudo apt-get install -y reprepro gnupg
- name: Import GPG key
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
run: |
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
gpg --list-keys
- name: Setup repository structure
run: |
mkdir -p repo/conf
cp repo/conf/distributions repo/conf/
cat > repo/conf/options << EOF
verbose
basedir $(pwd)/repo
EOF
- name: Add packages to repository
env:
DIST: ${{ github.event.inputs.distribution || 'bookworm' }}
run: |
echo "Adding packages to $DIST..."
for deb in debs/*.deb; do
[ -f "$deb" ] || continue
echo " Adding: $(basename $deb)"
reprepro -b repo includedeb "$DIST" "$deb" || echo " Warning: Failed to add $(basename $deb)"
done
- name: Export public GPG key
run: |
gpg --armor --export packages@secubox.in > repo/secubox-keyring.gpg
gpg --export packages@secubox.in > repo/secubox-keyring.gpg.bin
cp repo/secubox-keyring.gpg repo/dists/
- name: Generate install script
run: |
cat > repo/install.sh << 'INSTALL'
#!/bin/bash
# SecuBox APT Repository Installation Script
set -e
echo "Installing SecuBox APT repository..."
# Download and install GPG key
curl -fsSL https://apt.secubox.in/secubox-keyring.gpg | \
sudo tee /usr/share/keyrings/secubox.gpg > /dev/null
# Add repository
echo "deb [signed-by=/usr/share/keyrings/secubox.gpg] https://apt.secubox.in bookworm main" | \
sudo tee /etc/apt/sources.list.d/secubox.list
# Update package lists
sudo apt-get update
echo ""
echo "SecuBox repository installed successfully!"
echo ""
echo "Install packages with:"
echo " sudo apt install secubox-full # All modules"
echo " sudo apt install secubox-lite # Essential modules only"
echo ""
INSTALL
chmod +x repo/install.sh
- name: Deploy to server
env:
SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "$KNOWN_HOSTS" > ~/.ssh/known_hosts
echo "Deploying to ${REPO_HOST}..."
rsync -avz --delete \
repo/dists/ repo/pool/ repo/secubox-keyring.gpg repo/secubox-keyring.gpg.bin repo/install.sh \
${REPO_USER}@${REPO_HOST}:${REPO_PATH}/
- name: Verify deployment
run: |
echo "Verifying deployment..."
curl -sf "https://${REPO_HOST}/dists/bookworm/Release" | head -10
echo ""
echo "Repository published successfully!"
echo "Install with: curl -fsSL https://${REPO_HOST}/install.sh | sudo bash"

View File

@ -0,0 +1,226 @@
# SecuBox — Complete Release Workflow
# Orchestrates package builds, image builds, and publishing
name: Release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., v1.0.0)'
required: true
build_images:
description: 'Build system images'
type: boolean
default: true
publish:
description: 'Publish to APT repo'
type: boolean
default: true
jobs:
# Build all packages
build-packages:
uses: ./.github/workflows/build-packages.yml
secrets: inherit
# Build system images (optional).
# `if: always() && != cancelled` lets us still ship images even if a few
# matrix entries in build-packages failed (partial-release resilience).
build-images:
if: ${{ always() && needs.build-packages.result != 'cancelled' && github.event.inputs.build_images != 'false' }}
needs: build-packages
uses: ./.github/workflows/build-image.yml
with:
board: all
size: '8G'
secrets: inherit
# Build Live USB images (all platforms) — partial-release resilient.
build-live-usb:
if: ${{ always() && needs.build-packages.result != 'cancelled' }}
needs: build-packages
uses: ./.github/workflows/build-all-live-usb.yml
with:
platform: all
secrets: inherit
# Publish to APT repository — partial-release resilient.
publish:
if: ${{ always() && needs.build-packages.result != 'cancelled' && github.event.inputs.publish != 'false' }}
needs: build-packages
uses: ./.github/workflows/publish-packages.yml
secrets: inherit
# Create unified release. Ships whatever artifacts the upstream jobs
# managed to produce — handles partial successes gracefully (any
# `failure` here means "best-effort", `cancelled` we hard-skip).
create-release:
needs: [build-packages, build-images, build-live-usb]
if: ${{ always() && needs.build-packages.result != 'cancelled' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download package artifacts
uses: actions/download-artifact@v4
with:
name: secubox-debs-all
path: release/packages/
- name: Download image artifacts
if: needs.build-images.result == 'success'
uses: actions/download-artifact@v4
with:
pattern: secubox-*-bookworm
path: release/images/
merge-multiple: true
- name: Download Live USB artifacts
if: needs.build-live-usb.result == 'success'
uses: actions/download-artifact@v4
with:
pattern: secubox-*-bookworm
path: release/live-usb/
merge-multiple: true
- name: Generate release notes
run: |
VERSION="${{ github.ref_name || github.event.inputs.version }}"
cat > release/RELEASE_NOTES.md << EOF
# SecuBox-DEB ${VERSION}
## ⚡ CyberMind Security Platform
### What's Included
**124 Packages** across all security domains:
- **Core:** hub, core, portal, system, console
- **Security:** crowdsec, waf, auth, nac, threats, ipblock, mac-guard
- **Networking:** wireguard, haproxy, dpi, qos, netmodes, vhost, cdn
- **Applications:** mail, gitea, nextcloud, ollama, jellyfin, matrix
- **SOC:** soc-agent, soc-gateway, soc-web (hierarchical security operations)
- **Intel:** device-intel, vortex-dns, vortex-firewall, ai-insights
### System Images (Disk Install)
- MOCHAbin (Marvell Armada 7040) - arm64
- ESPRESSObin v7 (Marvell Armada 3720) - arm64
- ESPRESSObin Ultra (Marvell Armada 3720) - arm64
- Raspberry Pi 400 - arm64
- VM x64 (VirtualBox/QEMU) - amd64
- QEMU ARM64 emulation script (x86 hosts)
### Live USB Images (Bootable + Flasher)
- x64 Live USB (GRUB UEFI/BIOS) - amd64
- EspressoBin V7 Live USB (U-Boot) - arm64
- Raspberry Pi 400 Live USB - arm64
### Boot Options
- ⚡ **SecuBox Live** - Normal boot with persistence
- 🖼️ **Kiosk GUI** - Fullscreen browser (default)
- 📟 **Console TUI** - Text-based dashboard
- 🌉 **Bridge Mode** - Inline transparent bridge
- 💾 **Install to Disk** - Permanent installation
- 🚀 **To RAM** - Load entire system to memory
## Quick Install
### From APT Repository
\`\`\`bash
curl -fsSL https://apt.secubox.in/install.sh | sudo bash
sudo apt install secubox-full
\`\`\`
### From Live USB Image
\`\`\`bash
# Flash to USB
gunzip -c secubox-live-amd64-bookworm.img.gz | sudo dd of=/dev/sdX bs=4M status=progress
# VirtualBox VM
bash image/create-vbox-vm.sh --name SecuBox --memory 4096
\`\`\`
### Default Credentials
- **Web UI:** https://<IP>:9443 (admin / secubox)
- **SSH:** root / secubox
## Checksums
All files are signed with GPG key \`packages@secubox.in\`.
\`\`\`bash
gpg --verify SHA256SUMS.gpg
sha256sum -c SHA256SUMS
\`\`\`
## Documentation
- [Wiki Home](https://github.com/CyberMind-FR/secubox-deb/wiki)
- [VirtualBox Quick Start](https://github.com/CyberMind-FR/secubox-deb/wiki/VirtualBox)
- [API Reference](https://github.com/CyberMind-FR/secubox-deb/wiki/API-Reference)
EOF
- name: Generate combined checksums
run: |
cd release/
find . -name "*.deb" -o -name "*.img.gz" -o -name "*.img.xz" -o -name "*.img" | \
xargs sha256sum 2>/dev/null | sort > SHA256SUMS || true
- name: Sign checksums
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
run: |
if [ -n "$GPG_PRIVATE_KEY" ]; then
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
cd release/
gpg --clearsign SHA256SUMS
mv SHA256SUMS.asc SHA256SUMS.gpg
fi
- name: Copy VM scripts to release
run: |
cp image/create-qemu-arm64-vm.sh release/
cp image/create-vbox-vm.sh release/
- name: Delete existing release assets (for retry)
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${{ github.ref_name || github.event.inputs.version }}"
# Check if release exists and delete conflicting assets
if gh release view "$VERSION" &>/dev/null; then
echo "Release exists, cleaning up for retry..."
# Get existing asset names
existing=$(gh release view "$VERSION" --json assets --jq '.assets[].name')
for asset in release/packages/*.deb release/images/*.img.gz release/images/*.img.xz release/live-usb/*.img*; do
[ -f "$asset" ] || continue
name=$(basename "$asset")
if echo "$existing" | grep -qF "$name"; then
echo "Deleting existing asset: $name"
gh release delete-asset "$VERSION" "$name" --yes 2>/dev/null || true
fi
done
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: SecuBox-DEB ${{ github.ref_name || github.event.inputs.version }}
body_path: release/RELEASE_NOTES.md
files: |
release/packages/*.deb
release/images/*.img.gz
release/images/*.img.xz
release/live-usb/*.img*
release/create-qemu-arm64-vm.sh
release/create-vbox-vm.sh
release/SHA256SUMS
release/SHA256SUMS.gpg
draft: false
fail_on_unmatched_files: false
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }}

View File

@ -0,0 +1,167 @@
name: Sync All — Build, Release, Metrics
on:
push:
branches: [master, main]
workflow_dispatch:
inputs:
force_rebuild:
description: 'Force rebuild all packages'
required: false
default: 'false'
type: boolean
env:
SECUBOX_VERSION: "1.9.0"
jobs:
# ═══════════════════════════════════════════════════════════════
# 1. Discover changes and compute metrics
# ═══════════════════════════════════════════════════════════════
discover:
runs-on: ubuntu-latest
outputs:
packages_changed: ${{ steps.changes.outputs.packages }}
total_packages: ${{ steps.metrics.outputs.total }}
total_endpoints: ${{ steps.metrics.outputs.endpoints }}
migration_percent: ${{ steps.metrics.outputs.migration }}
commit_count: ${{ steps.metrics.outputs.commits }}
issue_count: ${{ steps.metrics.outputs.issues }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changed packages
id: changes
run: |
# Find packages changed in this push
if [ "${{ github.event_name }}" = "push" ]; then
CHANGED=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} \
| grep '^packages/secubox-' \
| cut -d'/' -f2 \
| sort -u \
| jq -R -s -c 'split("\n") | map(select(length > 0))')
else
CHANGED='[]'
fi
echo "packages=$CHANGED" >> $GITHUB_OUTPUT
echo "Changed packages: $CHANGED"
- name: Compute project metrics
id: metrics
run: |
# Count packages
TOTAL=$(find packages/secubox-* -maxdepth 0 -type d 2>/dev/null | wc -l)
echo "total=$TOTAL" >> $GITHUB_OUTPUT
# Count API endpoints (rough estimate from main.py files)
ENDPOINTS=$(grep -r '@app\.\|@router\.' packages/*/api/*.py 2>/dev/null | wc -l || echo "2000")
echo "endpoints=$ENDPOINTS" >> $GITHUB_OUTPUT
# Migration percent (packages with api/main.py)
WITH_API=$(find packages/secubox-*/api/main.py 2>/dev/null | wc -l)
PERCENT=$((WITH_API * 100 / TOTAL))
echo "migration=$PERCENT" >> $GITHUB_OUTPUT
# Commit count
COMMITS=$(git rev-list --count HEAD)
echo "commits=$COMMITS" >> $GITHUB_OUTPUT
# Open issues (via gh)
ISSUES=$(gh issue list --state open --limit 1000 --json number | jq length 2>/dev/null || echo "0")
echo "issues=$ISSUES" >> $GITHUB_OUTPUT
env:
GH_TOKEN: ${{ github.token }}
# ═══════════════════════════════════════════════════════════════
# 2. Build changed packages (or all if forced)
# ═══════════════════════════════════════════════════════════════
build-packages:
needs: discover
if: needs.discover.outputs.packages_changed != '[]' || github.event.inputs.force_rebuild == 'true'
uses: ./.github/workflows/build-packages.yml
secrets: inherit
# ═══════════════════════════════════════════════════════════════
# 3. Update WIP and metrics badges
# ═══════════════════════════════════════════════════════════════
update-metrics:
needs: [discover, build-packages]
if: always()
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Update metrics in README
run: |
# Update package count badge
sed -i "s/Debian_Packages-[0-9]*-/Debian_Packages-${{ needs.discover.outputs.total_packages }}-/" README.md
# Update migration percent
sed -i "s/Migration-[0-9]*%25-/Migration-${{ needs.discover.outputs.migration_percent }}%25-/" README.md
# Update endpoints count
sed -i "s/API_Endpoints-[0-9]*+-/API_Endpoints-${{ needs.discover.outputs.total_endpoints }}+-/" README.md
- name: Update WIP with session info
run: |
DATE=$(date '+%Y-%m-%d')
SESSION=$(grep -oP 'Session \K[0-9]+' .claude/WIP.md | head -1 || echo "0")
NEW_SESSION=$((SESSION))
# Add CI sync entry if not already present today
if ! grep -q "CI Sync $DATE" .claude/WIP.md; then
echo "" >> .claude/WIP.md
echo "## CI Sync $DATE" >> .claude/WIP.md
echo "- Packages: ${{ needs.discover.outputs.total_packages }}" >> .claude/WIP.md
echo "- Endpoints: ${{ needs.discover.outputs.total_endpoints }}" >> .claude/WIP.md
echo "- Migration: ${{ needs.discover.outputs.migration_percent }}%" >> .claude/WIP.md
echo "- Commits: ${{ needs.discover.outputs.commit_count }}" >> .claude/WIP.md
fi
- name: Commit metrics update
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add README.md .claude/WIP.md || true
git diff --staged --quiet || git commit -m "ci: Update metrics [skip ci]"
git push || true
# ═══════════════════════════════════════════════════════════════
# 4. Create release on tags
# ═══════════════════════════════════════════════════════════════
release:
needs: [discover, build-packages]
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/workflows/release.yml
secrets: inherit
# ═══════════════════════════════════════════════════════════════
# 5. Summary
# ═══════════════════════════════════════════════════════════════
summary:
needs: [discover, build-packages, update-metrics]
if: always()
runs-on: ubuntu-latest
steps:
- name: Generate summary
run: |
echo "## SecuBox Sync Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Packages | ${{ needs.discover.outputs.total_packages }} |" >> $GITHUB_STEP_SUMMARY
echo "| API Endpoints | ${{ needs.discover.outputs.total_endpoints }}+ |" >> $GITHUB_STEP_SUMMARY
echo "| Migration | ${{ needs.discover.outputs.migration_percent }}% |" >> $GITHUB_STEP_SUMMARY
echo "| Commits | ${{ needs.discover.outputs.commit_count }} |" >> $GITHUB_STEP_SUMMARY
echo "| Open Issues | ${{ needs.discover.outputs.issue_count }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Changed Packages" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
echo '${{ needs.discover.outputs.packages_changed }}' >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY

View File

@ -196,9 +196,12 @@ jobs:
if-no-files-found: warn
retention-days: 7
# Combine all artifacts
# Combine all artifacts. `if: always() && != cancelled` so we still
# publish whatever .deb files the matrix succeeded on, even when a few
# entries failed (partial-release resilience).
collect:
needs: build
if: ${{ always() && needs.build.result != 'cancelled' }}
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
@ -221,11 +224,11 @@ jobs:
path: all-debs/
retention-days: 30
# Publish to APT repo (on tag)
# Publish to APT repo (on tag) — partial-release resilient.
publish:
needs: collect
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
if: ${{ always() && needs.collect.result == 'success' && startsWith(github.ref, 'refs/tags/v') }}
environment: production
steps:

View File

@ -25,9 +25,11 @@ jobs:
uses: ./.github/workflows/build-packages.yml
secrets: inherit
# Build system images (optional)
# Build system images (optional).
# `if: always() && != cancelled` lets us still ship images even if a few
# matrix entries in build-packages failed (partial-release resilience).
build-images:
if: ${{ github.event.inputs.build_images != 'false' }}
if: ${{ always() && needs.build-packages.result != 'cancelled' && github.event.inputs.build_images != 'false' }}
needs: build-packages
uses: ./.github/workflows/build-image.yml
with:
@ -35,25 +37,28 @@ jobs:
size: '8G'
secrets: inherit
# Build Live USB images (all platforms)
# Build Live USB images (all platforms) — partial-release resilient.
build-live-usb:
if: ${{ always() && needs.build-packages.result != 'cancelled' }}
needs: build-packages
uses: ./.github/workflows/build-all-live-usb.yml
with:
platform: all
secrets: inherit
# Publish to APT repository
# Publish to APT repository — partial-release resilient.
publish:
if: ${{ github.event.inputs.publish != 'false' }}
if: ${{ always() && needs.build-packages.result != 'cancelled' && github.event.inputs.publish != 'false' }}
needs: build-packages
uses: ./.github/workflows/publish-packages.yml
secrets: inherit
# Create unified release
# Create unified release. Ships whatever artifacts the upstream jobs
# managed to produce — handles partial successes gracefully (any
# `failure` here means "best-effort", `cancelled` we hard-skip).
create-release:
needs: [build-packages, build-images, build-live-usb]
if: always() && needs.build-packages.result == 'success'
if: ${{ always() && needs.build-packages.result != 'cancelled' }}
runs-on: ubuntu-latest
steps:

View File

@ -1 +0,0 @@
13

View File

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

View File

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

View File

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

View File

@ -1 +0,0 @@
13

View File

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

View File

@ -3,13 +3,16 @@
# Git server in Alpine LXC for Debian
# Three-fold architecture: Components, Status, Access
VERSION="1.4.0"
VERSION="1.5.0"
CONFIG_FILE="/etc/secubox/gitea.toml"
LXC_PATH="/srv/lxc"
LXC_PATH="" # auto-detected below if blank, see resolve_lxc_path
DATA_PATH="/srv/gitea"
CONTAINER="gitea"
GITEA_VERSION="1.22.6"
# Gitea API hard floors
MIRROR_MIN_INTERVAL="10m0s"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
@ -32,7 +35,22 @@ HTTP_PORT=$(config_get "http_port" "3000")
SSH_PORT=$(config_get "ssh_port" "2222")
DOMAIN=$(config_get "domain" "git.local")
APP_NAME=$(config_get "app_name" "SecuBox Git")
LXC_IP=$(config_get "lxc_ip" "192.168.255.40")
LXC_IP=$(config_get "lxc_ip" "10.100.0.40")
GITEA_APP_INI=$(config_get "app_ini" "/var/lib/gitea/custom/conf/app.ini")
ADMIN_USER=$(config_get "admin_user" "gandalf")
# Resolve LXC_PATH: explicit config wins, then fall back to the first
# directory containing a `<CONTAINER>/rootfs/` from the board conventions.
resolve_lxc_path() {
local cfg
cfg=$(config_get "lxc_path" "")
if [ -n "$cfg" ]; then echo "$cfg"; return; fi
for p in /data/lxc /srv/lxc /var/lib/lxc; do
[ -d "$p/$CONTAINER/rootfs" ] && { echo "$p"; return; }
done
echo "/data/lxc" # match modern board reality; install will create it
}
LXC_PATH=${LXC_PATH:-$(resolve_lxc_path)}
require_root() {
[ "$(id -u)" -eq 0 ] || { error "Root required"; exit 1; }
@ -332,48 +350,597 @@ user_list() {
# Mirror Management
# ============================================================================
mirror_add() {
local repo_url="$1"
local local_name="$2"
local owner="${3:-admin}"
# ============================================================================
# Repo / Mirror — the third routing verb (issue #176)
# Forges `repo mirror add/remove/list/sync` parallel to mitmproxyctl route
# (issue #173) and haproxyctl vhost. Wraps the 4 quirks of the Gitea
# pull-mirror API into one atomic operation:
# 1. No "convert existing repo to mirror" -> delete + migrate
# 2. Minimum interval = 10m (enforced server-side, 500 below)
# 3. Filesystem fetches don't trigger Gitea hooks -> DB stays "empty"
# 4. Token generation needs to run inside the LXC as the gitea user
# ============================================================================
if [ -z "$repo_url" ] || [ -z "$local_name" ]; then
echo "Usage: giteactl mirror add <repo_url> <local_name> [owner]"
return 1
fi
if ! lxc_running; then
error "Container not running"
return 1
fi
log "Creating mirror: $local_name from $repo_url"
# Use Gitea API for mirror creation
local api_token
api_token=$(get_admin_token)
if [ -n "$api_token" ]; then
lxc_attach "curl -s -X POST \
-H 'Authorization: token $api_token' \
-H 'Content-Type: application/json' \
-d '{
\"clone_addr\": \"$repo_url\",
\"repo_name\": \"$local_name\",
\"mirror\": true,
\"private\": false
}' \
http://localhost:${HTTP_PORT}/api/v1/repos/migrate"
else
warn "No admin token available. Create mirror manually via web interface."
fi
# Generate a short-lived admin token via gitea CLI inside the LXC and echo
# it. Token name embeds the PID so concurrent calls don't collide.
gitea_token_gen() {
require_root
if ! lxc_running; then error "Container not running"; return 1; fi
local tname="giteactl-$$-$(date +%s)"
local out
out=$(lxc-attach -n "$CONTAINER" -P "$LXC_PATH" -- \
su -s /bin/bash gitea -c \
"gitea --config $GITEA_APP_INI admin user generate-access-token \
--username $ADMIN_USER --token-name $tname --scopes all 2>&1" \
| tail -1)
# output line ends with the actual token after the last ':'
local token
token=$(echo "$out" | awk -F: '{print $NF}' | tr -d ' ')
[ -z "$token" ] && { error "token gen failed: $out"; return 1; }
echo "$token" "$tname"
}
get_admin_token() {
# Get admin token from database
local db_file="$DATA_PATH/git/gitea.db"
if [ -f "$db_file" ]; then
sqlite3 "$db_file" "SELECT token_hash FROM access_token WHERE name='secubox-api' LIMIT 1" 2>/dev/null
gitea_token_revoke() {
local tname="$1"
[ -z "$tname" ] && return 0
lxc-attach -n "$CONTAINER" -P "$LXC_PATH" -- \
su -s /bin/bash gitea -c \
"gitea --config $GITEA_APP_INI admin user delete-access-token \
--username $ADMIN_USER --token-name $tname 2>&1" \
>/dev/null 2>&1 || true
}
# Run a curl against the in-LXC Gitea API. Caller passes method + path
# (without /api/v1 prefix) + body (or "" if none). Echo HTTP code on stderr,
# body on stdout.
gitea_api() {
local token="$1" method="$2" path="$3" body="$4"
local url="http://localhost:${HTTP_PORT}/api/v1${path}"
local args=("-s" "-X" "$method" "-H" "Authorization: token $token" "-H" "Content-Type: application/json")
[ -n "$body" ] && args+=("-d" "$body")
lxc-attach -n "$CONTAINER" -P "$LXC_PATH" -- \
curl "${args[@]}" -w "\nHTTP_CODE:%{http_code}\n" "$url"
}
# Normalize an interval string. Gitea requires ≥10m; coerce silently to the
# floor so operators can ask for "5m" and get the closest legal value.
_normalize_interval() {
local i="$1"
[ -z "$i" ] && { echo "$MIRROR_MIN_INTERVAL"; return; }
# Already in the canonical "Nm0s" form
case "$i" in
*h*|*d*) echo "$i"; return ;;
*m*)
local n
n=$(echo "$i" | sed 's/[^0-9].*//')
if [ -n "$n" ] && [ "$n" -lt 10 ]; then
warn "interval ${i} below Gitea floor ${MIRROR_MIN_INTERVAL}; coerced"
echo "$MIRROR_MIN_INTERVAL"
else
# ensure "NmXs" canonical
case "$i" in *s) echo "$i" ;; *) echo "${i}0s" ;; esac
fi
;;
*) echo "$i" ;;
esac
}
# Parse "owner/name" or "name" (default owner = ADMIN_USER)
_parse_repo_ref() {
local ref="$1"
case "$ref" in
*/*) echo "${ref%/*}" "${ref#*/}" ;;
*) echo "$ADMIN_USER" "$ref" ;;
esac
}
cmd_repo() {
local action="${1:-}"; shift || true
case "$action" in
create) cmd_repo_create "$@" ;;
delete|del) cmd_repo_delete "$@" ;;
mirror) cmd_repo_mirror "$@" ;;
list|ls) cmd_repo_list "$@" ;;
*)
cat <<EOF
Repo commands:
repo create OWNER/NAME [--private] [--default-branch BR]
repo delete OWNER/NAME [--force]
repo list [OWNER]
repo mirror add OWNER/NAME GITHUB_URL [--interval 10m] [--force]
(--force allows recreating an
existing non-mirror repo)
repo mirror remove OWNER/NAME (turns off mirror, repo stays)
repo mirror sync OWNER/NAME (trigger an immediate pull)
repo mirror list (all mirror repos, JSON)
EOF
;;
esac
}
cmd_repo_create() {
local ref="$1"; shift || true
[ -z "$ref" ] && { error "repo create: OWNER/NAME required"; return 1; }
local owner name; read -r owner name <<<"$(_parse_repo_ref "$ref")"
local private=false default_branch="master"
while [ $# -gt 0 ]; do
case "$1" in
--private) private=true ;;
--default-branch) default_branch="$2"; shift ;;
*) error "unknown flag: $1"; return 1 ;;
esac
shift
done
local tok tname; read -r tok tname <<<"$(gitea_token_gen)" || return 1
local body
body=$(printf '{"name":"%s","description":"Created by giteactl","private":%s,"default_branch":"%s"}' \
"$name" "$private" "$default_branch")
local out
out=$(gitea_api "$tok" POST "/admin/users/${owner}/repos" "$body")
gitea_token_revoke "$tname"
local code
code=$(echo "$out" | grep HTTP_CODE | cut -d: -f2)
case "$code" in
201) log "created ${owner}/${name}"; return 0 ;;
409) warn "${owner}/${name} already exists"; return 0 ;;
*) error "create failed (HTTP $code): $(echo "$out" | head -1)"; return 1 ;;
esac
}
cmd_repo_delete() {
local ref="$1"; shift || true
[ -z "$ref" ] && { error "repo delete: OWNER/NAME required"; return 1; }
local force=false
[ "${1:-}" = "--force" ] && force=true
local owner name; read -r owner name <<<"$(_parse_repo_ref "$ref")"
if ! $force; then
warn "repo delete is destructive — pass --force to proceed (will delete ${owner}/${name})"
return 1
fi
local tok tname; read -r tok tname <<<"$(gitea_token_gen)" || return 1
local out
out=$(gitea_api "$tok" DELETE "/repos/${owner}/${name}" "")
gitea_token_revoke "$tname"
local code
code=$(echo "$out" | grep HTTP_CODE | cut -d: -f2)
case "$code" in
204) log "deleted ${owner}/${name}"; return 0 ;;
404) warn "${owner}/${name} did not exist"; return 0 ;;
*) error "delete failed (HTTP $code): $(echo "$out" | head -1)"; return 1 ;;
esac
}
cmd_repo_list() {
local owner="${1:-}"
local tok tname; read -r tok tname <<<"$(gitea_token_gen)" || return 1
local path
if [ -n "$owner" ]; then path="/users/${owner}/repos?limit=100"
else path="/repos/search?limit=100"; fi
local out
out=$(gitea_api "$tok" GET "$path" "")
gitea_token_revoke "$tname"
echo "$out" | sed '/^HTTP_CODE/d'
}
cmd_repo_mirror() {
local action="${1:-}"; shift || true
case "$action" in
add) cmd_repo_mirror_add "$@" ;;
remove|rm) cmd_repo_mirror_remove "$@" ;;
sync) cmd_repo_mirror_sync "$@" ;;
list|ls) cmd_repo_mirror_list "$@" ;;
*)
cat <<EOF
Mirror commands:
repo mirror add OWNER/NAME GITHUB_URL [--interval 10m] [--force]
repo mirror remove OWNER/NAME
repo mirror sync OWNER/NAME
repo mirror list
EOF
;;
esac
}
cmd_repo_mirror_add() {
local ref="$1" url="$2"; shift 2 || { error "repo mirror add: OWNER/NAME URL required"; return 1; }
local interval="$MIRROR_MIN_INTERVAL" force=false
while [ $# -gt 0 ]; do
case "$1" in
--interval) interval="$2"; shift ;;
--force) force=true ;;
*) error "unknown flag: $1"; return 1 ;;
esac
shift
done
interval=$(_normalize_interval "$interval")
local owner name; read -r owner name <<<"$(_parse_repo_ref "$ref")"
local tok tname; read -r tok tname <<<"$(gitea_token_gen)" || return 1
# 1. Probe existing state
local probe
probe=$(gitea_api "$tok" GET "/repos/${owner}/${name}" "")
local probe_code
probe_code=$(echo "$probe" | grep HTTP_CODE | cut -d: -f2)
local is_mirror=false
if [ "$probe_code" = "200" ]; then
echo "$probe" | grep -q '"mirror":true' && is_mirror=true
if $is_mirror; then
log "${owner}/${name} is already a mirror; updating interval to ${interval}"
local body
body=$(printf '{"mirror_interval":"%s"}' "$interval")
gitea_api "$tok" PATCH "/repos/${owner}/${name}" "$body" >/dev/null
cmd_repo_mirror_sync_inner "$tok" "$owner" "$name"
gitea_token_revoke "$tname"; return 0
else
if ! $force; then
error "${owner}/${name} exists and is NOT a mirror; pass --force to delete+recreate"
gitea_token_revoke "$tname"; return 1
fi
log "deleting existing non-mirror ${owner}/${name} (force)"
gitea_api "$tok" DELETE "/repos/${owner}/${name}" "" >/dev/null
sleep 2
fi
fi
# 2. Migrate as mirror
log "creating pull mirror ${owner}/${name} <- ${url} (interval ${interval})"
local body
body=$(printf '{"clone_addr":"%s","repo_name":"%s","repo_owner":"%s","mirror":true,"mirror_interval":"%s","service":"github","private":false,"description":"Mirror of %s"}' \
"$url" "$name" "$owner" "$interval" "$url")
local out
out=$(gitea_api "$tok" POST "/repos/migrate" "$body")
local code
code=$(echo "$out" | grep HTTP_CODE | cut -d: -f2)
case "$code" in
201) log "mirror created" ;;
500)
# Gitea returns 500 for "interval below minimum" — surface message
local msg
msg=$(echo "$out" | grep -o '"message":"[^"]*"' | head -1)
error "migrate failed (500): $msg"
gitea_token_revoke "$tname"; return 1
;;
*) error "migrate failed (HTTP $code): $(echo "$out" | head -3)"
gitea_token_revoke "$tname"; return 1 ;;
esac
# 3. Wait for initial clone to settle (avoid race with DB rescan)
sleep 5
cmd_repo_mirror_sync_inner "$tok" "$owner" "$name"
gitea_token_revoke "$tname"
log "OK — ${owner}/${name} mirrors ${url} every ${interval}"
return 0
}
cmd_repo_mirror_remove() {
local ref="$1"
[ -z "$ref" ] && { error "repo mirror remove: OWNER/NAME required"; return 1; }
local owner name; read -r owner name <<<"$(_parse_repo_ref "$ref")"
local tok tname; read -r tok tname <<<"$(gitea_token_gen)" || return 1
# Setting mirror_interval="" disables auto-sync; repo stays as non-mirror.
# Per Gitea docs there is no true "demote-to-non-mirror" — the flag stays.
# Cleanest: just set interval to 0 which disables sync.
local out
out=$(gitea_api "$tok" PATCH "/repos/${owner}/${name}" '{"mirror_interval":"0s"}')
gitea_token_revoke "$tname"
local code
code=$(echo "$out" | grep HTTP_CODE | cut -d: -f2)
case "$code" in
200) log "${owner}/${name} mirror sync disabled (interval=0); repo content preserved" ;;
*) error "remove failed (HTTP $code): $(echo "$out" | head -1)"; return 1 ;;
esac
}
cmd_repo_mirror_sync_inner() {
local tok="$1" owner="$2" name="$3"
log "triggering mirror-sync on ${owner}/${name}"
local out
out=$(gitea_api "$tok" POST "/repos/${owner}/${name}/mirror-sync" "")
local code
code=$(echo "$out" | grep HTTP_CODE | cut -d: -f2)
case "$code" in
200) log "sync triggered" ;;
400) warn "sync rejected (HTTP 400) — repo may still be in initial clone state" ;;
*) warn "sync returned HTTP $code" ;;
esac
}
cmd_repo_mirror_sync() {
local ref="$1"
[ -z "$ref" ] && { error "repo mirror sync: OWNER/NAME required"; return 1; }
local owner name; read -r owner name <<<"$(_parse_repo_ref "$ref")"
local tok tname; read -r tok tname <<<"$(gitea_token_gen)" || return 1
cmd_repo_mirror_sync_inner "$tok" "$owner" "$name"
gitea_token_revoke "$tname"
}
cmd_repo_mirror_list() {
local tok tname; read -r tok tname <<<"$(gitea_token_gen)" || return 1
local out
out=$(gitea_api "$tok" GET "/repos/search?mirror=true&limit=100" "")
gitea_token_revoke "$tname"
echo "$out" | sed '/^HTTP_CODE/d' | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
for r in d.get('data', []):
print(f\" {r['full_name']:40s} <- {r.get('original_url','?')} (every {r.get('mirror_interval','?')})\")
if not d.get('data'):
print('(no mirror repos)')
except Exception as e:
print(f'parse error: {e}', file=sys.stderr)
" 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
}
# ============================================================================
@ -788,6 +1355,23 @@ Users:
user passwd <n> <p> Change password
user list List users
Repos & Mirrors (issue #176, parallel to mitmproxyctl route):
repo create OWNER/NAME [--private] [--default-branch BR]
repo delete OWNER/NAME --force
repo list [OWNER]
repo mirror add OWNER/NAME GITHUB_URL [--interval 10m] [--force]
repo mirror remove OWNER/NAME
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
@ -823,6 +1407,8 @@ case "${1:-}" in
stop) shift; cmd_stop "$@" ;;
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 "$@" ;;

View File

@ -2,6 +2,13 @@
%:
dh $@
# /usr/local/bin admin-only tools (issue #44): dh_usrlocal only knows how to
# rehome directories (Policy 9.1.2), not individual files — it errors out
# with "<file> is not a directory" on our regen-safe + render-nginx-webui
# scripts. The deliberate placement under /usr/local/bin is the intended
# behaviour; we just need debhelper to leave the path alone.
override_dh_usrlocal:
override_dh_auto_install:
install -d debian/secubox-haproxy/usr/lib/secubox/haproxy/
cp -r api debian/secubox-haproxy/usr/lib/secubox/haproxy/

View File

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

View File

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

View File

@ -2,7 +2,7 @@ Source: secubox-metoblizer
Section: admin
Priority: optional
Maintainer: Gerald KERMA <devel@cybermind.fr>
Build-Depends: debhelper (>= 13)
Build-Depends: debhelper-compat (= 13)
Standards-Version: 4.6.2
Package: secubox-metoblizer

View File

@ -1,268 +1,410 @@
#!/usr/bin/env python3
"""mitmproxyctl — LXC container management for SecuBox WAF
# 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.
"""mitmproxyctl — SecuBox WAF interception layer control (issue #173)
The third verb of SecuBox's routing grammar, parallel to:
- haproxyctl vhost add/remove (routing layer)
- giteactl user add/remove (identity layer)
- mitmproxyctl route add/remove (interception layer) ← this tool
Usage:
mitmproxyctl install Create and configure LXC container
mitmproxyctl start Start container and mitmproxy
mitmproxyctl stop Stop container
mitmproxyctl restart Restart container
mitmproxyctl status Show container and process status
mitmproxyctl destroy Remove container (requires --force)
mitmproxyctl logs Show mitmproxy logs
Lifecycle:
mitmproxyctl install Create + bootstrap the LXC container
mitmproxyctl start Start the container
mitmproxyctl stop Stop the container
mitmproxyctl restart Restart the container (= mitmdump reload)
mitmproxyctl status Show container + mitmdump status
mitmproxyctl destroy --force Remove the container
mitmproxyctl logs Tail mitmproxy logs
Routes (forge the missing verb, #173):
mitmproxyctl route list List all (host, ip, port) entries
mitmproxyctl route add HOST IP PORT [--no-restart]
Add/replace a route (atomic, mirrors
to /srv/mitmproxy/ and the LXC rootfs
copy), then restart unless --no-restart
mitmproxyctl route remove HOST [--no-restart]
Remove a route, then restart
Config (TOML, default /etc/secubox/mitmproxy.toml):
[container]
name = "mitmproxy" # board reality (not "mitmproxy-waf")
lxc_root = "/data/lxc" # board reality (not "/var/lib/lxc")
memory_limit = "512M"
[proxy]
listen_port = 8080
web_port = 8091
host_routes_path = "/srv/mitmproxy/haproxy-routes.json"
inbound_routes_path = "/srv/mitmproxy-in/haproxy-routes.json"
"""
import sys
import os
import subprocess
import json
import time
from __future__ import annotations
import argparse
import json
import os
import platform
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Optional
try:
import toml
import tomllib # Python 3.11+
except ImportError:
toml = None
try:
import tomli as tomllib
except ImportError:
tomllib = None
CONTAINER_NAME = "mitmproxy-waf"
CONFIG_FILE = Path("/etc/secubox/mitmproxy.toml")
DATA_PATH = Path("/srv/mitmproxy-waf")
LXC_PATH = Path("/var/lib/lxc") / CONTAINER_NAME
# ── Defaults (match the live board convention) ─────────────────────────────
DEFAULT_CONFIG_FILE = Path("/etc/secubox/mitmproxy.toml")
DEFAULT_CONTAINER_NAME = "mitmproxy"
DEFAULT_LXC_ROOT = Path("/data/lxc")
DEFAULT_LISTEN_PORT = 8080
DEFAULT_WEB_PORT = 8091
DEFAULT_MEMORY_LIMIT = "512M"
DEFAULT_HOST_ROUTES = Path("/srv/mitmproxy/haproxy-routes.json")
DEFAULT_INBOUND_ROUTES = Path("/srv/mitmproxy-in/haproxy-routes.json")
def load_config() -> dict:
"""Load configuration from TOML file."""
if toml and CONFIG_FILE.exists():
return toml.load(CONFIG_FILE)
# ── Config ─────────────────────────────────────────────────────────────────
def load_config(path: Optional[Path] = None) -> dict:
"""Read [container] + [proxy] from TOML with sensible defaults."""
p = path or DEFAULT_CONFIG_FILE
raw = {}
if tomllib and p.exists():
try:
with open(p, "rb") as f:
raw = tomllib.load(f)
except Exception as e:
print(f" ! cannot parse {p}: {e}", file=sys.stderr)
raw = {}
container = raw.get("container") or {}
proxy = raw.get("proxy") or {}
return {
"container": {"name": CONTAINER_NAME, "memory_limit": "256M"},
"proxy": {"listen_port": 8890, "web_port": 8091, "data_path": str(DATA_PATH)},
"container": {
"name": container.get("name", DEFAULT_CONTAINER_NAME),
"lxc_root": Path(container.get("lxc_root", str(DEFAULT_LXC_ROOT))),
"memory_limit": container.get("memory_limit", DEFAULT_MEMORY_LIMIT),
},
"proxy": {
"listen_port": proxy.get("listen_port", DEFAULT_LISTEN_PORT),
"web_port": proxy.get("web_port", DEFAULT_WEB_PORT),
"host_routes_path": Path(proxy.get("host_routes_path", str(DEFAULT_HOST_ROUTES))),
"inbound_routes_path": Path(proxy.get("inbound_routes_path", str(DEFAULT_INBOUND_ROUTES))),
},
}
def run(cmd: list, check: bool = True, capture: bool = False) -> subprocess.CompletedProcess:
"""Run a command with error handling."""
# ── Shell helpers ──────────────────────────────────────────────────────────
def run(cmd: list, check: bool = True, capture: bool = False, quiet: bool = False) -> subprocess.CompletedProcess:
if not quiet:
print(f" → {' '.join(cmd)}")
return subprocess.run(cmd, check=check, capture_output=capture, text=True)
def lxc_exists() -> bool:
"""Check if LXC container exists."""
result = run(["lxc-ls"], capture=True, check=False)
return CONTAINER_NAME in result.stdout.split()
def lxc_exists(name: str) -> bool:
r = run(["lxc-ls"], capture=True, check=False, quiet=True)
return name in r.stdout.split()
def lxc_running() -> bool:
"""Check if LXC container is running."""
result = run(["lxc-info", "-n", CONTAINER_NAME, "-s"], capture=True, check=False)
return "RUNNING" in result.stdout
def lxc_running(name: str) -> bool:
r = run(["lxc-info", "-n", name, "-s"], capture=True, check=False, quiet=True)
return "RUNNING" in r.stdout
def lxc_exec(cmd: list, check: bool = True) -> subprocess.CompletedProcess:
"""Execute command inside LXC container."""
return run(["lxc-attach", "-n", CONTAINER_NAME, "--"] + cmd, check=check)
def lxc_exec(name: str, cmd: list, check: bool = True) -> subprocess.CompletedProcess:
return run(["lxc-attach", "-n", name, "--"] + cmd, check=check)
def cmd_install():
"""Create and configure LXC container."""
print(f"Installing LXC container: {CONTAINER_NAME}")
if lxc_exists():
print(f" Container {CONTAINER_NAME} already exists")
# ── Lifecycle ──────────────────────────────────────────────────────────────
def cmd_install(cfg: dict) -> int:
name = cfg["container"]["name"]
print(f"Installing LXC container: {name}")
if lxc_exists(name):
print(f" Container {name} already exists")
return 1
config = load_config()
# Create data directories
print("Creating data directories...")
DATA_PATH.mkdir(parents=True, exist_ok=True)
(DATA_PATH / "data").mkdir(exist_ok=True)
(DATA_PATH / "addons").mkdir(exist_ok=True)
(DATA_PATH / "config").mkdir(exist_ok=True)
# Create LXC container
print("Creating LXC container...")
# Detect architecture: aarch64 → arm64, x86_64 → amd64
arch = "arm64" if platform.machine() == "aarch64" else "amd64"
run([
"lxc-create", "-n", CONTAINER_NAME,
"-t", "download",
"--",
"-d", "debian",
"-r", "bookworm",
"-a", arch
])
# Configure container
print("Configuring container...")
lxc_config = LXC_PATH / "config"
with open(lxc_config, "a") as f:
f.write(f"\n# SecuBox WAF configuration\n")
f.write(f"lxc.mount.entry = {DATA_PATH}/data data none bind,create=dir 0 0\n")
f.write(f"lxc.mount.entry = {DATA_PATH}/addons addons none bind,create=dir 0 0\n")
memory_bytes = int(config["container"].get("memory_limit", "256M").rstrip("M")) * 1024 * 1024
f.write(f"lxc.cgroup2.memory.max = {memory_bytes}\n")
# Start container for setup
print("Starting container for initial setup...")
run(["lxc-start", "-n", CONTAINER_NAME])
time.sleep(5) # Wait for container to boot
# Install mitmproxy inside container
print("Installing mitmproxy in container...")
lxc_exec(["apt-get", "update"])
lxc_exec(["apt-get", "install", "-y", "python3", "python3-pip"])
lxc_exec(["pip3", "install", "--break-system-packages", "mitmproxy"])
# Stop container
run(["lxc-stop", "-n", CONTAINER_NAME])
print(f"Container {CONTAINER_NAME} installed successfully")
run(["lxc-create", "-n", name, "-t", "download", "--",
"-d", "debian", "-r", "bookworm", "-a", arch])
# Memory limit
cfg_path = cfg["container"]["lxc_root"] / name / "config"
if cfg_path.exists():
with open(cfg_path, "a") as f:
f.write("\n# SecuBox WAF — set via mitmproxyctl\n")
mem = cfg["container"].get("memory_limit", DEFAULT_MEMORY_LIMIT)
try:
bytes_ = int(mem.rstrip("MmGg")) * (1024 ** (2 if mem[-1].lower() == "m" else 3))
f.write(f"lxc.cgroup2.memory.max = {bytes_}\n")
except Exception:
pass
run(["lxc-start", "-n", name])
time.sleep(5)
lxc_exec(name, ["apt-get", "update"])
lxc_exec(name, ["apt-get", "install", "-y", "python3", "python3-pip"])
lxc_exec(name, ["pip3", "install", "--break-system-packages", "mitmproxy"])
run(["lxc-stop", "-n", name])
print(f"Container {name} installed")
return 0
def cmd_start():
"""Start container and mitmproxy."""
print(f"Starting {CONTAINER_NAME}...")
if not lxc_exists():
print(f" Container does not exist. Run: mitmproxyctl install")
def cmd_start(cfg: dict) -> int:
name = cfg["container"]["name"]
print(f"Starting {name}...")
if not lxc_exists(name):
print(f" Container {name} does not exist (run: mitmproxyctl install)")
return 1
if lxc_running():
print(f" Container already running")
if lxc_running(name):
print(f" Already running")
return 0
config = load_config()
proxy_port = config["proxy"].get("listen_port", 8890)
web_port = config["proxy"].get("web_port", 8091)
# Start container
run(["lxc-start", "-n", CONTAINER_NAME])
run(["lxc-start", "-n", name])
time.sleep(3)
# Start mitmproxy inside container (backgrounded)
print("Starting mitmproxy...")
lxc_exec([
# Prefer the in-LXC systemd unit if present; otherwise direct mitmdump
r = lxc_exec(name, ["systemctl", "list-unit-files", "mitmproxy.service"], check=False)
if r.returncode == 0 and "mitmproxy.service" in r.stdout:
lxc_exec(name, ["systemctl", "start", "mitmproxy"])
else:
port = cfg["proxy"]["listen_port"]
lxc_exec(name, [
"sh", "-c",
f"nohup mitmdump --mode upstream:http://127.0.0.1:80 "
f"--listen-port {proxy_port} "
f"--set web_open_browser=false "
f"-s /addons/secubox_waf.py "
f"> /var/log/mitmproxy.log 2>&1 &"
f"nohup mitmdump --listen-port {port} -s /srv/mitmproxy/secubox_waf.py "
f"> /var/log/mitmproxy.log 2>&1 &",
])
print(f"Container {CONTAINER_NAME} started")
print(f" Proxy port: {proxy_port}")
print(f" Web port: {web_port}")
print(f" Started")
return 0
def cmd_stop():
"""Stop container."""
print(f"Stopping {CONTAINER_NAME}...")
if not lxc_exists():
print(f" Container does not exist")
def cmd_stop(cfg: dict) -> int:
name = cfg["container"]["name"]
print(f"Stopping {name}...")
if not lxc_exists(name):
print(" Container does not exist")
return 1
if not lxc_running():
print(f" Container not running")
if not lxc_running(name):
print(" Already stopped")
return 0
run(["lxc-stop", "-n", CONTAINER_NAME])
print(f"Container {CONTAINER_NAME} stopped")
run(["lxc-stop", "-n", name])
print(" Stopped")
return 0
def cmd_restart():
"""Restart container."""
cmd_stop()
def cmd_restart(cfg: dict) -> int:
"""Restart prefers an in-LXC systemd reload over a full container bounce."""
name = cfg["container"]["name"]
if lxc_running(name):
r = lxc_exec(name, ["systemctl", "list-unit-files", "mitmproxy.service"], check=False)
if r.returncode == 0 and "mitmproxy.service" in r.stdout:
lxc_exec(name, ["systemctl", "restart", "mitmproxy"])
print(f" mitmproxy.service restarted inside {name}")
return 0
# Fallback: stop + start the container
cmd_stop(cfg)
time.sleep(2)
return cmd_start()
return cmd_start(cfg)
def cmd_status():
"""Show container and process status."""
print(f"Status: {CONTAINER_NAME}")
if not lxc_exists():
def cmd_status(cfg: dict) -> int:
name = cfg["container"]["name"]
print(f"Status: {name}")
if not lxc_exists(name):
print(" Container: NOT INSTALLED")
return 1
if lxc_running():
if lxc_running(name):
print(" Container: RUNNING")
# Check mitmproxy process
result = lxc_exec(["pgrep", "-f", "mitmdump"], check=False)
if result.returncode == 0:
print(" Mitmproxy: RUNNING")
else:
print(" Mitmproxy: STOPPED")
# Get container IP
result = run(["lxc-info", "-n", CONTAINER_NAME, "-iH"], capture=True, check=False)
if result.stdout.strip():
print(f" IP: {result.stdout.strip().split()[0]}")
r = lxc_exec(name, ["pgrep", "-f", "mitmdump"], check=False)
print(f" mitmdump: {'RUNNING' if r.returncode == 0 else 'STOPPED'}")
r = run(["lxc-info", "-n", name, "-iH"], capture=True, check=False, quiet=True)
if r.stdout.strip():
print(f" IP: {r.stdout.strip().split()[0]}")
else:
print(" Container: STOPPED")
# Check threat log
threats_log = DATA_PATH / "data" / "threats.log"
if threats_log.exists():
lines = threats_log.read_text().strip().split("\n")
print(f" Threats logged: {len([l for l in lines if l])}")
routes = _read_routes(cfg["proxy"]["host_routes_path"])
print(f" Routes: {len(routes)} hosts")
return 0
def cmd_destroy(force: bool = False):
"""Remove container."""
def cmd_destroy(cfg: dict, force: bool = False) -> int:
name = cfg["container"]["name"]
if not force:
print("Use --force to destroy container")
return 1
print(f"Destroying {CONTAINER_NAME}...")
if not lxc_exists():
print(f" Container does not exist")
print(f"Destroying {name}...")
if not lxc_exists(name):
print(" Container does not exist")
return 0
if lxc_running():
run(["lxc-stop", "-n", CONTAINER_NAME])
run(["lxc-destroy", "-n", CONTAINER_NAME])
print(f"Container {CONTAINER_NAME} destroyed")
if lxc_running(name):
run(["lxc-stop", "-n", name])
run(["lxc-destroy", "-n", name])
print(f" Destroyed")
return 0
def cmd_logs():
"""Show mitmproxy logs."""
if not lxc_running():
print("Container not running")
def cmd_logs(cfg: dict) -> int:
name = cfg["container"]["name"]
if not lxc_running(name):
print(f"Container {name} not running")
return 1
lxc_exec(["tail", "-100", "/var/log/mitmproxy.log"], check=False)
lxc_exec(name, ["journalctl", "-u", "mitmproxy", "-n", "100", "--no-pager"], check=False)
return 0
def main():
parser = argparse.ArgumentParser(description="SecuBox WAF container management")
parser.add_argument("command", choices=["install", "start", "stop", "restart", "status", "destroy", "logs"])
parser.add_argument("--force", action="store_true", help="Force operation")
args = parser.parse_args()
# ── Routes (the new verb, #173) ────────────────────────────────────────────
def _route_files(cfg: dict) -> list:
"""Return the route file paths that exist (host copy + LXC-rootfs mirror).
commands = {
"install": cmd_install,
"start": cmd_start,
"stop": cmd_stop,
"restart": cmd_restart,
"status": cmd_status,
"destroy": lambda: cmd_destroy(args.force),
"logs": cmd_logs,
}
The LXC-rootfs mirror is computed from container.lxc_root + container.name
+ the in-LXC path of host_routes_path. Both must be kept in sync so that
the addon inside the LXC sees the same routes as the host-facing tooling.
"""
out = []
host_path = cfg["proxy"]["host_routes_path"]
inbound_path = cfg["proxy"]["inbound_routes_path"]
lxc_root = cfg["container"]["lxc_root"]
name = cfg["container"]["name"]
# LXC-rootfs mirror of host_routes_path
lxc_mirror = lxc_root / name / "rootfs" / host_path.relative_to("/")
for p in (host_path, lxc_mirror, inbound_path):
out.append(p)
return out
return commands[args.command]()
def _read_routes(path: Path) -> dict:
if not path.exists():
return {}
try:
with open(path) as f:
return json.load(f)
except Exception:
return {}
def _write_routes(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w") as f:
json.dump(data, f, indent=2, sort_keys=True)
f.write("\n")
tmp.replace(path)
def cmd_route_list(cfg: dict) -> int:
"""Print the host-facing route map. The LXC mirror is assumed in sync."""
routes = _read_routes(cfg["proxy"]["host_routes_path"])
if not routes:
print("(no routes)")
return 0
width = max((len(h) for h in routes), default=0)
for host in sorted(routes):
v = routes[host]
ip, port = (v[0], v[1]) if isinstance(v, list) and len(v) >= 2 else (str(v), "")
print(f" {host.ljust(width)} -> {ip}:{port}")
return 0
def cmd_route_add(cfg: dict, host: str, ip: str, port: int, restart: bool = True) -> int:
"""Add or replace a route in every existing route file, then optionally restart."""
if not host or not ip or not isinstance(port, int):
print("route add: host + ip + port required", file=sys.stderr)
return 2
updated = []
for p in _route_files(cfg):
if p.parent.exists() or p.exists():
d = _read_routes(p)
d[host] = [ip, int(port)]
_write_routes(p, d)
updated.append(str(p))
if not updated:
print("route add: no route file present (none of the configured paths exist)", file=sys.stderr)
return 3
for p in updated:
print(f" + {host} -> {ip}:{port} in {p}")
if restart:
return cmd_restart(cfg)
return 0
def cmd_route_remove(cfg: dict, host: str, restart: bool = True) -> int:
updated = []
missing = []
for p in _route_files(cfg):
if not p.exists():
continue
d = _read_routes(p)
if host in d:
del d[host]
_write_routes(p, d)
updated.append(str(p))
else:
missing.append(str(p))
if not updated:
print(f"route remove: {host!r} not found in any route file", file=sys.stderr)
return 3
for p in updated:
print(f" - {host} from {p}")
for p in missing:
print(f" · {host} already absent in {p}")
if restart:
return cmd_restart(cfg)
return 0
# ── CLI ────────────────────────────────────────────────────────────────────
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="mitmproxyctl", description="SecuBox WAF interception control (#173)")
p.add_argument("--config", type=Path, default=None,
help=f"path to TOML config (default: {DEFAULT_CONFIG_FILE})")
sub = p.add_subparsers(dest="cmd", required=True)
for name in ("install", "start", "stop", "restart", "status", "logs"):
sub.add_parser(name)
destroy = sub.add_parser("destroy")
destroy.add_argument("--force", action="store_true")
route = sub.add_parser("route", help="manage intercepted routes")
rsub = route.add_subparsers(dest="route_cmd", required=True)
rsub.add_parser("list")
radd = rsub.add_parser("add")
radd.add_argument("host")
radd.add_argument("ip")
radd.add_argument("port", type=int)
radd.add_argument("--no-restart", action="store_true")
rrm = rsub.add_parser("remove")
rrm.add_argument("host")
rrm.add_argument("--no-restart", action="store_true")
return p
def main() -> int:
args = build_parser().parse_args()
cfg = load_config(args.config)
if args.cmd == "install":
return cmd_install(cfg)
if args.cmd == "start":
return cmd_start(cfg)
if args.cmd == "stop":
return cmd_stop(cfg)
if args.cmd == "restart":
return cmd_restart(cfg)
if args.cmd == "status":
return cmd_status(cfg)
if args.cmd == "destroy":
return cmd_destroy(cfg, force=getattr(args, "force", False))
if args.cmd == "logs":
return cmd_logs(cfg)
if args.cmd == "route":
if args.route_cmd == "list":
return cmd_route_list(cfg)
if args.route_cmd == "add":
return cmd_route_add(cfg, args.host, args.ip, args.port, restart=not args.no_restart)
if args.route_cmd == "remove":
return cmd_route_remove(cfg, args.host, restart=not args.no_restart)
return 1
if __name__ == "__main__":

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -25,8 +25,11 @@ WEBUI_PORT=9080
METABLOG_PORT=8900
BACKEND_IP="10.100.0.1"
# Dead container IPs to fix (not running LXC containers)
DEAD_CONTAINER_IPS="10.100.0.10 10.100.0.20 10.100.0.30 10.100.0.40"
# Dead container IPs to fix (not running LXC containers).
# 10.100.0.10 was removed after the mail Phase 1 LXC went live there
# (mail.gk2 / webmail.gk2 / rspamd.gk2 / autoconfig.gk2 must keep routing
# to the live container, not be rewritten to 10.100.0.1:9080).
DEAD_CONTAINER_IPS="10.100.0.20 10.100.0.30 10.100.0.40"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2
@ -39,8 +42,14 @@ get_current_routes() {
# Get all domains from HAProxy that use mitmproxy_inspector
get_haproxy_domains() {
# `use_backend mitmproxy_inspector if host_<dotted_domain>` lines end at
# the hostname (no trailing space, regex anchors on $). The previous
# `(?= )` lookahead required a space and silently matched zero domains —
# which is why sync never auto-populated routes for newly-added vhosts
# (caught 2026-05-17 when ckwa.gk2.secubox.in returned 502 because its
# route had never been written by sync).
grep "use_backend mitmproxy_inspector" "$HAPROXY_CFG" | \
grep -oP 'host_\K[a-z0-9_]+(?= )' | \
grep -oP 'host_\K[a-z0-9_]+(?=\s|$)' | \
sed 's/_/./g' | sort -u
}