mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 19:43:10 +00:00
Compare commits
12 Commits
ec1a4641e0
...
25e1e89ed1
| Author | SHA1 | Date | |
|---|---|---|---|
| 25e1e89ed1 | |||
|
|
b0b42e81de | ||
|
|
355767935c | ||
|
|
3219050775 | ||
| c39743726a | |||
| 5c72d869a4 | |||
| a313816edc | |||
| 9c6e11d54d | |||
| 3770586a31 | |||
| bd4ef55a98 | |||
| b59d053c54 | |||
| 2494b55a18 |
135
.claude/WIP.md
135
.claude/WIP.md
|
|
@ -1,5 +1,117 @@
|
|||
# WIP — Work In Progress
|
||||
*Mis à jour : 2026-05-14*
|
||||
*Mis à jour : 2026-05-15*
|
||||
|
||||
---
|
||||
|
||||
## 🔄 2026-05-15: Mail stack Phase 1 — LXC consolidation + source catch-up (Issue [#136](https://github.com/CyberMind-FR/secubox-deb/issues/136), PR [#141](https://github.com/CyberMind-FR/secubox-deb/pull/141) OPEN)
|
||||
|
||||
### Objective
|
||||
|
||||
Catch the in-repo source up to the test board's `/data/lxc/mail` + `/data/volumes/mail` + `10.100.0.10/24` reality (repo still references `/srv/lxc`, `/srv/mail`, `192.168.255.30`, separate `mail_container`/`webmail_container`). Phase 1 collapses the dual-container layout into a single mail LXC, deprecates legacy `secubox-mail-lxc` / `secubox-webmail` / `secubox-webmail-lxc` companion packages with `Breaks/Replaces`, and ships HAProxy mail-TCP snippets pointed at the new IP.
|
||||
|
||||
### Status (worktree `136-mail-stack-phase-1-source-catch-up-legac`, branch `feature/136-mail-stack-phase-1-source-catch-up-legac`)
|
||||
|
||||
- Phase 0 spec rev. 2 (`docs/superpowers/specs/2026-05-15-mail-stack-architecture-design.md`) + Phase 1 plan + rollback recipe committed to master
|
||||
- mailctl/mailser feature commits landed (latest `bade94f1`)
|
||||
- Versions bumped to 2.2.0 with `Breaks/Replaces` markers on the three legacy packages
|
||||
- HAProxy mail-TCP snippet targets new `10.100.0.10`
|
||||
- Test coverage shipped: 62-route endpoint-presence pytest + end-to-end acceptance smoke
|
||||
- **PR [#141](https://github.com/CyberMind-FR/secubox-deb/pull/141) opened** today — awaiting review + live deploy/cutover on test board
|
||||
|
||||
---
|
||||
|
||||
## 🔄 2026-05-15: remote-ui converged dashboard (Issue [#135](https://github.com/CyberMind-FR/secubox-deb/issues/135), PR [#140](https://github.com/CyberMind-FR/secubox-deb/pull/140) OPEN)
|
||||
|
||||
### Status
|
||||
|
||||
- PR [#137](https://github.com/CyberMind-FR/secubox-deb/pull/137) (initial converge round/ + square/ dashboards into `secubox_common` + pointer input on Pi 4B/400) **squash-merged `839bab94`**
|
||||
- **PR [#140](https://github.com/CyberMind-FR/secubox-deb/pull/140) OPEN** on `feature/135-converge-round-square-dashboards-into-re` — head `89968477`, 35 commits ahead of `origin/master`, net +35 / −2086 lines
|
||||
- Spec + plan landed on master (`b5e44e72`, `78316556`)
|
||||
|
||||
### Fixups added in this session (2026-05-15)
|
||||
|
||||
- `387fabb4` `fixup(common): pod_size=48 to match deployed icon sizes` — modules pods were rendering as letter placeholders on Pi 4B because `paint_pod_cluster(pod_size=40)` missed the deployed icon sizes (22/48/96/128); bumped to 48, radius 70→78 for clearance from the central button.
|
||||
- `f4acd5a9` `fixup(square): ship remote-ui/common/assets/icons` — the square build copied `common/python/` but skipped `common/assets/`; without the icons under `/var/www/common/assets/icons/`, `secubox_common.icons.load_module_icon` would fall back to first-letter placeholders.
|
||||
- `89968477` `fixup(square): install secubox-otg-gadget.sh to /usr/local/sbin` — the square `secubox-otg-gadget.service` ExecStarts that path; without the composer script the gadget never composed (zero USB enumeration events on the MOCHAbin, xHCI setup timeouts).
|
||||
|
||||
### Hardware bench (2026-05-15)
|
||||
|
||||
- **Pi 4B + 7" DSI (square):** converged dashboard renders ✓, icons ✓, all 4 right-panel tabs work (alerts/console/module_detail/mode_controls), USB-C OTG composes to MOCHAbin after the gadget-composer fixup landed.
|
||||
- **Pi Zero W + HyperPixel 2.1 (round):** boots clean, `fallback_manager.py` OFFLINE radar renders correctly on the existing image.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 2026-05-15: Port `radar_concentric` into `secubox_common` (Issue [#138](https://github.com/CyberMind-FR/secubox-deb/issues/138), PR [#142](https://github.com/CyberMind-FR/secubox-deb/pull/142) OPEN)
|
||||
|
||||
### Status
|
||||
|
||||
- Issue opened today; PR [#142](https://github.com/CyberMind-FR/secubox-deb/pull/142) opened the same day on `feature/138-port-radar-concentric-into-secubox-commo` with title "Port radar_concentric into secubox_common + phase-aware dashboards (closes #138)"
|
||||
- New `secubox_common.painters.radar_concentric` module (phase-aware); module→arc-angle decoupled from list order via `DEFAULT_NAME_TO_ANGLE`
|
||||
- `RoundDashboard.layout(metrics, phase=0.0)` + `SquareDashboard.layout(metrics, phase=0.0)` — backward-compatible (phase=0 = still frame)
|
||||
- Square kiosk `__main__` drives `phase = (time.monotonic() * 12.0 / 60.0) % 1.0` at 12 RPM (matches deployed `fallback_manager._sweep_speed`)
|
||||
- 118 / 118 tests green (36 secubox_common incl. 8 new + 78 square kiosk + 4 round)
|
||||
- **Hardware bench (Pi 4B + 7" DSI, 2026-05-15):** rotating radar ✓ · icons ✓ · right panel ✓ — user-confirmed
|
||||
- `fallback_manager.py` migration deferred (visual-palette decision needed; follow-up)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 2026-05-15: Round image cleanup — dead ifupdown + secubox sudo + OTG comment (Issue [#139](https://github.com/CyberMind-FR/secubox-deb/issues/139), PR [#143](https://github.com/CyberMind-FR/secubox-deb/pull/143) OPEN)
|
||||
|
||||
### Original misdiagnosis → rescoped
|
||||
|
||||
Initial report claimed "OTG networking dead": `/etc/network/interfaces.d/usb0` was a static stanza for ifupdown, but `ifupdown` was missing → `usb0` stayed DOWN. Wrong conclusion. Live-system probe via ACM serial showed the actual binding is on `usb1` (10.55.0.2/30), set programmatically by `secubox-otg-gadget.sh`. The dead ifupdown stanza never did anything; OTG was always working.
|
||||
|
||||
### Rescoped fix
|
||||
|
||||
- Drop dead `/etc/network/interfaces.d/usb0` (file + the inline heredoc in `build-eye-remote-image.sh` that recreated it)
|
||||
- Add `secubox` user to `sudo` group (so ACM serial recovery is possible — previously the only-path-in had no path-to-fix)
|
||||
- Rewrite the misleading `usb1 = ECM` comment in `secubox-otg-gadget.sh` — RNDIS+ECM share host_addr so host reaches 10.55.0.2 via either function
|
||||
|
||||
### Status
|
||||
|
||||
- Issue opened 2026-05-15 10:27; PR [#143](https://github.com/CyberMind-FR/secubox-deb/pull/143) opened 2 minutes later on `fix/139-round-image-usb0-otg-networking-dead-ifu`
|
||||
- Initial misdiagnosis annotated as a comment on issue #139 — diagnostic confusion came from the dead stanza being visible
|
||||
- **Hardware bench (Pi Zero W 1st gen, HyperPixel 2.1 Round, 2026-05-15):** all 3 fixes verified live:
|
||||
- `/etc/network/interfaces.d/` directory absent ✓
|
||||
- `secubox` in `sudo` group (`27(sudo)`) ✓
|
||||
- Gadget composer comment rewritten ✓
|
||||
- Bonus: ping `10.55.0.2` from host 3/3 received at 0.3 ms, SSH port 22 OPEN, both `usb0` + `usb1` UP @ 10.55.0.2/30 on the Pi
|
||||
- Mid-bench: caught a pre-existing Pi Zero W `dwc2` kernel panic under host xHCI reset hammering — unrelated to #143 (image was good, dwc2 driver instability on ARMv6 under USB stress); deferred to a separate investigation
|
||||
|
||||
---
|
||||
|
||||
## 🔄 2026-05-15: CMSD SPDX header rollout (Issue [#81](https://github.com/CyberMind-FR/secubox-deb/issues/81))
|
||||
|
||||
### Status
|
||||
|
||||
Worktree `secubox-deb-license-wt` on branch `feature/license-phase-b-full` at `aa1f7481` ("enroll all in-scope files via `**` allowlist (Phase B + C)"). Phase A + B + C work all on the branch but no PR opened yet.
|
||||
|
||||
---
|
||||
|
||||
## 🧹 2026-05-15: worktree + local-state housekeeping
|
||||
|
||||
### Cleaned 2026-05-15
|
||||
|
||||
- `secubox-deb-worktrees/127-add-remote-ui-square-variant-for-pi-4b-7` (Phase 1, PR #130 merged as `7c37415f`) — removed
|
||||
- `secubox-deb-worktrees/127-phase2-square-variant` (Phase 2, PR #131 closed/superseded) — removed
|
||||
- `secubox-deb-worktrees/127-phase3-python-kiosk` (Phase 3, PR #132 merged as `dee8bf8b`) — force-removed (had two stray untracked Signal Desktop apt-key files unrelated to the project, safe to discard)
|
||||
|
||||
All three feature branches deleted locally. `agent-worktree.sh clean` resolves by issue number which collides for multi-worktree issues like #127; used direct `git worktree remove` + `git branch -D`.
|
||||
|
||||
### Local master state
|
||||
|
||||
- `master` synced with `origin/master` at `a313816e` (pushed `839bab94..a313816e`, 6 commits). Was 6 ahead / 1 behind earlier; rebased then pushed.
|
||||
- Untracked: `.claude/settings.json` (pre-existing, intentional)
|
||||
|
||||
### Worktrees still active
|
||||
|
||||
| Worktree | Branch | Backing PR |
|
||||
|---|---|---|
|
||||
| `135-converge-round-square-dashboards-into-re` | `feature/135-…` | #140 OPEN |
|
||||
| `136-mail-stack-phase-1-source-catch-up-legac` | `feature/136-…` | #141 OPEN |
|
||||
| `138-port-radar-concentric-into-secubox-commo` | `feature/138-…` | #142 OPEN |
|
||||
| `139-round-image-usb0-otg-networking-dead-ifu` | `fix/139-…` | #143 OPEN |
|
||||
| `secubox-deb-license-wt` | `feature/license-phase-b-full` | none (SPDX rollout #81) |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -20,11 +132,24 @@ Replace Phase 2's Chromium+PySide6 dual-window stack with a single-process Pytho
|
|||
- Phase 2 PR #131 closed (superseded by Phase 3)
|
||||
- **Squash-merged 2026-05-14 as `dee8bf8b`** on master (after Phase 1 `7c37415f`)
|
||||
|
||||
### Followups
|
||||
### Hardware gates — 3 of 4 closed (2026-05-15)
|
||||
|
||||
- **Task 24 (Pi 400 manual sanity) — IN PROGRESS** this session: building `secubox-eye-square_0.2.0_arm64.img.xz` locally for flash to uSD.
|
||||
- **Task 23 (Pi 4B manual bench)** — still pending hardware (build + flash + boot + kiosk visible + OTG link to MOCHAbin).
|
||||
- Issue #127 stays open until both Pi 4B and Pi 400 benches pass.
|
||||
| Task | Hardware | Status |
|
||||
|------|----------|--------|
|
||||
| **Task 23** — Pi 4B square/ manual bench | Pi 4B + official 7" DSI 800×480 | ✅ kiosk renders correctly post-#134 fixes |
|
||||
| **Task 24** — Pi 400 square/ sanity | Pi 400 + HDMI 1920×1080 | ✅ same image, kiosk center-padded into letterbox (PR #134 second commit) |
|
||||
| **Task 19** — Pi Zero W round/ manual bench | Pi Zero W + HyperPixel 2.1 | ✅ booted from CI-built `secubox-eye-remote-2.2.1.img.xz`, rainbow ring dashboard clean post-`common/` |
|
||||
| Task 18 — round/ `diffoscope` regression gate | n/a (automated) | ⏳ still blocked on `hyperpixel2r.dtbo` prerequisite |
|
||||
|
||||
### Bug haul from the bench (fixed in PR [#134](https://github.com/CyberMind-FR/secubox-deb/pull/134), merged `a3a918ed`)
|
||||
|
||||
1. `/run/secubox` not recreated at boot (tmpfs wipe) → added `tmpfiles.d/secubox-eye-square.conf`
|
||||
2. `fonts-dejavu-core` missing from chroot apt-install → added
|
||||
3. `draw.text()` calls relied on Pillow legacy bitmap default (no Unicode) → `theme.DEFAULT_FONT` + `font=` kwarg on all 25 call sites
|
||||
4. `framebuffer.py` hardcoded 32bpp BGRA but `vc4drmfb` is 16bpp RGB565 → numpy RGB565 packer + `bits_per_pixel` auto-detect
|
||||
5. (followup) `framebuffer.py` hardcoded 800×480 → `virtual_size` auto-detect + center-pad for HDMI
|
||||
|
||||
Issue #127 closure now gated only on Task 18. Bug #139 (round image OTG networking) and enhancement #138 (radar to common) were surfaced from the same bench session — see today's PRs above.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
1618
docs/superpowers/plans/2026-05-15-mail-phase1-lxc-consolidation.md
Normal file
1618
docs/superpowers/plans/2026-05-15-mail-phase1-lxc-consolidation.md
Normal file
File diff suppressed because it is too large
Load Diff
46
docs/superpowers/runs/2026-05-15-mail-phase1-rollback.md
Normal file
46
docs/superpowers/runs/2026-05-15-mail-phase1-rollback.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Mail Phase 1 — Rollback recipe
|
||||
|
||||
Backups produced 2026-05-15 07:41 on test board 192.168.1.200 by
|
||||
`docs/superpowers/plans/2026-05-15-mail-phase1-lxc-consolidation.md` Task 0.
|
||||
|
||||
## What's in `/srv/backups/mail-phase1/`
|
||||
|
||||
| File | Size | Contents |
|
||||
|---|---|---|
|
||||
| `data-volumes-mail-2026-05-15-0741.tar.gz` | 48K | Entire `/data/volumes/mail/` tree — vmail dirs for `secubox.in/{gk2,bat,bourdon,lemurien,ragondin}`, Postfix lookup tables, ACME certs |
|
||||
| `lxc-mail-config-2026-05-15-0741.tar.gz` | 4.0K | `/data/lxc/mail/config` (the LXC's unprivileged-veth network config) |
|
||||
| `mail-toml-2026-05-15-0741.bak` | 0.4K | Original `/etc/secubox/mail.toml` (still has legacy keys) |
|
||||
| `pkglist-2026-05-15-0741.txt` | 0.7K | `dpkg -l` output for `secubox-mail*` + `secubox-webmail*` pre-deploy |
|
||||
|
||||
## Rollback procedure
|
||||
|
||||
If Phase 1 deploy breaks the mail stack on the board:
|
||||
|
||||
```bash
|
||||
ssh root@192.168.1.200 'set -euo pipefail
|
||||
lxc-stop -n mail 2>/dev/null || true
|
||||
|
||||
# Restore /data/volumes/mail (vmail + config + ssl)
|
||||
rm -rf /data/volumes/mail
|
||||
tar -xzf /srv/backups/mail-phase1/data-volumes-mail-2026-05-15-0741.tar.gz -C /
|
||||
|
||||
# Restore LXC config
|
||||
tar -xzf /srv/backups/mail-phase1/lxc-mail-config-2026-05-15-0741.tar.gz -C /
|
||||
|
||||
# Restore toml + downgrade packages
|
||||
cp /srv/backups/mail-phase1/mail-toml-2026-05-15-0741.bak /etc/secubox/mail.toml
|
||||
apt install --allow-downgrades -y \
|
||||
secubox-mail=2.1.0-1~bookworm1 \
|
||||
secubox-mail-lxc=1.1.0-1~bookworm1 \
|
||||
secubox-webmail=1.0.0-1~bookworm1 \
|
||||
secubox-webmail-lxc=1.1.0-1~bookworm1
|
||||
|
||||
systemctl restart secubox-mail nginx haproxy'
|
||||
```
|
||||
|
||||
## Priority guarantees
|
||||
|
||||
- The data tarball preserves the 5 live `secubox.in` mailboxes (`gk2`, `bat`,
|
||||
`bourdon`, `lemurien`, `ragondin`) and the ACME certs from Feb 2026.
|
||||
- Per spec rev. 2 invariant **I13**, this data MUST NOT be lost. If anything
|
||||
goes wrong, restoring this tarball is the first and most important step.
|
||||
|
|
@ -0,0 +1,316 @@
|
|||
# Mail Stack Architecture — Phase 0 Design (rev. 2)
|
||||
|
||||
**Date:** 2026-05-15 (rev. 2 — reconciled with board reality)
|
||||
**Status:** Approved direction; revised invariants pending user re-confirmation
|
||||
**Author:** Gérald Kerma <devel@cybermind.fr>
|
||||
**Scope:** Architecture-only. Each implementation phase below gets its own spec → plan → PR cycle.
|
||||
|
||||
> **Revision note (rev. 2, 2026-05-15):** Initial draft assumed a `/srv/lxc/` + `192.168.255.x` + `lxc.net.0.type = none` greenfield layout. Live board inspection on 2026-05-15 showed the actual single-`mail` LXC has already been hand-built on the test board with a modern unprivileged-veth layout under `/data/lxc/` + `/data/volumes/`. Invariants have been corrected; Phase 1 is reduced from "consolidate two LXCs" to "catch the repo source up to where the board already is, then deprecate the legacy package frame".
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Deliver a "full-featured" multi-domain mail + collaboration stack inside a single LXC container, integrated with the existing SecuBox-Deb identity (`secubox-users`), DNS (`secubox-dns`) and storage (`secubox-nextcloud`) services.
|
||||
|
||||
**Definition of full-featured for this project:**
|
||||
- Standards-compliant SMTP/IMAP with TLS, SPF, DKIM, DMARC, ARC
|
||||
- Per-user features: ManageSieve filters, quotas, vacation, app passwords
|
||||
- Multi-domain virtual hosting with per-domain DKIM keys
|
||||
- Roundcube webmail with PGP (Enigma), 2FA, ManageSieve UI, CardDAV/CalDAV bridged to Nextcloud
|
||||
- Mailing lists (mlmmj) and shared mailboxes
|
||||
- imapsync-based migration from OpenWrt SecuBox or any external IMAP
|
||||
- End-user self-service portal (password, vacation, aliases, app-passwords, sieve)
|
||||
- Observability + outbound abuse policies
|
||||
|
||||
## 2. Non-goals
|
||||
|
||||
- ActiveSync / EAS protocol (Z-Push, grommunio) — not in scope this round
|
||||
- JMAP (Cyrus pivot) — not in scope
|
||||
- Mail archival / legal hold — not in scope
|
||||
- Independent CardDAV/CalDAV server inside the mail LXC — delegated to `secubox-nextcloud`
|
||||
|
||||
## 3. Locked invariants
|
||||
|
||||
Each phase below MUST respect these. Changing one requires a new Phase 0 revision.
|
||||
|
||||
| # | Invariant |
|
||||
|---|---|
|
||||
| **I1** | Exactly ONE LXC named `mail` at `/data/lxc/mail` (symlinked from `/var/lib/lxc/mail`). No separate `mailserver` or `roundcube` LXCs. |
|
||||
| **I2** | LXC is **unprivileged** (`lxc.idmap = u 0 100000 65536`), veth on bridge `br-lxc`, IPv4 `10.100.0.10/24` with gateway `10.100.0.1`. AppArmor + `debian.common.conf` includes. |
|
||||
| **I3** | Antispam stack is **Rspamd** (single daemon: greylisting + spam scoring + DKIM sign+verify + SPF + DMARC + ARC). ClamAV remains as separate AV milter. SpamAssassin, Postgrey, OpenDKIM, opendmarc removed in Phase 2. |
|
||||
| **I4** | CardDAV + CalDAV are **not** served from the mail LXC. Roundcube plugins point at `https://nextcloud.gk2.secubox.in/remote.php/dav/`. |
|
||||
| **I5** | Mail accounts are provisioned **by** `secubox-users`. The mail stack is a downstream consumer. Local Dovecot is the materialized projection. |
|
||||
| **I6** | Outbound delivery is **direct on port 25**. No smarthost relay. |
|
||||
| **I7** | Multi-domain virtual users. Mailbox path: `/data/volumes/mail/vmail/<domain>/<user>/Maildir/`. Per-domain DKIM key (or Rspamd selector after Phase 2). |
|
||||
| **I8** | Existing data is migrated from OpenWrt SecuBox via **imapsync** (Phase 7). |
|
||||
| **I9** | Webmail = Roundcube. SOGo / Cyrus / grommunio rejected. |
|
||||
| **I10** | All container daemons listen on the LXC IP (`10.100.0.10`). Exposed to LAN/WAN via host's HAProxy (SMTP/IMAPS TCP pass-through) + nginx (admin + webmail HTTPS). |
|
||||
| **I11** | Configuration source of truth: `/etc/secubox/mail.toml` on the host. Rendered into the LXC by `mailctl`. No editing config inside the LXC. |
|
||||
| **I12** | **Persistent data lives on the host under `/data/volumes/mail/{vmail,config,ssl}`** and is bind-mounted into the LXC. Destroying the LXC rootfs MUST be safe — no production data lives in the rootfs. |
|
||||
| **I13** | **Existing mail data on the test board MUST be preserved.** As of 2026-05-15 the board hosts the `secubox.in` domain with five live mailboxes (`gk2`, `bat`, `bourdon`, `lemurien`, `ragondin`) under `/data/volumes/mail/vmail/secubox.in/`. Any upgrade path that touches the data directory MUST refuse to proceed if it cannot guarantee preservation. |
|
||||
|
||||
## 4. Current state (test board 192.168.1.200, surveyed 2026-05-15)
|
||||
|
||||
| Element | Reality |
|
||||
|---|---|
|
||||
| LXCs on board | `gitea`, `mail`, `matrix`, `mitmproxy`, `nextcloud`, `streamlit` |
|
||||
| `mail` LXC location | `/data/lxc/mail/` (symlinked from `/var/lib/lxc/mail`) |
|
||||
| `mail` LXC state | STOPPED (last touched 2026-05-08) |
|
||||
| `mail` LXC networking | unprivileged, veth `br-lxc`, `10.100.0.10/24`, gw `10.100.0.1` |
|
||||
| `mail` LXC bind mounts | `/data/volumes/mail/vmail` → `var/vmail`, `/data/volumes/mail/config` → `etc/mail-config`, `/data/volumes/mail/ssl` → `etc/ssl/mail` |
|
||||
| Inside-LXC software | Postfix, Dovecot (core+imapd+lmtpd+pop3d), Apache2+mod_php, nginx, OpenDKIM, SpamAssassin, Roundcube (core+plugins+classic+larry skins, mysql backend), php-net-sieve |
|
||||
| **NOT yet inside LXC** | Postgrey, ClamAV (planned by spec rev. 1 — never installed; rev. 2 drops Postgrey entirely and defers ClamAV to Phase 2) |
|
||||
| Persistent data | `/data/volumes/mail/vmail/{secubox.in/{gk2,bat,bourdon,lemurien,ragondin},gk2}`, `/data/volumes/mail/config/{main.cf,master.cf,vmailbox,virtual,vdomains,users,aliases,...}`, `/data/volumes/mail/ssl/{fullchain.pem,privkey.pem}` (Feb 2026 ACME issue) |
|
||||
| Host packages | `secubox-mail 2.1.0-1`, `secubox-mail-lxc 1.1.0-1`, `secubox-webmail 1.0.0-1`, `secubox-webmail-lxc 1.1.0-1` |
|
||||
| Host service | `secubox-mail.service` is `active` (FastAPI listens, but mail LXC isn't running) |
|
||||
| Postfix `main.cf` (in `/data/volumes/mail/config/`) | hostname `mail.secubox.in`, virtual mailbox domains via `/etc/postfix/vdomains`, SASL via Dovecot, TLS via `/etc/ssl/mail/`, Maildir layout |
|
||||
| Roundcube webserver | Apache2 + libapache2-mod-php8.2 (BOTH nginx and apache2 packages installed inside LXC; only one needed) |
|
||||
| Repo source layout (this tree) | Out of date: `mailctl` still references `/srv/lxc`, `/srv/mail`, `mail_container = "mailserver"`, `webmail_container = "roundcube"`, `192.168.255.30`. The single `mail` LXC was hand-built outside the repo. |
|
||||
| Host `mail.toml` | Out of date: still has `mail_container`, `webmail_container`, `mail_ip = "192.168.255.30"`, `webmail_ip = "192.168.255.31"` |
|
||||
|
||||
## 5. Target architecture
|
||||
|
||||
### 5.1 LXC layout (canonical)
|
||||
|
||||
```
|
||||
/var/lib/lxc/mail -> /data/lxc/mail (symlink, host-side)
|
||||
/data/lxc/mail/
|
||||
config # LXC config (unprivileged, veth, br-lxc, 10.100.0.10/24)
|
||||
rootfs/ # Debian bookworm arm64
|
||||
etc/postfix/ # rendered by mailctl (read-only at runtime)
|
||||
etc/dovecot/
|
||||
etc/rspamd/ # Phase 2+
|
||||
etc/clamav/ # Phase 2+
|
||||
etc/apache2/ # Phase 1 keeps Apache; Phase 5 may revisit
|
||||
etc/roundcube/
|
||||
etc/mlmmj/ # Phase 6+
|
||||
opt/start-mail.sh # init script run by lxc.init.cmd
|
||||
|
||||
/data/volumes/mail/ # Persistent data (bind-mounted into LXC)
|
||||
vmail/ # Maildirs
|
||||
secubox.in/<user>/Maildir/
|
||||
<future-domain>/<user>/Maildir/
|
||||
config/ # Postfix/Dovecot lookup tables, owned by host
|
||||
main.cf, master.cf
|
||||
users, vmailbox, virtual, valias, vdomains, aliases
|
||||
*.lmdb (rebuilt by postmap)
|
||||
ssl/ # ACME-issued certs (host renews, container reads)
|
||||
fullchain.pem, privkey.pem
|
||||
dkim/ # per-domain keys (Phase 2 owned by Rspamd)
|
||||
rspamd/ # Phase 2 — bayes corpus, history
|
||||
clamav/ # Phase 2 — virus signature DB
|
||||
sieve/ # Phase 4 — per-user sieve scripts
|
||||
mlmmj/ # Phase 6 — mailing list spools
|
||||
roundcube/ # Phase 5 — user data, logs, plugin state
|
||||
```
|
||||
|
||||
### 5.2 Network and ports
|
||||
|
||||
LXC IP: `10.100.0.10` (br-lxc). Gateway: `10.100.0.1` (host bridge).
|
||||
|
||||
| Listener | Port | Protocol | Exposed how |
|
||||
|---|---|---|---|
|
||||
| Postfix smtpd | 25 | SMTP | HAProxy TCP pass-through, WAN |
|
||||
| Postfix submission | 587 | SMTP+STARTTLS+SASL | HAProxy TCP pass-through, WAN |
|
||||
| Postfix submissions | 465 | SMTPS+SASL | HAProxy TCP pass-through, WAN |
|
||||
| Dovecot imap | 143 | IMAP+STARTTLS | LAN only |
|
||||
| Dovecot imaps | 993 | IMAPS | HAProxy TCP pass-through, WAN |
|
||||
| Dovecot ManageSieve | 4190 | sieve+STARTTLS | HAProxy TCP pass-through, WAN |
|
||||
| Rspamd controller | 11334 | HTTP | Behind host nginx admin auth (Phase 2+) |
|
||||
| Rspamd worker | 11332 | milter | Localhost-in-LXC only (Phase 2+) |
|
||||
| ClamAV milter | 8894 | milter | Localhost-in-LXC only (Phase 2+) |
|
||||
| Roundcube HTTP (Apache or nginx) | 80 / 443 | HTTP | Behind host nginx on `webmail.<domain>` |
|
||||
|
||||
Host nginx publishes:
|
||||
- `https://mail-admin.gk2.secubox.in/` → FastAPI on UNIX socket `/run/secubox/mail.sock`
|
||||
- `https://webmail.gk2.secubox.in/` → `http://10.100.0.10:80/` (Roundcube)
|
||||
- `https://mail.gk2.secubox.in/.well-known/autoconfig/...` → FastAPI autoconfig
|
||||
- `https://rspamd.gk2.secubox.in/` → `http://10.100.0.10:11334/` (Phase 2+, admin-auth gated)
|
||||
|
||||
### 5.3 Daemon inventory (end of Phase 8)
|
||||
|
||||
Inside `mail` LXC:
|
||||
|
||||
| Daemon | Source | Role | Phase added |
|
||||
|---|---|---|---|
|
||||
| Postfix | Debian | MTA | already on board |
|
||||
| Dovecot | Debian | IMAP + LMTP + ManageSieve + SASL auth | already on board |
|
||||
| Apache2 + mod_php | Debian | Roundcube webserver | already on board (Phase 5 may migrate to nginx+php-fpm) |
|
||||
| Roundcube | Debian | Webmail (with classic/larry skins, plugins) | already on board |
|
||||
| Rspamd | Debian | Greylist + spam + DKIM + SPF + DMARC + ARC + ratelimit | Phase 2 |
|
||||
| ClamAV (clamd + clamav-milter) | Debian | Virus scan | Phase 2 |
|
||||
| mlmmj | Debian | Mailing lists | Phase 6 |
|
||||
| acme.sh | upstream | TLS cert renewal | host-side, already wired |
|
||||
| imapsync | upstream | One-shot per migration job | Phase 7 |
|
||||
|
||||
**Daemons removed by Phase 2:** SpamAssassin, OpenDKIM. (Postgrey was planned by rev. 1 but never installed; dropped from scope.)
|
||||
|
||||
### 5.4 Identity / provisioning flow (Phase 3)
|
||||
|
||||
```
|
||||
secubox-users API ──"user.created"──▶ mail provisioning webhook
|
||||
│
|
||||
▼
|
||||
mailctl provision <user@domain>
|
||||
│
|
||||
├──▶ /data/volumes/mail/vmail/<domain>/<user>/Maildir (mkdir + perms)
|
||||
├──▶ append /data/volumes/mail/config/users (Dovecot passwd-file, SHA512-CRYPT)
|
||||
├──▶ append /data/volumes/mail/config/vmailbox (Postfix virtual_mailbox_maps)
|
||||
└──▶ postmap if needed; notify Rspamd
|
||||
```
|
||||
|
||||
Password sync: `secubox-users` POSTs `/internal/password` over UNIX socket on every change. No password ever leaves the host except as the SHA512-CRYPT hash already stored in Dovecot's `users` file.
|
||||
|
||||
### 5.5 DNS records owned by the mail stack
|
||||
|
||||
For each managed domain, `mailctl dns-records <domain>` emits records `secubox-dns` must publish:
|
||||
|
||||
```
|
||||
mail.<domain> A <public IP>
|
||||
<domain> MX 10 mail.<domain>.
|
||||
<domain> TXT "v=spf1 mx -all"
|
||||
default._domainkey.<domain> TXT "v=DKIM1; k=rsa; p=<pubkey>"
|
||||
_dmarc.<domain> TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@<domain>; ruf=mailto:postmaster@<domain>; adkim=s; aspf=s"
|
||||
_imaps._tcp.<domain> SRV "0 1 993 mail.<domain>."
|
||||
_submission._tcp.<domain> SRV "0 1 587 mail.<domain>."
|
||||
autoconfig.<domain> CNAME mail.<domain>.
|
||||
autodiscover.<domain> CNAME mail.<domain>.
|
||||
```
|
||||
|
||||
Phase 3 wires this to `secubox-dns` via API.
|
||||
|
||||
### 5.6 `mail.toml` schema (target — end of Phase 3)
|
||||
|
||||
```toml
|
||||
[mail]
|
||||
enabled = true
|
||||
hostname = "mail.gk2.secubox.in"
|
||||
container = "mail"
|
||||
lxc_path = "/var/lib/lxc" # symlink to /data/lxc on this board
|
||||
data_path = "/data/volumes/mail"
|
||||
lxc_ip = "10.100.0.10"
|
||||
lxc_bridge = "br-lxc"
|
||||
lxc_gateway = "10.100.0.1"
|
||||
|
||||
[[mail.domain]]
|
||||
name = "secubox.in"
|
||||
primary = true
|
||||
dkim_selector = "default"
|
||||
dmarc_policy = "quarantine"
|
||||
catchall = ""
|
||||
|
||||
[mail.tls]
|
||||
provider = "acme"
|
||||
acme_email = "postmaster@secubox.in"
|
||||
|
||||
[mail.rspamd] # Phase 2+
|
||||
greylist = true
|
||||
bayes_autolearn = true
|
||||
ratelimit_outbound = "100/h/user"
|
||||
|
||||
[mail.identity] # Phase 3+
|
||||
source = "secubox-users"
|
||||
provisioning_url = "http://127.0.0.1:8093/api/v1/users"
|
||||
|
||||
[mail.dav] # Phase 5+
|
||||
provider = "secubox-nextcloud"
|
||||
url = "https://nextcloud.gk2.secubox.in/remote.php/dav/"
|
||||
|
||||
[mail.webmail] # Phase 5+
|
||||
enabled = true
|
||||
url = "https://webmail.gk2.secubox.in/"
|
||||
plugins = ["managesieve", "carddav", "calendar", "enigma", "twofactor"]
|
||||
|
||||
[mail.lists] # Phase 6+
|
||||
enabled = false
|
||||
default_domain = "lists.gk2.secubox.in"
|
||||
```
|
||||
|
||||
## 6. Phase plan (revised)
|
||||
|
||||
Phase 1 is now substantially smaller: most of the architectural bones are already on the board; the repo source just doesn't reflect them yet.
|
||||
|
||||
| # | Phase | Effort | Critical-path? |
|
||||
|---|---|---|---|
|
||||
| **0** | Architecture spec (this doc, rev. 2) | done | — |
|
||||
| **1** | **Reconcile source ↔ board, deprecate legacy packages, lock the data contract** | 2–3 days | yes |
|
||||
| **2** | Rspamd migration (drops SA + OpenDKIM, adds ClamAV) | 1 wk | yes |
|
||||
| **3** | Multi-domain + `secubox-users` provisioning hook | 1.5 wk | yes |
|
||||
| **4** | ManageSieve + quotas + vacation | 1 wk | yes |
|
||||
| **5** | Roundcube polish + Nextcloud DAV bridge + (optional) Apache→nginx+php-fpm | 1 wk | no |
|
||||
| **6** | mlmmj mailing lists + shared mailboxes | 1 wk | no |
|
||||
| **7** | imapsync migration tooling | 1 wk | no |
|
||||
| **8** | Self-service portal + observability + outbound abuse policies | 1.5 wk | no |
|
||||
|
||||
**Total:** ~8 weeks. Phase 5–8 can interleave once Phase 3 is in.
|
||||
|
||||
### Phase 1 — revised goal: "source-catch-up + legacy package cleanup"
|
||||
|
||||
**Deliverables**
|
||||
- Repo source updated to canonical paths/IP: `/var/lib/lxc/mail`, `/data/volumes/mail`, `10.100.0.10`, unprivileged veth br-lxc. (`mailctl`, `mailserverctl`, `roundcubectl`, `api/main.py`.)
|
||||
- `mail.toml` schema: single `container`, `lxc_ip`, `lxc_bridge`, `lxc_gateway`, `data_path`. Drop `mail_container`/`webmail_container`/`mail_ip`/`webmail_ip`/`webmail_port`.
|
||||
- `lib/install.sh` + `lib/lxc.sh` extracted from `mailserverctl` for re-use.
|
||||
- `mailctl migrate-config` rewrites a legacy `mail.toml` in place. Idempotent.
|
||||
- `mail-migrate-to-single-lxc.sh` becomes a defensive **scanner** that detects old `mailserver`/`roundcube` LXC directories (none expected on this board) and old toml keys, and applies safe migration. **Refuses to touch `/data/volumes/mail/` if data is present** (per I13).
|
||||
- Legacy `secubox-mail-lxc`, `secubox-webmail-lxc`, `secubox-webmail` packages → transitional metadata-only `2.2.0` packages that just `Depends: secubox-mail (>= 2.2)`.
|
||||
- `secubox-mail` bumps to `2.2.0` (one minor higher than current `2.1.0`) with `Breaks:`/`Replaces:` against the transitional packages.
|
||||
- Host nginx vhost: `mail-admin.<base>` → FastAPI socket; `webmail.<base>` → `http://10.100.0.10:80/`. Replaces both `packages/secubox-mail/nginx/mail.conf` and `packages/secubox-webmail/nginx/webmail.conf` with one `common/nginx/modules.d/mail.conf`.
|
||||
- HAProxy SMTP/submission/IMAPS/sieve backends targeting `10.100.0.10`.
|
||||
- API `main.py` updated to read new keys; all 62 endpoints respond non-5xx (presence test).
|
||||
- Acceptance: from clean checkout + deploy, `mailctl status` correctly reports the existing `mail` LXC; `mailctl start` brings it up; existing 5 `secubox.in` users can IMAP login; Roundcube responds via host proxy.
|
||||
|
||||
**Explicitly out of Phase 1:**
|
||||
- Installing Postgrey / ClamAV inside the LXC — Phase 2 handles ClamAV; Postgrey is dropped entirely.
|
||||
- Multi-domain refactor — Phase 3.
|
||||
- Apache → nginx+php-fpm migration — Phase 5 if desired.
|
||||
- Roundcube CardDAV/CalDAV plugin wiring — Phase 5.
|
||||
|
||||
## 7. Deprecations and breaking changes
|
||||
|
||||
| Item | Phase | Migration |
|
||||
|---|---|---|
|
||||
| `secubox-mail-lxc` package | 1 | Transitional 2.2.0 stub depending on `secubox-mail (>= 2.2)`. Removed entirely in 3.0. |
|
||||
| `secubox-webmail-lxc` package | 1 | Same |
|
||||
| `secubox-webmail` package | 1 | Same — its API surface folded into `secubox-mail` API. |
|
||||
| `mail_container`, `webmail_container`, `mail_ip`, `webmail_ip`, `webmail_port` in `mail.toml` | 1 | `mailctl migrate-config` rewrites to single `container`/`lxc_ip` + comments the old keys for one release. |
|
||||
| `/srv/lxc/`, `/srv/mail/` paths in source | 1 | Replaced by `/var/lib/lxc/` and `/data/volumes/mail/` everywhere. |
|
||||
| `192.168.255.30/31` IP literals in source | 1 | Replaced by `10.100.0.10` (and `lxc_ip` lookup from toml). |
|
||||
| OpenDKIM (`/dkim/*` API) | 2 | Rspamd DKIM module; old endpoints proxy for one minor version, removed in 3.0. |
|
||||
| SpamAssassin (`/spam/*`) | 2 | Rspamd spam scoring; same pattern. |
|
||||
| Postgrey (`/grey/*`) | 2 | Rspamd greylist module; the `/grey/*` endpoints were stubbed but Postgrey was never installed — endpoints return informative deprecation responses. |
|
||||
| `domain` scalar in `mail.toml` | 3 | Migrated to `[[mail.domain]]` array. |
|
||||
|
||||
## 8. GitHub issue plan
|
||||
|
||||
| # | Title | Label | Phase |
|
||||
|---|---|---|---|
|
||||
| TBD | Mail stack: Phase 1 — source-catch-up + legacy package cleanup | `migration,wip` | 1 |
|
||||
| TBD | Mail stack: Phase 2 — Rspamd migration | `migration,security` | 2 |
|
||||
| TBD | Mail stack: Phase 3 — multi-domain + secubox-users integration | `migration,api` | 3 |
|
||||
| TBD | Mail stack: Phase 4 — ManageSieve + quotas + vacation | `api,frontend` | 4 |
|
||||
| TBD | Mail stack: Phase 5 — Roundcube polish + Nextcloud DAV bridge | `frontend` | 5 |
|
||||
| TBD | Mail stack: Phase 6 — mailing lists + shared mailboxes | `api,frontend` | 6 |
|
||||
| TBD | Mail stack: Phase 7 — imapsync migration tooling | `migration` | 7 |
|
||||
| TBD | Mail stack: Phase 8 — self-service portal + metrics + abuse | `frontend,infra` | 8 |
|
||||
|
||||
Issues filed at start of each phase.
|
||||
|
||||
## 9. Open questions (deferred to per-phase specs)
|
||||
|
||||
- **Roundcube webserver (Phase 5):** Keep Apache+mod_php (current) or migrate to nginx+php-fpm? Decided in Phase 5 spec. Phase 1 does **not** touch this.
|
||||
- **PGP key escrow (Phase 5/8):** read-only after import vs. user-managed in self-service portal?
|
||||
- **HAProxy SMTP cert handling:** TCP pass-through (current direction) vs. terminate at HAProxy with shared cert. Phase 1 stays pass-through; revisit only if cert renewal proves painful.
|
||||
- **Mailing list tool (Phase 6):** mlmmj vs. Mailman 3?
|
||||
|
||||
## 10. ANSSI / CSPN posture
|
||||
|
||||
- **Privilege separation:** every daemon under its own user. LXC unprivileged adds a second layer (root inside LXC = uid 100000 outside).
|
||||
- **Audit logging:** all admin actions (provision, delete, password reset, sieve edit) appended to `/data/volumes/mail/audit.log` and to `secubox-users` audit stream.
|
||||
- **Double-buffer config:** `mailctl` writes Postfix/Dovecot/Rspamd config under `/data/volumes/mail/config/shadow/`, validates with `postfix check` / `doveconf -n`, atomic-swap to `active/`. Keeps R1..R4.
|
||||
- **AppArmor profiles:** one per daemon, shipped by `secubox-mail` debian/, enforced via `postinst`.
|
||||
- **Secrets:** Dovecot SHA512-CRYPT only; DKIM private keys 0600 owned by `_rspamd` (post-Phase-2); ACME private keys 0600 owned by root. Nothing leaves the host.
|
||||
|
||||
---
|
||||
|
||||
**End of Phase 0 spec rev. 2.** Next: revised Phase 1 plan, then user re-confirmation, then execution.
|
||||
|
|
@ -28,6 +28,8 @@ HELPER_SOCK = os.environ.get(
|
|||
TARGET_FPS = 30
|
||||
PROBE_INTERVAL_S = 30
|
||||
METRICS_INTERVAL_S = 2
|
||||
# Radar sweep rotation speed (matches the deployed round fallback radar).
|
||||
RADAR_RPM = 12.0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
|
@ -89,8 +91,10 @@ def main() -> int:
|
|||
if ev.kind == "tap":
|
||||
_dispatch_tap(ev.x, ev.y, panel, dashboard)
|
||||
|
||||
# Render.
|
||||
full = dashboard.layout(metrics)
|
||||
# Render. phase advances the radar sweep angle — monotonic
|
||||
# so frame-to-frame motion is smooth across system clock jumps.
|
||||
phase = (time.monotonic() * RADAR_RPM / 60.0) % 1.0
|
||||
full = dashboard.layout(metrics, phase=phase)
|
||||
if pointer.cursor_visible:
|
||||
draw_cursor(full, *pointer.cursor_xy)
|
||||
fb.blit(full)
|
||||
|
|
|
|||
|
|
@ -22,27 +22,34 @@ class SquareDashboard(DashboardCanvas):
|
|||
DASHBOARD_REGION_SIZE = (480, 480)
|
||||
PANEL_REGION_SIZE = (320, 480)
|
||||
CENTER = (240, 240)
|
||||
RING_RADII = [200, 185, 170, 155, 140, 125]
|
||||
# Same radii as RoundDashboard so the left half is visually identical
|
||||
# to the deployed Pi Zero W radar.
|
||||
RING_RADII = [214, 188, 162, 136, 110, 84]
|
||||
|
||||
def __init__(self, right_panel):
|
||||
self.right_panel = right_panel
|
||||
|
||||
def layout(self, metrics: dict) -> Image.Image:
|
||||
# Image.new() with COSMOS_BLACK+(255,) is equivalent to calling
|
||||
# paint_background on a fresh canvas; skip the redundant fill.
|
||||
def layout(self, metrics: dict, phase: float = 0.0) -> Image.Image:
|
||||
"""Render one frame at animation `phase` (0..1).
|
||||
|
||||
Phase rotates the radar sweep on the left half; the right panel
|
||||
is static. Pass `phase=0.0` for a still frame.
|
||||
"""
|
||||
img = Image.new("RGBA", self.SIZE, theme.COSMOS_BLACK + (255,))
|
||||
|
||||
# Left dashboard region.
|
||||
# Left dashboard region — phase-aware radar.
|
||||
dash = Image.new("RGBA", self.DASHBOARD_REGION_SIZE,
|
||||
theme.COSMOS_BLACK + (255,))
|
||||
self.paint_rainbow_ring(dash, self.CENTER, 235, 220)
|
||||
self.paint_concentric_arcs(dash, self.CENTER, MODULES, metrics,
|
||||
self.RING_RADII)
|
||||
self.paint_pod_cluster(dash, MODULES, self.CENTER, radius=70, pod_size=40)
|
||||
self.paint_radar_concentric(
|
||||
dash, self.CENTER, MODULES, metrics,
|
||||
radii=self.RING_RADII, phase=phase, draw_hub=True,
|
||||
)
|
||||
# pod_size=48 matches deployed icon sizes (22/48/96/128).
|
||||
self.paint_pod_cluster(dash, MODULES, self.CENTER, radius=78, pod_size=48)
|
||||
self.paint_central_button(dash, self.CENTER, size=44)
|
||||
img.paste(dash, (0, 0))
|
||||
|
||||
# Right panel.
|
||||
# Right panel (static).
|
||||
panel = Image.new("RGBA", self.PANEL_REGION_SIZE,
|
||||
theme.COSMOS_BLACK + (255,))
|
||||
self.right_panel.draw(panel)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
secubox-mail-lxc (2.2.0-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Transitional package — all functionality moved to secubox-mail >= 2.2.
|
||||
* Closes: #136
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Fri, 15 May 2026 12:00:00 +0200
|
||||
|
||||
secubox-mail-lxc (1.1.0-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Remove standalone menu entry (now integrated into secubox-mail UI)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
Source: secubox-mail-lxc
|
||||
Section: admin
|
||||
Section: oldlibs
|
||||
Priority: optional
|
||||
Maintainer: Gerald KERMA <devel@cybermind.fr>
|
||||
Build-Depends: debhelper-compat (= 13)
|
||||
|
|
@ -7,15 +7,8 @@ Standards-Version: 4.6.2
|
|||
|
||||
Package: secubox-mail-lxc
|
||||
Architecture: all
|
||||
Depends: ${misc:Depends}, secubox-core (>= 1.0.0), lxc, wget
|
||||
Recommends: secubox-mail
|
||||
Description: SecuBox Mail LXC Container (Backend)
|
||||
LXC container management for Postfix/Dovecot mail server.
|
||||
Backend component consumed by secubox-mail (no standalone UI).
|
||||
.
|
||||
Provides Alpine Linux container with:
|
||||
- Postfix MTA
|
||||
- Dovecot IMAP/POP3
|
||||
- OpenDKIM signing
|
||||
.
|
||||
Install secubox-mail for the management UI.
|
||||
Depends: ${misc:Depends}, secubox-mail (>= 2.2)
|
||||
Description: Transitional package — mail LXC functionality moved to secubox-mail
|
||||
The single consolidated mail LXC is now installed and driven by
|
||||
secubox-mail (>= 2.2). This package ships no files. Safe to
|
||||
apt-get autoremove after upgrade.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Transitional package (secubox-mail-lxc 2.2.0) — clean up the old
|
||||
# standalone service if it's still around from <2.2 installs.
|
||||
if [ "$1" = "configure" ]; then
|
||||
systemctl daemon-reload
|
||||
systemctl enable secubox-mail-lxc.service || true
|
||||
systemctl start secubox-mail-lxc.service || true
|
||||
if [ -e /lib/systemd/system/secubox-mail-lxc.service ] \
|
||||
|| [ -e /etc/systemd/system/secubox-mail-lxc.service ]; then
|
||||
systemctl stop secubox-mail-lxc.service 2>/dev/null || true
|
||||
systemctl disable secubox-mail-lxc.service 2>/dev/null || true
|
||||
rm -f /lib/systemd/system/secubox-mail-lxc.service \
|
||||
/etc/systemd/system/secubox-mail-lxc.service
|
||||
systemctl daemon-reload || true
|
||||
fi
|
||||
fi
|
||||
|
||||
#DEBHELPER#
|
||||
exit 0
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
if [ "$1" = "remove" ]; then
|
||||
systemctl stop secubox-mail-lxc.service || true
|
||||
systemctl disable secubox-mail-lxc.service || true
|
||||
systemctl reload nginx 2>/dev/null || true
|
||||
fi
|
||||
# Transitional package — nothing to undo.
|
||||
#DEBHELPER#
|
||||
exit 0
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
#!/usr/bin/make -f
|
||||
# Transitional package — ships no files. dh handles the metadata only.
|
||||
%:
|
||||
dh $@
|
||||
|
||||
override_dh_auto_install:
|
||||
# API (used by secubox-mail for container management)
|
||||
install -d debian/secubox-mail-lxc/usr/lib/secubox/mail-lxc
|
||||
cp -r api debian/secubox-mail-lxc/usr/lib/secubox/mail-lxc/
|
||||
# Control scripts
|
||||
install -d debian/secubox-mail-lxc/usr/sbin
|
||||
[ -d sbin ] && install -m 755 sbin/* debian/secubox-mail-lxc/usr/sbin/ || true
|
||||
# Modular nginx config (API proxy only, no separate menu)
|
||||
install -d debian/secubox-mail-lxc/etc/nginx/secubox.d
|
||||
[ -f nginx/mail-lxc.conf ] && cp nginx/mail-lxc.conf debian/secubox-mail-lxc/etc/nginx/secubox.d/ || true
|
||||
# intentionally empty — no payload in transitional package
|
||||
:
|
||||
|
||||
override_dh_installsystemd:
|
||||
# do not install/enable the old secubox-mail-lxc.service
|
||||
:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
# /etc/nginx/secubox.d/mail-lxc.conf
|
||||
# Installed by secubox-mail-lxc package
|
||||
location /api/v1/mail-lxc/ {
|
||||
proxy_pass http://unix:/run/secubox/mail-lxc.sock:/;
|
||||
include /etc/nginx/snippets/secubox-proxy.conf;
|
||||
}
|
||||
# DEPRECATED in secubox-mail-lxc 2.2 — mail LXC management folded into
|
||||
# secubox-mail's /api/v1/mail/. This snippet is kept empty for one release.
|
||||
|
|
|
|||
|
|
@ -19,17 +19,27 @@ from pydantic import BaseModel
|
|||
from secubox_core.auth import require_jwt
|
||||
from secubox_core.config import get_config
|
||||
|
||||
app = FastAPI(title="SecuBox Mail", version="1.8.0")
|
||||
app = FastAPI(title="SecuBox Mail", version="2.2.0")
|
||||
config = get_config("mail")
|
||||
|
||||
DATA_PATH = Path(config.get("data_path", "/srv/mail"))
|
||||
LXC_PATH = Path(config.get("lxc_path", "/srv/lxc"))
|
||||
MAIL_CONTAINER = config.get("mail_container", "mailserver")
|
||||
WEBMAIL_CONTAINER = config.get("webmail_container", "roundcube")
|
||||
# Canonical config keys (Phase 1 rev. 2). Legacy keys are accepted as
|
||||
# fallback for one release so deploys mid-upgrade don't break.
|
||||
DATA_PATH = Path(config.get("data_path", "/data/volumes/mail"))
|
||||
LXC_PATH = Path(config.get("lxc_path", "/var/lib/lxc"))
|
||||
CONTAINER = config.get("container", config.get("mail_container", "mail"))
|
||||
# Legacy alias retained for callers that still reference WEBMAIL_CONTAINER —
|
||||
# webmail is now in the same single LXC.
|
||||
MAIL_CONTAINER = CONTAINER
|
||||
WEBMAIL_CONTAINER = CONTAINER
|
||||
DOMAIN = config.get("domain", "secubox.local")
|
||||
HOSTNAME = config.get("hostname", "mail")
|
||||
MAIL_IP = config.get("mail_ip", "192.168.255.30")
|
||||
WEBMAIL_PORT = config.get("webmail_port", 8027)
|
||||
LXC_IP = config.get("lxc_ip", config.get("mail_ip", "10.100.0.10"))
|
||||
LXC_BRIDGE = config.get("lxc_bridge", "br-lxc")
|
||||
LXC_GATEWAY = config.get("lxc_gateway", "10.100.0.1")
|
||||
# Webmail is on standard HTTP inside the LXC; host nginx proxies via :443.
|
||||
WEBMAIL_PORT = 80
|
||||
# Back-compat alias for any caller still reading MAIL_IP.
|
||||
MAIL_IP = LXC_IP
|
||||
|
||||
|
||||
def run_cmd(cmd: list, timeout: int = 30) -> tuple:
|
||||
|
|
@ -901,21 +911,31 @@ async def fix_ports():
|
|||
class SettingsUpdate(BaseModel):
|
||||
domain: Optional[str] = None
|
||||
hostname: Optional[str] = None
|
||||
# Canonical fields (Phase 1 rev. 2)
|
||||
lxc_ip: Optional[str] = None
|
||||
# Legacy aliases — accepted for back-compat, mapped to lxc_ip server-side.
|
||||
mail_ip: Optional[str] = None
|
||||
webmail_port: Optional[int] = None
|
||||
|
||||
|
||||
@app.get("/settings", dependencies=[Depends(require_jwt)])
|
||||
async def get_settings():
|
||||
"""Get mail configuration settings"""
|
||||
"""Get mail configuration settings."""
|
||||
return {
|
||||
"domain": DOMAIN,
|
||||
"hostname": HOSTNAME,
|
||||
"mail_ip": MAIL_IP,
|
||||
"webmail_port": WEBMAIL_PORT,
|
||||
"mail_container": MAIL_CONTAINER,
|
||||
"webmail_container": WEBMAIL_CONTAINER,
|
||||
# Canonical
|
||||
"container": CONTAINER,
|
||||
"lxc_ip": LXC_IP,
|
||||
"lxc_bridge": LXC_BRIDGE,
|
||||
"lxc_gateway": LXC_GATEWAY,
|
||||
"data_path": str(DATA_PATH),
|
||||
"lxc_path": str(LXC_PATH),
|
||||
# Legacy aliases (kept until v3.0)
|
||||
"mail_ip": LXC_IP,
|
||||
"webmail_port": WEBMAIL_PORT,
|
||||
"mail_container": CONTAINER,
|
||||
"webmail_container": CONTAINER,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -932,14 +952,19 @@ async def update_settings(settings: SettingsUpdate):
|
|||
key, val = line.split("=", 1)
|
||||
current[key.strip()] = val.strip().strip('"')
|
||||
|
||||
# Update values
|
||||
# Update values. Canonical keys take precedence; legacy fields are
|
||||
# accepted for one release and mapped to canonical.
|
||||
if settings.domain:
|
||||
current["domain"] = settings.domain
|
||||
if settings.hostname:
|
||||
current["hostname"] = settings.hostname
|
||||
if settings.mail_ip:
|
||||
current["mail_ip"] = settings.mail_ip
|
||||
if settings.lxc_ip:
|
||||
current["lxc_ip"] = settings.lxc_ip
|
||||
elif settings.mail_ip:
|
||||
current["lxc_ip"] = settings.mail_ip
|
||||
if settings.webmail_port:
|
||||
# webmail_port is no longer meaningful (Roundcube is on :80 inside LXC,
|
||||
# proxied via host nginx :443). Recorded for audit only.
|
||||
current["webmail_port"] = str(settings.webmail_port)
|
||||
|
||||
# Write config
|
||||
|
|
|
|||
0
packages/secubox-mail/api/tests/__init__.py
Normal file
0
packages/secubox-mail/api/tests/__init__.py
Normal file
9
packages/secubox-mail/api/tests/conftest.py
Normal file
9
packages/secubox-mail/api/tests/conftest.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"""pytest conftest — make secubox_core importable when running locally
|
||||
out of the source tree (no system-wide install)."""
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[4]
|
||||
COMMON = REPO_ROOT / "common"
|
||||
if COMMON.is_dir() and str(COMMON) not in sys.path:
|
||||
sys.path.insert(0, str(COMMON))
|
||||
93
packages/secubox-mail/api/tests/test_phase1_endpoints.py
Normal file
93
packages/secubox-mail/api/tests/test_phase1_endpoints.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""Phase 1 rev. 2 acceptance: every existing API endpoint still responds non-5xx
|
||||
after the source-catch-up renames.
|
||||
|
||||
We don't care about response *content* here — only that the route is
|
||||
registered and the handler doesn't 500 on a default invocation. JWT-protected
|
||||
endpoints return 401 without a token; that still counts as "registered".
|
||||
Phase 2+ tightens this to assert specific shapes.
|
||||
"""
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parents[2]))
|
||||
from api.main import app # noqa: E402
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Pulled from packages/secubox-mail/api/main.py via grep '@app\.' on 2026-05-15.
|
||||
# 62 endpoints — keep this list in sync if main.py adds/removes routes.
|
||||
LEGACY_ROUTES = [
|
||||
("GET", "/status"),
|
||||
("GET", "/health"),
|
||||
("GET", "/components"),
|
||||
("GET", "/access"),
|
||||
("GET", "/mail/config-v1.1.xml"),
|
||||
("GET", "/autoconfig/mail/config-v1.1.xml"),
|
||||
("GET", "/autodiscover/autodiscover.xml"),
|
||||
("POST", "/autodiscover/autodiscover.xml"),
|
||||
("POST", "/Autodiscover/Autodiscover.xml"),
|
||||
("GET", "/.well-known/autoconfig/mail/config-v1.1.xml"),
|
||||
("GET", "/users"),
|
||||
("POST", "/user"),
|
||||
("DELETE", "/user/foo@example.com"),
|
||||
("POST", "/user/password"),
|
||||
("GET", "/aliases"),
|
||||
("POST", "/alias"),
|
||||
("DELETE", "/alias/foo@example.com"),
|
||||
("POST", "/start"),
|
||||
("POST", "/stop"),
|
||||
("POST", "/restart"),
|
||||
("POST", "/install"),
|
||||
("GET", "/webmail/status"),
|
||||
("POST", "/webmail/start"),
|
||||
("POST", "/webmail/stop"),
|
||||
("POST", "/webmail/restart"),
|
||||
("POST", "/webmail/install"),
|
||||
("POST", "/migrate"),
|
||||
("GET", "/backups"),
|
||||
("POST", "/backup"),
|
||||
("POST", "/restore/test"),
|
||||
("GET", "/logs"),
|
||||
("GET", "/ssl"),
|
||||
("POST", "/ssl/setup"),
|
||||
("GET", "/acme/status"),
|
||||
("POST", "/acme/issue"),
|
||||
("POST", "/acme/renew"),
|
||||
("POST", "/acme/install"),
|
||||
("GET", "/dns-setup"),
|
||||
("POST", "/user/repair/foo@example.com"),
|
||||
("POST", "/fix-ports"),
|
||||
("GET", "/settings"),
|
||||
("POST", "/settings"),
|
||||
("GET", "/dkim/status"),
|
||||
("POST", "/dkim/setup"),
|
||||
("POST", "/dkim/keygen"),
|
||||
("POST", "/dkim/sync"),
|
||||
("GET", "/dkim/record"),
|
||||
("GET", "/spam/status"),
|
||||
("POST", "/spam/setup"),
|
||||
("POST", "/spam/enable"),
|
||||
("POST", "/spam/disable"),
|
||||
("POST", "/spam/update"),
|
||||
("GET", "/grey/status"),
|
||||
("POST", "/grey/setup"),
|
||||
("POST", "/grey/enable"),
|
||||
("POST", "/grey/disable"),
|
||||
("GET", "/av/status"),
|
||||
("POST", "/av/setup"),
|
||||
("POST", "/av/enable"),
|
||||
("POST", "/av/disable"),
|
||||
("POST", "/av/update"),
|
||||
("GET", "/example.com.mobileconfig"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method,path", LEGACY_ROUTES)
|
||||
def test_route_responds(method, path):
|
||||
resp = client.request(method, path, json={})
|
||||
assert resp.status_code < 500, (
|
||||
f"{method} {path} → {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
|
|
@ -1,21 +1,21 @@
|
|||
# SecuBox Mail Server Configuration
|
||||
# SecuBox Mail Server Configuration — Phase 1 rev. 2 (single LXC, canonical paths)
|
||||
|
||||
[mail]
|
||||
enabled = true
|
||||
domain = "secubox.local"
|
||||
hostname = "mail"
|
||||
data_path = "/srv/mail"
|
||||
lxc_path = "/srv/lxc"
|
||||
|
||||
# Mail server container
|
||||
mail_container = "mailserver"
|
||||
mail_ip = "192.168.255.30"
|
||||
# Single consolidated LXC (Phase 1 rev. 2)
|
||||
container = "mail"
|
||||
lxc_ip = "10.100.0.10"
|
||||
lxc_bridge = "br-lxc"
|
||||
lxc_gateway = "10.100.0.1"
|
||||
lxc_path = "/var/lib/lxc"
|
||||
data_path = "/data/volumes/mail"
|
||||
|
||||
# Webmail container
|
||||
webmail_container = "roundcube"
|
||||
webmail_ip = "192.168.255.31"
|
||||
webmail_port = 8027
|
||||
# Webmail is served by the same LXC; this URL is the host-side proxy target
|
||||
webmail_url = "https://webmail.gk2.secubox.in"
|
||||
|
||||
# SSL settings
|
||||
ssl_provider = "acme" # acme, manual, none
|
||||
ssl_provider = "acme" # acme | manual | none
|
||||
acme_email = ""
|
||||
|
|
|
|||
|
|
@ -1,3 +1,20 @@
|
|||
secubox-mail (2.2.0-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Phase 1 source-catch-up: canonical paths /var/lib/lxc/mail +
|
||||
/data/volumes/mail, IP 10.100.0.10 (unprivileged veth br-lxc).
|
||||
* Extract lib/install.sh + lib/lxc.sh + lib/migrate.sh from mailserverctl.
|
||||
* mailctl gains migrate-config subcommand; mailserverctl + roundcubectl
|
||||
reduced to deprecation shims (will be removed in 3.0).
|
||||
* mail-migrate-to-single-lxc.sh added as defensive scanner — respects
|
||||
spec invariant I13 (existing /data/volumes/mail/vmail data preserved).
|
||||
* HAProxy mail-TCP snippet shipped at packages/secubox-mail/haproxy/
|
||||
targeting 10.100.0.10.
|
||||
* Breaks/Replaces transitional secubox-mail-lxc / secubox-webmail-lxc /
|
||||
secubox-webmail packages (<< 2.2).
|
||||
* Closes: #136
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Fri, 15 May 2026 12:00:00 +0200
|
||||
|
||||
secubox-mail (2.1.0-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Enhanced frontend with security features dashboard
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ Standards-Version: 4.6.2
|
|||
Package: secubox-mail
|
||||
Architecture: all
|
||||
Depends: ${misc:Depends}, secubox-core (>= 1.0.0), lxc, debootstrap, openssl
|
||||
Breaks: secubox-mail-lxc (<< 2.2), secubox-webmail (<< 2.2), secubox-webmail-lxc (<< 2.2)
|
||||
Replaces: secubox-mail-lxc (<< 2.2), secubox-webmail (<< 2.2), secubox-webmail-lxc (<< 2.2)
|
||||
Suggests: acme.sh
|
||||
Description: SecuBox Mail Module
|
||||
Complete email server management API and dashboard.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,23 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
if [ "$1" = "configure" ]; then
|
||||
# Phase 1 rev. 2: on upgrade from < 2.2, rewrite legacy toml + run
|
||||
# the defensive scanner. Both are idempotent and refuse to touch
|
||||
# /data/volumes/mail/vmail user data (spec invariant I13).
|
||||
if dpkg --compare-versions "${2:-0}" lt-nl 2.2.0; then
|
||||
if [ -x /usr/sbin/mailctl ]; then
|
||||
/usr/sbin/mailctl migrate-config || true
|
||||
fi
|
||||
if [ -x /usr/sbin/mail-migrate-to-single-lxc.sh ]; then
|
||||
/usr/sbin/mail-migrate-to-single-lxc.sh || true
|
||||
fi
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable secubox-mail.service || true
|
||||
systemctl start secubox-mail.service || true
|
||||
fi
|
||||
|
||||
#DEBHELPER#
|
||||
exit 0
|
||||
|
|
|
|||
38
packages/secubox-mail/debian/secubox-mail.NEWS
Normal file
38
packages/secubox-mail/debian/secubox-mail.NEWS
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
secubox-mail (2.2.0-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
Phase 1 source-catch-up + WAF-compliant routing notes.
|
||||
|
||||
This release does not change the running mail LXC. It updates the host-
|
||||
side controllers, packaging metadata, and config schema to match the
|
||||
canonical paths used by the production board:
|
||||
|
||||
LXC: /var/lib/lxc/mail (symlink → /data/lxc/mail)
|
||||
Data: /data/volumes/mail/{vmail,config,ssl}
|
||||
Network: unprivileged veth on br-lxc, 10.100.0.10/24
|
||||
|
||||
WAF compliance (mandatory per CLAUDE.md):
|
||||
|
||||
* HTTPS surface (webmail.<domain>, mail-admin.<domain>) MUST route
|
||||
via HAProxy → mitmproxy_inspector → backend. This release does not
|
||||
change the HAProxy frontend ACLs (already in place for
|
||||
webmail.gk2.secubox.in). The mitmproxy route map must be updated
|
||||
once at deploy time:
|
||||
|
||||
/srv/mitmproxy/haproxy-routes.json
|
||||
/srv/mitmproxy-in/haproxy-routes.json
|
||||
"webmail.gk2.secubox.in": ["10.100.0.10", 80]
|
||||
|
||||
Then: systemctl restart mitmproxy
|
||||
|
||||
* Mail protocols (SMTP 25/587/465, IMAPS 993, ManageSieve 4190) are
|
||||
TCP pass-through to the LXC via HAProxy. They do not transit
|
||||
mitmproxy because mitmproxy inspects HTTP, not SMTP/IMAP. This is
|
||||
not a WAF bypass — mail protocols are not in the WAF's protocol
|
||||
scope.
|
||||
|
||||
Legacy companion packages (secubox-mail-lxc, secubox-webmail,
|
||||
secubox-webmail-lxc) become transitional metadata-only stubs that
|
||||
depend on secubox-mail (>= 2.2). Safe to apt-get autoremove after
|
||||
upgrade.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Fri, 15 May 2026 12:00:00 +0200
|
||||
66
packages/secubox-mail/haproxy/mail-tcp.cfg
Normal file
66
packages/secubox-mail/haproxy/mail-tcp.cfg
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# SecuBox-Deb :: HAProxy mail TCP frontends (Phase 1 rev. 2)
|
||||
# To activate: append the contents of this file to /etc/haproxy/haproxy.cfg
|
||||
# after the http/https frontends, then `systemctl reload haproxy`.
|
||||
#
|
||||
# Mail protocols (SMTP, IMAPS, ManageSieve) are TCP pass-through to the
|
||||
# single mail LXC at 10.100.0.10. They do NOT transit mitmproxy because
|
||||
# mitmproxy inspects HTTP, not SMTP/IMAP. This is not a WAF bypass —
|
||||
# mail protocols are out of the WAF's protocol scope (per CLAUDE.md).
|
||||
#
|
||||
# Postfix/Dovecot inside the LXC present their own TLS certificates;
|
||||
# HAProxy operates in tcp mode and does not terminate TLS for these.
|
||||
|
||||
frontend smtp_in
|
||||
bind *:25
|
||||
mode tcp
|
||||
option tcplog
|
||||
default_backend smtp_mail
|
||||
|
||||
frontend submission_in
|
||||
bind *:587
|
||||
mode tcp
|
||||
option tcplog
|
||||
default_backend submission_mail
|
||||
|
||||
frontend submissions_in
|
||||
bind *:465
|
||||
mode tcp
|
||||
option tcplog
|
||||
default_backend submissions_mail
|
||||
|
||||
frontend imaps_in
|
||||
bind *:993
|
||||
mode tcp
|
||||
option tcplog
|
||||
default_backend imaps_mail
|
||||
|
||||
frontend managesieve_in
|
||||
bind *:4190
|
||||
mode tcp
|
||||
option tcplog
|
||||
default_backend managesieve_mail
|
||||
|
||||
backend smtp_mail
|
||||
mode tcp
|
||||
option tcplog
|
||||
server mail 10.100.0.10:25 check
|
||||
|
||||
backend submission_mail
|
||||
mode tcp
|
||||
option tcplog
|
||||
server mail 10.100.0.10:587 check
|
||||
|
||||
backend submissions_mail
|
||||
mode tcp
|
||||
option tcplog
|
||||
server mail 10.100.0.10:465 check
|
||||
|
||||
backend imaps_mail
|
||||
mode tcp
|
||||
option tcplog
|
||||
server mail 10.100.0.10:993 check
|
||||
|
||||
backend managesieve_mail
|
||||
mode tcp
|
||||
option tcplog
|
||||
server mail 10.100.0.10:4190 check
|
||||
299
packages/secubox-mail/lib/install.sh
Normal file
299
packages/secubox-mail/lib/install.sh
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
#!/usr/bin/env 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.
|
||||
|
||||
# SecuBox-Deb :: mail :: install + configure helpers for the single mail LXC.
|
||||
# Extracted in Phase 1 from packages/secubox-mail/sbin/{mailserverctl,roundcubectl}.
|
||||
# Sourced library — do not execute directly.
|
||||
#
|
||||
# All functions take the container name as $1. They read defaults from the
|
||||
# environment ($LXC_BASE, $DATA_PATH, $DOMAIN, $HOSTNAME, $WEBMAIL_PORT) so
|
||||
# the same helpers work from mailctl, mail-migrate-to-single-lxc.sh, and
|
||||
# the bats suite (which overrides $LXC_BASE/$DATA_PATH to a tmpdir).
|
||||
|
||||
# Bootstrap a fresh Debian bookworm rootfs into ${LXC_BASE}/${container}/rootfs.
|
||||
# Idempotent: safe to skip if rootfs already exists.
|
||||
bootstrap_debian() {
|
||||
local container="$1"
|
||||
local base="${LXC_BASE:-/var/lib/lxc}"
|
||||
local lxc_path="$base/$container"
|
||||
|
||||
mkdir -p "$lxc_path"
|
||||
if [ -d "$lxc_path/rootfs/etc" ]; then
|
||||
echo "[install] rootfs already present at $lxc_path/rootfs — skipping debootstrap"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! command -v debootstrap >/dev/null 2>&1; then
|
||||
echo "ERROR: debootstrap not installed. Run: apt install debootstrap" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "[install] running debootstrap (a few minutes)..."
|
||||
debootstrap --variant=minbase --include=ca-certificates,curl,gnupg,locales \
|
||||
bookworm "$lxc_path/rootfs" http://deb.debian.org/debian
|
||||
|
||||
echo "$container" > "$lxc_path/rootfs/etc/hostname"
|
||||
cat > "$lxc_path/rootfs/etc/resolv.conf" <<'EOF'
|
||||
nameserver 8.8.8.8
|
||||
nameserver 1.1.1.1
|
||||
EOF
|
||||
echo "[install] Debian base system installed"
|
||||
}
|
||||
|
||||
# Install Postfix + Dovecot + rsyslog inside the LXC rootfs. Run via chroot
|
||||
# so the container does not need to be running yet.
|
||||
install_mail_packages() {
|
||||
local container="$1"
|
||||
local rootfs="${LXC_BASE:-/var/lib/lxc}/$container/rootfs"
|
||||
|
||||
echo "[install] installing Postfix + Dovecot inside $rootfs..."
|
||||
chroot "$rootfs" /bin/bash <<'CHROOT_EOF'
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
postfix postfix-lmdb \
|
||||
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd \
|
||||
rsyslog ca-certificates openssl
|
||||
|
||||
groupadd -g 5000 vmail 2>/dev/null || true
|
||||
useradd -u 5000 -g vmail -s /usr/sbin/nologin -d /var/mail -M vmail 2>/dev/null || true
|
||||
|
||||
apt-get clean
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
CHROOT_EOF
|
||||
echo "[install] mail packages installed"
|
||||
}
|
||||
|
||||
# Install Apache+PHP+Roundcube inside the same LXC rootfs. Mirrors what the
|
||||
# legacy roundcubectl::install_roundcube_packages did, but the board reality
|
||||
# uses Apache+mod_php (not nginx+php-fpm). Phase 5 may reconcile.
|
||||
install_webmail_packages() {
|
||||
local container="$1"
|
||||
local rootfs="${LXC_BASE:-/var/lib/lxc}/$container/rootfs"
|
||||
|
||||
echo "[install] installing Roundcube webmail stack inside $rootfs..."
|
||||
chroot "$rootfs" /bin/bash <<'CHROOT_EOF'
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
apache2 libapache2-mod-php8.2 \
|
||||
php8.2-imap php8.2-ldap php8.2-curl php8.2-xml \
|
||||
php8.2-mbstring php8.2-intl php8.2-sqlite3 \
|
||||
php8.2-zip php8.2-gd \
|
||||
roundcube roundcube-core roundcube-plugins roundcube-sqlite3 \
|
||||
roundcube-skin-classic roundcube-skin-larry \
|
||||
php-net-sieve \
|
||||
ca-certificates curl
|
||||
|
||||
apt-get clean
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
CHROOT_EOF
|
||||
echo "[install] webmail packages installed"
|
||||
}
|
||||
|
||||
# Write Postfix main.cf + master.cf into the LXC rootfs. Reads $HOSTNAME +
|
||||
# $DOMAIN from the environment; caller (mailctl) supplies them from
|
||||
# /etc/secubox/mail.toml.
|
||||
configure_postfix() {
|
||||
local container="$1"
|
||||
local rootfs="${LXC_BASE:-/var/lib/lxc}/$container/rootfs"
|
||||
local hostname="${HOSTNAME:-mail}"
|
||||
local domain="${DOMAIN:-secubox.local}"
|
||||
echo "[install] configuring Postfix in $rootfs..."
|
||||
|
||||
mkdir -p "$rootfs/etc/postfix"
|
||||
|
||||
cat > "$rootfs/etc/postfix/main.cf" <<EOF
|
||||
# SecuBox Postfix Configuration
|
||||
myhostname = ${hostname}.${domain}
|
||||
mydomain = ${domain}
|
||||
myorigin = \$mydomain
|
||||
mydestination = \$myhostname, localhost.\$mydomain, localhost
|
||||
mynetworks = 127.0.0.0/8 [::1]/128 10.100.0.0/16 192.168.0.0/16 10.0.0.0/8
|
||||
|
||||
# Virtual mailbox
|
||||
virtual_mailbox_domains = ${domain}
|
||||
virtual_mailbox_base = /var/vmail
|
||||
virtual_mailbox_maps = lmdb:/etc/mail-config/vmailbox
|
||||
virtual_alias_maps = lmdb:/etc/mail-config/virtual
|
||||
virtual_uid_maps = static:5000
|
||||
virtual_gid_maps = static:5000
|
||||
virtual_transport = lmtp:unix:private/dovecot-lmtp
|
||||
|
||||
# SASL auth via Dovecot
|
||||
smtpd_sasl_auth_enable = yes
|
||||
smtpd_sasl_type = dovecot
|
||||
smtpd_sasl_path = private/auth
|
||||
smtpd_sasl_security_options = noanonymous
|
||||
broken_sasl_auth_clients = yes
|
||||
|
||||
# TLS
|
||||
smtpd_tls_cert_file = /etc/ssl/mail/fullchain.pem
|
||||
smtpd_tls_key_file = /etc/ssl/mail/privkey.pem
|
||||
smtpd_tls_security_level = may
|
||||
smtp_tls_security_level = may
|
||||
|
||||
# Restrictions
|
||||
smtpd_recipient_restrictions = permit_sasl_authenticated, permit_mynetworks, reject_unauth_destination
|
||||
smtpd_sender_restrictions = permit_sasl_authenticated, permit_mynetworks
|
||||
|
||||
# Limits
|
||||
mailbox_size_limit = 0
|
||||
message_size_limit = 52428800
|
||||
inet_interfaces = all
|
||||
inet_protocols = ipv4
|
||||
EOF
|
||||
|
||||
cat > "$rootfs/etc/postfix/master.cf" <<'EOF'
|
||||
smtp inet n - y - - smtpd
|
||||
submission inet n - y - - smtpd
|
||||
-o syslog_name=postfix/submission
|
||||
-o smtpd_tls_security_level=encrypt
|
||||
-o smtpd_sasl_auth_enable=yes
|
||||
-o smtpd_recipient_restrictions=permit_sasl_authenticated,reject
|
||||
smtps inet n - y - - smtpd
|
||||
-o syslog_name=postfix/smtps
|
||||
-o smtpd_tls_wrappermode=yes
|
||||
-o smtpd_sasl_auth_enable=yes
|
||||
-o smtpd_recipient_restrictions=permit_sasl_authenticated,reject
|
||||
pickup unix n - y 60 1 pickup
|
||||
cleanup unix n - y - 0 cleanup
|
||||
qmgr unix n - n 300 1 qmgr
|
||||
tlsmgr unix - - y 1000? 1 tlsmgr
|
||||
rewrite unix - - y - - trivial-rewrite
|
||||
bounce unix - - y - 0 bounce
|
||||
defer unix - - y - 0 bounce
|
||||
trace unix - - y - 0 bounce
|
||||
verify unix - - y - 1 verify
|
||||
flush unix n - y 1000? 0 flush
|
||||
proxymap unix - - n - - proxymap
|
||||
smtp unix - - y - - smtp
|
||||
relay unix - - y - - smtp
|
||||
showq unix n - y - - showq
|
||||
error unix - - y - - error
|
||||
retry unix - - y - - error
|
||||
discard unix - - y - - discard
|
||||
local unix - n n - - local
|
||||
virtual unix - n n - - virtual
|
||||
lmtp unix - - y - - lmtp
|
||||
anvil unix - - y - 1 anvil
|
||||
scache unix - - y - 1 scache
|
||||
EOF
|
||||
|
||||
# Stamp empty lookup tables if not already provided via bind-mount.
|
||||
[ -e "$rootfs/etc/mail-config/vmailbox" ] || touch "$rootfs/etc/mail-config/vmailbox" 2>/dev/null || true
|
||||
[ -e "$rootfs/etc/mail-config/virtual" ] || touch "$rootfs/etc/mail-config/virtual" 2>/dev/null || true
|
||||
|
||||
echo "[install] Postfix configured"
|
||||
}
|
||||
|
||||
# Write dovecot.conf into the LXC rootfs.
|
||||
configure_dovecot() {
|
||||
local container="$1"
|
||||
local rootfs="${LXC_BASE:-/var/lib/lxc}/$container/rootfs"
|
||||
echo "[install] configuring Dovecot in $rootfs..."
|
||||
|
||||
mkdir -p "$rootfs/etc/dovecot"
|
||||
|
||||
cat > "$rootfs/etc/dovecot/dovecot.conf" <<'EOF'
|
||||
protocols = imap pop3 lmtp
|
||||
listen = *
|
||||
mail_location = maildir:/var/vmail/%d/%n
|
||||
mail_uid = 5000
|
||||
mail_gid = 5000
|
||||
first_valid_uid = 500
|
||||
last_valid_uid = 65534
|
||||
|
||||
auth_mechanisms = plain login
|
||||
passdb {
|
||||
driver = passwd-file
|
||||
args = /etc/mail-config/users
|
||||
}
|
||||
userdb {
|
||||
driver = static
|
||||
args = uid=5000 gid=5000 home=/var/vmail/%d/%n
|
||||
}
|
||||
|
||||
ssl = no
|
||||
|
||||
service imap-login {
|
||||
inet_listener imap { port = 143 }
|
||||
inet_listener imaps { port = 993; ssl = yes }
|
||||
}
|
||||
service pop3-login {
|
||||
inet_listener pop3 { port = 110 }
|
||||
inet_listener pop3s { port = 995; ssl = yes }
|
||||
}
|
||||
service lmtp {
|
||||
unix_listener /var/spool/postfix/private/dovecot-lmtp {
|
||||
mode = 0600
|
||||
user = postfix
|
||||
group = postfix
|
||||
}
|
||||
}
|
||||
service auth {
|
||||
unix_listener /var/spool/postfix/private/auth {
|
||||
mode = 0660
|
||||
user = postfix
|
||||
group = postfix
|
||||
}
|
||||
}
|
||||
|
||||
namespace inbox {
|
||||
inbox = yes
|
||||
separator = /
|
||||
}
|
||||
|
||||
log_path = /var/log/dovecot.log
|
||||
info_log_path = /var/log/dovecot.log
|
||||
EOF
|
||||
|
||||
[ -e "$rootfs/etc/mail-config/users" ] || touch "$rootfs/etc/mail-config/users" 2>/dev/null || true
|
||||
chmod 644 "$rootfs/etc/mail-config/users" 2>/dev/null || true
|
||||
|
||||
echo "[install] Dovecot configured"
|
||||
}
|
||||
|
||||
# Write Apache+Roundcube config inside the LXC rootfs. Phase 1 mirrors what
|
||||
# the board has today; Phase 5 may migrate to nginx+php-fpm.
|
||||
configure_roundcube() {
|
||||
local container="$1"
|
||||
local rootfs="${LXC_BASE:-/var/lib/lxc}/$container/rootfs"
|
||||
local domain="${DOMAIN:-secubox.local}"
|
||||
echo "[install] configuring Roundcube (Apache) in $rootfs..."
|
||||
|
||||
mkdir -p "$rootfs/etc/apache2/sites-available" "$rootfs/etc/apache2/sites-enabled"
|
||||
|
||||
cat > "$rootfs/etc/apache2/sites-available/roundcube.conf" <<EOF
|
||||
<VirtualHost *:80>
|
||||
ServerName webmail.${domain}
|
||||
DocumentRoot /var/lib/roundcube/public_html
|
||||
<Directory /var/lib/roundcube/public_html>
|
||||
Options +FollowSymLinks
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
ErrorLog \${APACHE_LOG_DIR}/roundcube_error.log
|
||||
CustomLog \${APACHE_LOG_DIR}/roundcube_access.log combined
|
||||
</VirtualHost>
|
||||
EOF
|
||||
|
||||
chroot "$rootfs" /bin/bash <<'CHROOT_EOF'
|
||||
a2dissite 000-default 2>/dev/null || true
|
||||
a2ensite roundcube
|
||||
a2enmod php8.2 rewrite 2>/dev/null || true
|
||||
CHROOT_EOF
|
||||
|
||||
# Point Roundcube at the local Dovecot + Postfix
|
||||
if [ -f "$rootfs/etc/roundcube/config.inc.php" ]; then
|
||||
sed -i \
|
||||
-e "s|^\$config\['default_host'\].*|\$config['default_host'] = 'tls://localhost';|" \
|
||||
-e "s|^\$config\['smtp_server'\].*|\$config['smtp_server'] = 'tls://localhost';|" \
|
||||
"$rootfs/etc/roundcube/config.inc.php" || true
|
||||
fi
|
||||
|
||||
echo "[install] Roundcube (Apache) configured"
|
||||
}
|
||||
83
packages/secubox-mail/lib/lxc.sh
Normal file
83
packages/secubox-mail/lib/lxc.sh
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
#!/usr/bin/env 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.
|
||||
|
||||
# SecuBox-Deb :: mail :: LXC lifecycle helpers (unprivileged veth on br-lxc).
|
||||
# Sourced library — do not execute directly.
|
||||
|
||||
# Returns 0 if a container's rootfs exists under $LXC_BASE.
|
||||
lxc_exists() {
|
||||
local name="$1"
|
||||
[ -d "${LXC_BASE:-/var/lib/lxc}/$name/rootfs" ]
|
||||
}
|
||||
|
||||
# Returns 0 if a container is currently running.
|
||||
lxc_running() {
|
||||
local name="$1"
|
||||
lxc-info -n "$name" 2>/dev/null | grep -q "State:.*RUNNING"
|
||||
}
|
||||
|
||||
# Render lxc.config for the named container at the given IP.
|
||||
# Args: name, ipv4 (e.g. "10.100.0.10" or "10.100.0.10/24"),
|
||||
# bridge (default "br-lxc"), gateway (default "10.100.0.1").
|
||||
# Writes "$LXC_BASE/$name/config".
|
||||
lxc_create_config() {
|
||||
local name="$1"
|
||||
local ip="$2"
|
||||
local bridge="${3:-br-lxc}"
|
||||
local gw="${4:-10.100.0.1}"
|
||||
local base="${LXC_BASE:-/var/lib/lxc}"
|
||||
local data="${DATA_PATH:-/data/volumes/mail}"
|
||||
local ip_cidr
|
||||
case "$ip" in
|
||||
*/*) ip_cidr="$ip" ;;
|
||||
*) ip_cidr="$ip/24" ;;
|
||||
esac
|
||||
mkdir -p "$base/$name"
|
||||
cat > "$base/$name/config" <<EOF
|
||||
# Generated by mailctl — do not edit by hand
|
||||
lxc.include = /usr/share/lxc/config/debian.common.conf
|
||||
|
||||
lxc.arch = linux64
|
||||
lxc.uts.name = $name
|
||||
lxc.rootfs.path = dir:$base/$name/rootfs
|
||||
|
||||
lxc.net.0.type = veth
|
||||
lxc.net.0.link = $bridge
|
||||
lxc.net.0.flags = up
|
||||
lxc.net.0.ipv4.address = $ip_cidr
|
||||
lxc.net.0.ipv4.gateway = $gw
|
||||
lxc.net.0.name = eth0
|
||||
|
||||
lxc.idmap = u 0 100000 65536
|
||||
lxc.idmap = g 0 100000 65536
|
||||
|
||||
# Bind mounts for persistent data
|
||||
lxc.mount.entry = $data/vmail var/vmail none bind,create=dir 0 0
|
||||
lxc.mount.entry = $data/config etc/mail-config none bind,create=dir 0 0
|
||||
lxc.mount.entry = $data/ssl etc/ssl/mail none bind,create=dir 0 0
|
||||
|
||||
lxc.cgroup2.memory.max = 1G
|
||||
lxc.start.auto = 1
|
||||
EOF
|
||||
}
|
||||
|
||||
# Start a container and wait until it reports RUNNING (max 10s).
|
||||
lxc_start_safely() {
|
||||
local name="$1"
|
||||
lxc-start -n "$name" -d
|
||||
local i
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
lxc_running "$name" && return 0
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Run a command inside a container; propagate exit status.
|
||||
lxc_attach_run() {
|
||||
local name="$1"; shift
|
||||
lxc-attach -n "$name" -- "$@"
|
||||
}
|
||||
56
packages/secubox-mail/lib/migrate.sh
Normal file
56
packages/secubox-mail/lib/migrate.sh
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
#!/usr/bin/env 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.
|
||||
|
||||
# SecuBox-Deb :: mail :: Phase 1 defensive migration detectors.
|
||||
# Sourced library — do not execute directly.
|
||||
|
||||
# Detect legacy two-LXC layout under $LXC_BASE. Echoes any legacy
|
||||
# container names found, one per line. Returns 0 if any, 1 if none.
|
||||
detect_legacy_lxc() {
|
||||
local base="${LXC_BASE:-/var/lib/lxc}"
|
||||
local found=0
|
||||
local c
|
||||
for c in mailserver roundcube; do
|
||||
if [ -d "$base/$c/rootfs" ]; then
|
||||
echo "$c"
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
[ "$found" -eq 1 ]
|
||||
}
|
||||
|
||||
# Refuse to touch the data dir if /vmail has any nested content. This
|
||||
# enforces spec rev. 2 invariant I13 (existing mail data must be preserved).
|
||||
# Returns 0 if safe to clobber; non-zero with explanation otherwise.
|
||||
guard_data_path() {
|
||||
local data="${DATA_PATH:-/data/volumes/mail}"
|
||||
if [ ! -d "$data/vmail" ]; then
|
||||
return 0
|
||||
fi
|
||||
local count
|
||||
count=$(find "$data/vmail" -mindepth 2 -print -quit 2>/dev/null | wc -l)
|
||||
if [ "$count" -gt 0 ]; then
|
||||
echo "ERROR: $data/vmail already has data — refusing to touch (invariant I13)" >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Echo any legacy keys present in a mail.toml file (one per line).
|
||||
# Returns 0 if any legacy key is present, 1 if the file is already migrated.
|
||||
detect_legacy_toml_keys() {
|
||||
local toml="$1"
|
||||
[ -f "$toml" ] || return 1
|
||||
local found=0
|
||||
local k
|
||||
for k in mail_container webmail_container mail_ip webmail_ip webmail_port; do
|
||||
if grep -q "^${k} *=" "$toml" 2>/dev/null; then
|
||||
echo "$k"
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
[ "$found" -eq 1 ]
|
||||
}
|
||||
67
packages/secubox-mail/sbin/mail-migrate-to-single-lxc.sh
Executable file
67
packages/secubox-mail/sbin/mail-migrate-to-single-lxc.sh
Executable file
|
|
@ -0,0 +1,67 @@
|
|||
#!/usr/bin/env 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.
|
||||
#
|
||||
# Phase 1 rev. 2 migration scanner. Defensive — does NOT create or destroy
|
||||
# anything by default. Reports legacy state and exits 0 even if legacy
|
||||
# fragments are found, so debian/postinst can call us without aborting
|
||||
# the upgrade. Per spec invariant I13, existing /data/volumes/mail/vmail
|
||||
# data is sacrosanct and never touched.
|
||||
#
|
||||
# Invoked from secubox-mail debian/postinst on upgrade from < 2.2.
|
||||
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
LIB_DIR="${LIB_DIR:-/usr/lib/secubox/mail/lib}"
|
||||
[ -d "$LIB_DIR" ] || LIB_DIR="$SCRIPT_DIR/../lib"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$LIB_DIR/lxc.sh"
|
||||
# shellcheck source=/dev/null
|
||||
source "$LIB_DIR/migrate.sh"
|
||||
|
||||
log() { echo "[mail-migrate] $*"; }
|
||||
warn() { echo "[mail-migrate][WARN] $*" >&2; }
|
||||
|
||||
main() {
|
||||
[ "$(id -u)" -eq 0 ] || { echo "must run as root" >&2; exit 1; }
|
||||
|
||||
: "${LXC_BASE:=/var/lib/lxc}"
|
||||
: "${DATA_PATH:=/data/volumes/mail}"
|
||||
|
||||
log "Phase 1 scan starting (LXC_BASE=$LXC_BASE, DATA_PATH=$DATA_PATH)"
|
||||
|
||||
# 1. Legacy LXCs (rev. 1 two-LXC layout). Not expected on this board.
|
||||
if legacy=$(detect_legacy_lxc 2>/dev/null); then
|
||||
warn "legacy LXC dirs present:"
|
||||
echo "$legacy" | sed 's/^/[mail-migrate][WARN] - /' >&2
|
||||
warn "these are NOT removed automatically. To archive:"
|
||||
warn " cd $LXC_BASE && tar -czf /tmp/legacy-mail-lxc-\$(date +%s).tar.gz mailserver roundcube"
|
||||
else
|
||||
log "no legacy mailserver/roundcube LXC dirs — clean"
|
||||
fi
|
||||
|
||||
# 2. Data path — enforce invariant I13.
|
||||
if [ -d "$DATA_PATH/vmail" ]; then
|
||||
if guard_data_path 2>/dev/null; then
|
||||
log "data path $DATA_PATH/vmail is empty — fresh start ok"
|
||||
else
|
||||
log "data path $DATA_PATH/vmail has user data — preserving (per spec I13)"
|
||||
fi
|
||||
else
|
||||
log "data path $DATA_PATH/vmail does not exist yet"
|
||||
fi
|
||||
|
||||
# 3. mail LXC presence.
|
||||
if lxc_exists "mail"; then
|
||||
log "mail LXC present at $LXC_BASE/mail"
|
||||
else
|
||||
warn "mail LXC not yet installed — run 'mailctl install' after this scanner"
|
||||
fi
|
||||
|
||||
log "scan complete — no destructive actions taken"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
|
@ -3,11 +3,11 @@
|
|||
# Unified mail + webmail management for Debian
|
||||
# Three-fold architecture: Components, Status, Access
|
||||
|
||||
VERSION="1.3.0"
|
||||
VERSION="2.2.0"
|
||||
CONFIG_FILE="/etc/secubox/mail.toml"
|
||||
LIB_DIR="/usr/lib/secubox/mail/lib"
|
||||
DATA_PATH="/srv/mail"
|
||||
LXC_PATH="/srv/lxc"
|
||||
DATA_PATH="/data/volumes/mail"
|
||||
LXC_PATH="/var/lib/lxc"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
|
|
@ -41,10 +41,12 @@ config_get() {
|
|||
|
||||
DOMAIN=$(config_get "domain" "secubox.local")
|
||||
HOSTNAME=$(config_get "hostname" "mail")
|
||||
MAIL_CONTAINER=$(config_get "mail_container" "mailserver")
|
||||
WEBMAIL_CONTAINER=$(config_get "webmail_container" "roundcube")
|
||||
MAIL_IP=$(config_get "mail_ip" "192.168.255.30")
|
||||
WEBMAIL_PORT=$(config_get "webmail_port" "8027")
|
||||
CONTAINER=$(config_get "container" "mail")
|
||||
WEBMAIL_CONTAINER="$CONTAINER" # legacy alias — webmail merged into single container
|
||||
LXC_IP=$(config_get "lxc_ip" "10.100.0.10")
|
||||
LXC_BRIDGE=$(config_get "lxc_bridge" "br-lxc")
|
||||
LXC_GATEWAY=$(config_get "lxc_gateway" "10.100.0.1")
|
||||
WEBMAIL_PORT=80 # Roundcube now on standard HTTP inside the LXC, proxied via host nginx :443
|
||||
|
||||
# ============================================================================
|
||||
# LXC Helpers
|
||||
|
|
@ -77,8 +79,8 @@ cmd_components() {
|
|||
local webmail_installed=false
|
||||
local webmail_running=false
|
||||
|
||||
lxc_exists "$MAIL_CONTAINER" && mail_installed=true
|
||||
lxc_running "$MAIL_CONTAINER" && mail_running=true
|
||||
lxc_exists "$CONTAINER" && mail_installed=true
|
||||
lxc_running "$CONTAINER" && mail_running=true
|
||||
lxc_exists "$WEBMAIL_CONTAINER" && webmail_installed=true
|
||||
lxc_running "$WEBMAIL_CONTAINER" && webmail_running=true
|
||||
|
||||
|
|
@ -88,12 +90,12 @@ cmd_components() {
|
|||
{
|
||||
"name": "Mail Server",
|
||||
"type": "lxc",
|
||||
"container": "$MAIL_CONTAINER",
|
||||
"container": "$CONTAINER",
|
||||
"description": "Postfix + Dovecot mail server",
|
||||
"installed": $mail_installed,
|
||||
"running": $mail_running,
|
||||
"ports": [25, 587, 465, 143, 993, 110, 995],
|
||||
"ip": "$MAIL_IP"
|
||||
"ip": "$LXC_IP"
|
||||
},
|
||||
{
|
||||
"name": "Webmail",
|
||||
|
|
@ -209,13 +211,13 @@ cmd_user_repair() {
|
|||
local email="$1"
|
||||
[ -z "$email" ] && { echo "Usage: mailctl user-repair <email>"; return 1; }
|
||||
|
||||
if ! lxc_running "$MAIL_CONTAINER"; then
|
||||
if ! lxc_running "$CONTAINER"; then
|
||||
error "Mail container not running"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Repairing mailbox for $email..."
|
||||
lxc_attach "$MAIL_CONTAINER" doveadm force-resync -u "$email" '*'
|
||||
lxc_attach "$CONTAINER" doveadm force-resync -u "$email" '*'
|
||||
log "Mailbox repair complete"
|
||||
}
|
||||
|
||||
|
|
@ -224,13 +226,13 @@ cmd_user_repair() {
|
|||
# ============================================================================
|
||||
|
||||
cmd_fix_ports() {
|
||||
if ! lxc_running "$MAIL_CONTAINER"; then
|
||||
if ! lxc_running "$CONTAINER"; then
|
||||
error "Mail container not running"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Checking mail ports..."
|
||||
local ports=$(lxc_attach "$MAIL_CONTAINER" netstat -tln 2>/dev/null)
|
||||
local ports=$(lxc_attach "$CONTAINER" netstat -tln 2>/dev/null)
|
||||
local all_ok=true
|
||||
|
||||
for port in 25 587 465 143 993; do
|
||||
|
|
@ -244,7 +246,7 @@ cmd_fix_ports() {
|
|||
|
||||
if [ "$all_ok" = "false" ]; then
|
||||
warn "Some ports are not listening. Attempting restart..."
|
||||
lxc_attach "$MAIL_CONTAINER" /opt/start-mail.sh &
|
||||
lxc_attach "$CONTAINER" /opt/start-mail.sh &
|
||||
sleep 3
|
||||
log "Services restarted"
|
||||
else
|
||||
|
|
@ -288,9 +290,9 @@ cmd_uninstall() {
|
|||
systemctl stop secubox-mail 2>/dev/null
|
||||
|
||||
log "Removing containers..."
|
||||
lxc-stop -n "$MAIL_CONTAINER" 2>/dev/null
|
||||
lxc-stop -n "$CONTAINER" 2>/dev/null
|
||||
lxc-stop -n "$WEBMAIL_CONTAINER" 2>/dev/null
|
||||
rm -rf "$LXC_PATH/$MAIL_CONTAINER"
|
||||
rm -rf "$LXC_PATH/$CONTAINER"
|
||||
rm -rf "$LXC_PATH/$WEBMAIL_CONTAINER"
|
||||
|
||||
log "Mail server removed. Data preserved in $DATA_PATH"
|
||||
|
|
@ -330,15 +332,15 @@ cmd_status() {
|
|||
echo "Configuration:"
|
||||
echo " Domain: $DOMAIN"
|
||||
echo " Hostname: $HOSTNAME.$DOMAIN"
|
||||
echo " Mail IP: $MAIL_IP"
|
||||
echo " Mail IP: $LXC_IP"
|
||||
echo ""
|
||||
|
||||
# Mail server status
|
||||
echo "Mail Server ($MAIL_CONTAINER):"
|
||||
if lxc_running "$MAIL_CONTAINER"; then
|
||||
echo "Mail Server ($CONTAINER):"
|
||||
if lxc_running "$CONTAINER"; then
|
||||
echo -e " Status: ${GREEN}Running${NC}"
|
||||
# Check ports
|
||||
local ports=$(lxc_attach "$MAIL_CONTAINER" netstat -tln 2>/dev/null)
|
||||
local ports=$(lxc_attach "$CONTAINER" netstat -tln 2>/dev/null)
|
||||
for port in 25 587 465 993 995; do
|
||||
if echo "$ports" | grep -q ":$port "; then
|
||||
echo -e " Port $port: ${GREEN}listening${NC}"
|
||||
|
|
@ -346,7 +348,7 @@ cmd_status() {
|
|||
echo -e " Port $port: ${RED}closed${NC}"
|
||||
fi
|
||||
done
|
||||
elif lxc_exists "$MAIL_CONTAINER"; then
|
||||
elif lxc_exists "$CONTAINER"; then
|
||||
echo -e " Status: ${YELLOW}Stopped${NC}"
|
||||
else
|
||||
echo -e " Status: ${RED}Not installed${NC}"
|
||||
|
|
@ -655,8 +657,8 @@ cmd_dkim() {
|
|||
cmd_logs() {
|
||||
local lines="${1:-50}"
|
||||
|
||||
if lxc_running "$MAIL_CONTAINER"; then
|
||||
lxc_attach "$MAIL_CONTAINER" tail -n "$lines" /var/log/mail.log 2>/dev/null
|
||||
if lxc_running "$CONTAINER"; then
|
||||
lxc_attach "$CONTAINER" tail -n "$lines" /var/log/mail.log 2>/dev/null
|
||||
else
|
||||
error "Mail container not running"
|
||||
fi
|
||||
|
|
@ -724,6 +726,57 @@ Examples:
|
|||
EOF
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Phase 1 config migration (rev. 2)
|
||||
# ============================================================================
|
||||
|
||||
cmd_migrate_config() {
|
||||
[ "$(id -u)" -eq 0 ] || { error "Root required"; return 1; }
|
||||
: "${LIB_DIR:=/usr/lib/secubox/mail/lib}"
|
||||
[ -d "$LIB_DIR" ] || LIB_DIR="$(dirname "$0")/../lib"
|
||||
# shellcheck source=/dev/null
|
||||
source "$LIB_DIR/migrate.sh"
|
||||
|
||||
local cfg="${CONFIG_FILE:-/etc/secubox/mail.toml}"
|
||||
[ -f "$cfg" ] || { warn "no config to migrate at $cfg"; return 0; }
|
||||
|
||||
if grep -q "^container *=" "$cfg" 2>/dev/null; then
|
||||
log "config already migrated (has 'container =' key)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! detect_legacy_toml_keys "$cfg" >/dev/null; then
|
||||
log "no legacy keys to migrate"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local backup="${cfg}.pre-phase1.$(date +%s).bak"
|
||||
cp "$cfg" "$backup"
|
||||
log "backup written to $backup"
|
||||
|
||||
python3 - "$cfg" <<'PY'
|
||||
import sys, re
|
||||
path = sys.argv[1]
|
||||
src = open(path).read()
|
||||
inject = (
|
||||
'container = "mail"\n'
|
||||
'lxc_ip = "10.100.0.10"\n'
|
||||
'lxc_bridge = "br-lxc"\n'
|
||||
'lxc_gateway = "10.100.0.1"\n'
|
||||
'data_path = "/data/volumes/mail"\n'
|
||||
'lxc_path = "/var/lib/lxc"\n'
|
||||
)
|
||||
# Comment out legacy keys FIRST so we don't end up with duplicates of
|
||||
# data_path / lxc_path after injection.
|
||||
for k in ("mail_container", "webmail_container", "mail_ip", "webmail_ip",
|
||||
"webmail_port", "data_path", "lxc_path"):
|
||||
src = re.sub(rf"^({k} *=.*)$", r"# DEPRECATED Phase 1: \1", src, flags=re.MULTILINE)
|
||||
src = re.sub(r"(\[mail\]\n)", r"\1" + inject, src, count=1)
|
||||
open(path, "w").write(src)
|
||||
PY
|
||||
log "config migrated to single-container schema"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main
|
||||
# ============================================================================
|
||||
|
|
@ -737,6 +790,7 @@ case "${1:-}" in
|
|||
install) shift; cmd_install "$@" ;;
|
||||
uninstall) shift; cmd_uninstall "$@" ;;
|
||||
migrate) shift; cmd_migrate "$@" ;;
|
||||
migrate-config) shift; cmd_migrate_config "$@" ;;
|
||||
# Service control
|
||||
start) shift; cmd_start "$@" ;;
|
||||
stop) shift; cmd_stop "$@" ;;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,395 +1,10 @@
|
|||
#!/bin/bash
|
||||
# SecuBox Roundcube Webmail Controller
|
||||
# LXC container management for Roundcube (Debian bookworm)
|
||||
|
||||
VERSION="1.4.0"
|
||||
CONFIG_FILE="/etc/secubox/mail.toml"
|
||||
CONTAINER="roundcube"
|
||||
LXC_BASE="/srv/lxc"
|
||||
LXC_PATH="$LXC_BASE/roundcube"
|
||||
DATA_PATH="/srv/mail"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[ROUNDCUBE]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
|
||||
|
||||
config_get() {
|
||||
local key="$1" default="$2"
|
||||
[ -f "$CONFIG_FILE" ] && grep "^${key} *=" "$CONFIG_FILE" 2>/dev/null | head -1 | cut -d= -f2- | tr -d ' "' || echo "$default"
|
||||
}
|
||||
|
||||
DOMAIN=$(config_get "domain" "secubox.local")
|
||||
MAIL_HOST=$(config_get "hostname" "mail")
|
||||
WEBMAIL_PORT=$(config_get "webmail_port" "8027")
|
||||
|
||||
require_root() {
|
||||
[ "$(id -u)" -eq 0 ] || { error "Root required"; exit 1; }
|
||||
}
|
||||
|
||||
lxc_running() { lxc-info -n "$CONTAINER" -P "$LXC_BASE" 2>/dev/null | grep -q "State:.*RUNNING"; }
|
||||
lxc_exists() { [ -d "$LXC_PATH/rootfs" ]; }
|
||||
|
||||
# ============================================================================
|
||||
# LXC Configuration
|
||||
# ============================================================================
|
||||
|
||||
create_lxc_config() {
|
||||
mkdir -p "$LXC_PATH"
|
||||
cat > "$LXC_PATH/config" << EOF
|
||||
lxc.uts.name = roundcube
|
||||
lxc.rootfs.path = dir:${LXC_PATH}/rootfs
|
||||
lxc.net.0.type = none
|
||||
lxc.mount.auto = proc:mixed sys:ro cgroup:mixed
|
||||
lxc.cap.drop = sys_module mac_admin mac_override sys_time
|
||||
lxc.tty.max = 4
|
||||
lxc.pty.max = 256
|
||||
lxc.cgroup2.memory.max = 512M
|
||||
lxc.init.cmd = /opt/start-roundcube.sh
|
||||
EOF
|
||||
}
|
||||
|
||||
create_startup_script() {
|
||||
mkdir -p "$LXC_PATH/rootfs/opt"
|
||||
cat > "$LXC_PATH/rootfs/opt/start-roundcube.sh" << 'EOF'
|
||||
#!/bin/bash
|
||||
# Roundcube startup (Debian)
|
||||
|
||||
# Start PHP-FPM
|
||||
service php8.2-fpm start
|
||||
|
||||
# Start nginx
|
||||
service nginx start
|
||||
|
||||
echo "Roundcube started on port 8027"
|
||||
|
||||
# Keep container running
|
||||
exec tail -f /var/log/nginx/error.log 2>/dev/null || exec sleep infinity
|
||||
EOF
|
||||
chmod +x "$LXC_PATH/rootfs/opt/start-roundcube.sh"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Debian Bootstrap
|
||||
# ============================================================================
|
||||
|
||||
bootstrap_debian() {
|
||||
require_root
|
||||
log "Bootstrapping Debian bookworm for Roundcube..."
|
||||
|
||||
mkdir -p "$LXC_PATH"
|
||||
|
||||
if ! command -v debootstrap &>/dev/null; then
|
||||
error "debootstrap not installed. Run: apt install debootstrap"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Running debootstrap (this takes a few minutes)..."
|
||||
debootstrap --variant=minbase bookworm "$LXC_PATH/rootfs" http://deb.debian.org/debian
|
||||
|
||||
# Configure basic system
|
||||
echo "roundcube" > "$LXC_PATH/rootfs/etc/hostname"
|
||||
|
||||
cat > "$LXC_PATH/rootfs/etc/resolv.conf" << 'EOF'
|
||||
nameserver 8.8.8.8
|
||||
nameserver 1.1.1.1
|
||||
EOF
|
||||
|
||||
log "Debian base system installed"
|
||||
}
|
||||
|
||||
install_roundcube_packages() {
|
||||
require_root
|
||||
local rootfs="$LXC_PATH/rootfs"
|
||||
|
||||
log "Installing Roundcube packages..."
|
||||
|
||||
# Install via chroot since container may not be running yet
|
||||
chroot "$rootfs" /bin/bash << 'CHROOT_EOF'
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Update and install packages
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
nginx \
|
||||
php8.2-fpm php8.2-imap php8.2-ldap php8.2-curl \
|
||||
php8.2-xml php8.2-mbstring php8.2-intl php8.2-sqlite3 \
|
||||
php8.2-zip php8.2-gd \
|
||||
ca-certificates curl unzip
|
||||
|
||||
# Download Roundcube
|
||||
cd /tmp
|
||||
curl -L -o roundcube.tar.gz https://github.com/roundcube/roundcubemail/releases/download/1.6.6/roundcubemail-1.6.6-complete.tar.gz
|
||||
mkdir -p /var/www/roundcube
|
||||
tar -xzf roundcube.tar.gz -C /var/www/roundcube --strip-components=1
|
||||
chown -R www-data:www-data /var/www/roundcube
|
||||
rm -f roundcube.tar.gz
|
||||
|
||||
# Initialize SQLite database
|
||||
mkdir -p /var/www/roundcube/db
|
||||
mkdir -p /var/www/roundcube/temp
|
||||
mkdir -p /var/www/roundcube/logs
|
||||
chown -R www-data:www-data /var/www/roundcube
|
||||
|
||||
# Clean up
|
||||
apt-get clean
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
CHROOT_EOF
|
||||
|
||||
log "Packages installed"
|
||||
}
|
||||
|
||||
configure_roundcube() {
|
||||
local rootfs="$LXC_PATH/rootfs"
|
||||
log "Configuring Roundcube..."
|
||||
|
||||
# Nginx config - use specific port for host networking
|
||||
mkdir -p "$rootfs/etc/nginx/sites-available"
|
||||
mkdir -p "$rootfs/etc/nginx/sites-enabled"
|
||||
|
||||
cat > "$rootfs/etc/nginx/sites-available/roundcube" << EOF
|
||||
server {
|
||||
listen ${WEBMAIL_PORT};
|
||||
server_name webmail.$DOMAIN localhost;
|
||||
root /var/www/roundcube;
|
||||
index index.php;
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.php?\$args;
|
||||
}
|
||||
|
||||
location ~ \.php\$ {
|
||||
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Enable site
|
||||
rm -f "$rootfs/etc/nginx/sites-enabled/default"
|
||||
ln -sf /etc/nginx/sites-available/roundcube "$rootfs/etc/nginx/sites-enabled/roundcube"
|
||||
|
||||
# Roundcube config - use localhost since container shares host network
|
||||
cat > "$rootfs/var/www/roundcube/config/config.inc.php" << EOF
|
||||
<?php
|
||||
\$config['db_dsnw'] = 'sqlite:////var/www/roundcube/db/sqlite.db';
|
||||
\$config['imap_host'] = 'localhost:143';
|
||||
\$config['smtp_host'] = 'localhost:587';
|
||||
\$config['smtp_user'] = '%u';
|
||||
\$config['smtp_pass'] = '%p';
|
||||
\$config['product_name'] = 'SecuBox Webmail';
|
||||
\$config['des_key'] = '$(openssl rand -base64 24)';
|
||||
\$config['plugins'] = ['archive', 'zipdownload'];
|
||||
\$config['language'] = 'fr_FR';
|
||||
\$config['skin'] = 'elastic';
|
||||
\$config['smtp_conn_options'] = ['ssl' => ['verify_peer' => false, 'verify_peer_name' => false]];
|
||||
\$config['imap_conn_options'] = ['ssl' => ['verify_peer' => false, 'verify_peer_name' => false]];
|
||||
EOF
|
||||
|
||||
log "Roundcube configured"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Commands
|
||||
# ============================================================================
|
||||
|
||||
cmd_install() {
|
||||
require_root
|
||||
log "Installing Roundcube Webmail LXC (Debian bookworm)..."
|
||||
|
||||
if ! lxc_exists; then
|
||||
bootstrap_debian
|
||||
fi
|
||||
|
||||
install_roundcube_packages
|
||||
configure_roundcube
|
||||
create_lxc_config
|
||||
create_startup_script
|
||||
|
||||
log "Roundcube installed!"
|
||||
}
|
||||
|
||||
cmd_start() {
|
||||
require_root
|
||||
|
||||
if lxc_running; then
|
||||
log "Roundcube already running"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! lxc_exists; then
|
||||
error "Roundcube not installed. Run 'roundcubectl install' first"
|
||||
return 1
|
||||
fi
|
||||
|
||||
create_lxc_config
|
||||
|
||||
log "Starting Roundcube..."
|
||||
lxc-start -n "$CONTAINER" -P "$LXC_BASE" -d
|
||||
sleep 3
|
||||
|
||||
if lxc_running; then
|
||||
log "Roundcube started at http://localhost:$WEBMAIL_PORT"
|
||||
else
|
||||
error "Failed to start"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
require_root
|
||||
|
||||
if ! lxc_running; then
|
||||
log "Roundcube is not running"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Stopping Roundcube..."
|
||||
lxc-stop -n "$CONTAINER" -P "$LXC_BASE"
|
||||
log "Stopped"
|
||||
}
|
||||
|
||||
cmd_restart() {
|
||||
cmd_stop
|
||||
sleep 2
|
||||
cmd_start
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
echo ""
|
||||
echo "Roundcube Webmail LXC v$VERSION"
|
||||
echo "================================"
|
||||
echo ""
|
||||
echo "Container: $CONTAINER"
|
||||
echo "Base OS: Debian bookworm"
|
||||
|
||||
if lxc_running; then
|
||||
echo -e "Status: ${GREEN}Running${NC}"
|
||||
echo "Port: $WEBMAIL_PORT"
|
||||
echo "URL: http://localhost:$WEBMAIL_PORT"
|
||||
elif lxc_exists; then
|
||||
echo -e "Status: ${YELLOW}Stopped${NC}"
|
||||
else
|
||||
echo -e "Status: ${RED}Not installed${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_shell() {
|
||||
if ! lxc_running; then
|
||||
error "Container not running"
|
||||
return 1
|
||||
fi
|
||||
lxc-attach -n "$CONTAINER" -P "$LXC_BASE" -- /bin/bash
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# THREE-FOLD ARCHITECTURE: Components (What)
|
||||
# ============================================================================
|
||||
|
||||
cmd_components() {
|
||||
local installed=false
|
||||
local running=false
|
||||
lxc_exists && installed=true
|
||||
lxc_running && running=true
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"components": [
|
||||
{
|
||||
"name": "Roundcube Webmail",
|
||||
"type": "lxc",
|
||||
"container": "$CONTAINER",
|
||||
"description": "Webmail interface for mail server",
|
||||
"os": "debian-bookworm",
|
||||
"installed": $installed,
|
||||
"running": $running,
|
||||
"port": $WEBMAIL_PORT
|
||||
},
|
||||
{
|
||||
"name": "Nginx",
|
||||
"type": "service",
|
||||
"description": "Web server for Roundcube",
|
||||
"parent": "$CONTAINER"
|
||||
},
|
||||
{
|
||||
"name": "PHP-FPM",
|
||||
"type": "service",
|
||||
"description": "PHP 8.2 processor for Roundcube",
|
||||
"parent": "$CONTAINER"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# THREE-FOLD ARCHITECTURE: Access (How to connect)
|
||||
# ============================================================================
|
||||
|
||||
cmd_access() {
|
||||
local running=false
|
||||
lxc_running && running=true
|
||||
|
||||
cat <<EOF
|
||||
{
|
||||
"webmail": {
|
||||
"url": "https://webmail.$DOMAIN",
|
||||
"local_url": "http://localhost:$WEBMAIL_PORT",
|
||||
"running": $running
|
||||
},
|
||||
"mail_server": {
|
||||
"imap": "localhost:143",
|
||||
"smtp": "localhost:587"
|
||||
},
|
||||
"admin_panel": "/mail/"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
show_help() {
|
||||
cat << EOF
|
||||
Roundcube Webmail LXC Controller v$VERSION
|
||||
Debian bookworm based container
|
||||
Three-fold architecture: Components, Status, Access
|
||||
|
||||
Usage: roundcubectl <command>
|
||||
|
||||
Information (Three-fold):
|
||||
components List system components (JSON)
|
||||
status Show status
|
||||
access Show connection URLs (JSON)
|
||||
|
||||
Service:
|
||||
install Install LXC container (Debian)
|
||||
start Start Roundcube
|
||||
stop Stop Roundcube
|
||||
restart Restart
|
||||
shell Open shell in container
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
# Three-fold
|
||||
components) cmd_components ;;
|
||||
access) cmd_access ;;
|
||||
# Service
|
||||
install) shift; cmd_install "$@" ;;
|
||||
start) shift; cmd_start "$@" ;;
|
||||
stop) shift; cmd_stop "$@" ;;
|
||||
restart) shift; cmd_restart "$@" ;;
|
||||
status) shift; cmd_status "$@" ;;
|
||||
shell) shift; cmd_shell "$@" ;;
|
||||
help|--help|-h|'') show_help ;;
|
||||
*) error "Unknown: $1"; exit 1 ;;
|
||||
esac
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
#
|
||||
# DEPRECATED in secubox-mail 2.2 — Roundcube now lives inside the single
|
||||
# 'mail' LXC. This shim forwards to mailctl. Will be removed in 3.0.
|
||||
|
||||
set -euo pipefail
|
||||
echo "[roundcubectl] DEPRECATED — forwarding to mailctl (will be removed in 3.0)" >&2
|
||||
exec /usr/sbin/mailctl "$@"
|
||||
|
|
|
|||
18
packages/secubox-mail/tests/helpers.bash
Normal file
18
packages/secubox-mail/tests/helpers.bash
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# SecuBox-Deb :: mail :: bats shared fixtures (Phase 1).
|
||||
# Sourced by tests/*.bats via `load helpers`.
|
||||
|
||||
load_libs() {
|
||||
local pkg_root="${BATS_TEST_DIRNAME}/.."
|
||||
# shellcheck source=/dev/null
|
||||
source "${pkg_root}/lib/lxc.sh"
|
||||
# shellcheck source=/dev/null
|
||||
source "${pkg_root}/lib/install.sh"
|
||||
# shellcheck source=/dev/null
|
||||
source "${pkg_root}/lib/migrate.sh"
|
||||
}
|
||||
|
||||
make_fake_lxc_env() {
|
||||
export LXC_BASE="$BATS_TEST_TMPDIR/lxc"
|
||||
export DATA_PATH="$BATS_TEST_TMPDIR/data-volumes-mail"
|
||||
mkdir -p "$LXC_BASE" "$DATA_PATH"
|
||||
}
|
||||
21
packages/secubox-mail/tests/test_install_lib.bats
Normal file
21
packages/secubox-mail/tests/test_install_lib.bats
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#!/usr/bin/env bats
|
||||
load helpers
|
||||
|
||||
setup() { load_libs; make_fake_lxc_env; }
|
||||
|
||||
@test "install.sh sources cleanly" {
|
||||
[ "$(type -t bootstrap_debian)" = "function" ]
|
||||
[ "$(type -t install_mail_packages)" = "function" ]
|
||||
[ "$(type -t install_webmail_packages)" = "function" ]
|
||||
[ "$(type -t configure_postfix)" = "function" ]
|
||||
[ "$(type -t configure_dovecot)" = "function" ]
|
||||
[ "$(type -t configure_roundcube)" = "function" ]
|
||||
}
|
||||
|
||||
@test "bootstrap_debian refuses to run if debootstrap missing" {
|
||||
local fake_path="$BATS_TEST_TMPDIR/path"
|
||||
mkdir -p "$fake_path"
|
||||
PATH="$fake_path" run bootstrap_debian "$LXC_BASE/mail"
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"debootstrap"* ]]
|
||||
}
|
||||
41
packages/secubox-mail/tests/test_lxc_lib.bats
Normal file
41
packages/secubox-mail/tests/test_lxc_lib.bats
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env bats
|
||||
load helpers
|
||||
|
||||
setup() { load_libs; make_fake_lxc_env; }
|
||||
|
||||
@test "lxc.sh sources cleanly" {
|
||||
[ "$(type -t lxc_exists)" = "function" ]
|
||||
[ "$(type -t lxc_running)" = "function" ]
|
||||
[ "$(type -t lxc_create_config)" = "function" ]
|
||||
[ "$(type -t lxc_start_safely)" = "function" ]
|
||||
[ "$(type -t lxc_attach_run)" = "function" ]
|
||||
}
|
||||
|
||||
@test "lxc_exists returns 1 for missing container" {
|
||||
run lxc_exists "ghost-mail"
|
||||
[ "$status" -eq 1 ]
|
||||
}
|
||||
|
||||
@test "lxc_exists returns 0 when rootfs exists" {
|
||||
mkdir -p "$LXC_BASE/mail/rootfs"
|
||||
run lxc_exists "mail"
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "lxc_create_config writes a config with veth + 10.100.0.10 + br-lxc + unprivileged" {
|
||||
lxc_create_config "mail" "10.100.0.10" "br-lxc" "10.100.0.1"
|
||||
[ -f "$LXC_BASE/mail/config" ]
|
||||
grep -q "lxc.uts.name = mail" "$LXC_BASE/mail/config"
|
||||
grep -q "lxc.rootfs.path = dir:$LXC_BASE/mail/rootfs" "$LXC_BASE/mail/config"
|
||||
grep -q "lxc.net.0.type = veth" "$LXC_BASE/mail/config"
|
||||
grep -q "lxc.net.0.link = br-lxc" "$LXC_BASE/mail/config"
|
||||
grep -q "lxc.net.0.ipv4.address = 10.100.0.10/24" "$LXC_BASE/mail/config"
|
||||
grep -q "lxc.net.0.ipv4.gateway = 10.100.0.1" "$LXC_BASE/mail/config"
|
||||
grep -q "lxc.idmap = u 0 100000 65536" "$LXC_BASE/mail/config"
|
||||
grep -qE "vmail[[:space:]]+var/vmail[[:space:]]+none[[:space:]]+bind" "$LXC_BASE/mail/config"
|
||||
}
|
||||
|
||||
@test "lxc_create_config accepts plain IP (no /CIDR) and defaults to /24" {
|
||||
lxc_create_config "mail" "10.100.0.10"
|
||||
grep -q "lxc.net.0.ipv4.address = 10.100.0.10/24" "$LXC_BASE/mail/config"
|
||||
}
|
||||
73
packages/secubox-mail/tests/test_migrate_lib.bats
Normal file
73
packages/secubox-mail/tests/test_migrate_lib.bats
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
#!/usr/bin/env bats
|
||||
load helpers
|
||||
|
||||
setup() { load_libs; make_fake_lxc_env; }
|
||||
|
||||
@test "migrate.sh sources cleanly" {
|
||||
[ "$(type -t detect_legacy_lxc)" = "function" ]
|
||||
[ "$(type -t guard_data_path)" = "function" ]
|
||||
[ "$(type -t detect_legacy_toml_keys)" = "function" ]
|
||||
}
|
||||
|
||||
@test "detect_legacy_lxc finds mailserver+roundcube paths" {
|
||||
mkdir -p "$LXC_BASE/mailserver/rootfs" "$LXC_BASE/roundcube/rootfs"
|
||||
run detect_legacy_lxc
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"mailserver"* ]]
|
||||
[[ "$output" == *"roundcube"* ]]
|
||||
}
|
||||
|
||||
@test "detect_legacy_lxc returns 1 when only 'mail' exists" {
|
||||
mkdir -p "$LXC_BASE/mail/rootfs"
|
||||
run detect_legacy_lxc
|
||||
[ "$status" -eq 1 ]
|
||||
}
|
||||
|
||||
@test "guard_data_path refuses non-empty vmail (data preservation invariant I13)" {
|
||||
mkdir -p "$DATA_PATH/vmail/secubox.in/gk2"
|
||||
touch "$DATA_PATH/vmail/secubox.in/gk2/keep-me"
|
||||
run guard_data_path
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"refusing"* ]] || [[ "$output" == *"already has data"* ]]
|
||||
}
|
||||
|
||||
@test "guard_data_path accepts empty vmail dir" {
|
||||
mkdir -p "$DATA_PATH/vmail"
|
||||
run guard_data_path
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "guard_data_path accepts missing vmail dir" {
|
||||
run guard_data_path
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "detect_legacy_toml_keys finds each legacy key" {
|
||||
local toml="$BATS_TEST_TMPDIR/mail.toml"
|
||||
cat > "$toml" <<TOML
|
||||
[mail]
|
||||
mail_container = "mailserver"
|
||||
webmail_container = "roundcube"
|
||||
mail_ip = "192.168.255.30"
|
||||
webmail_ip = "192.168.255.31"
|
||||
webmail_port = 8027
|
||||
TOML
|
||||
run detect_legacy_toml_keys "$toml"
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"mail_container"* ]]
|
||||
[[ "$output" == *"webmail_container"* ]]
|
||||
[[ "$output" == *"mail_ip"* ]]
|
||||
[[ "$output" == *"webmail_ip"* ]]
|
||||
[[ "$output" == *"webmail_port"* ]]
|
||||
}
|
||||
|
||||
@test "detect_legacy_toml_keys returns 1 when toml is already migrated" {
|
||||
local toml="$BATS_TEST_TMPDIR/mail.toml"
|
||||
cat > "$toml" <<TOML
|
||||
[mail]
|
||||
container = "mail"
|
||||
lxc_ip = "10.100.0.10"
|
||||
TOML
|
||||
run detect_legacy_toml_keys "$toml"
|
||||
[ "$status" -eq 1 ]
|
||||
}
|
||||
|
|
@ -1,3 +1,10 @@
|
|||
secubox-webmail-lxc (2.2.0-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Transitional package — all functionality moved to secubox-mail >= 2.2.
|
||||
* Closes: #136
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Fri, 15 May 2026 12:00:00 +0200
|
||||
|
||||
secubox-webmail-lxc (1.1.0-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Remove standalone menu entry (now integrated into secubox-mail UI)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
Source: secubox-webmail-lxc
|
||||
Section: admin
|
||||
Section: oldlibs
|
||||
Priority: optional
|
||||
Maintainer: Gerald KERMA <devel@cybermind.fr>
|
||||
Build-Depends: debhelper-compat (= 13)
|
||||
|
|
@ -7,15 +7,8 @@ Standards-Version: 4.6.2
|
|||
|
||||
Package: secubox-webmail-lxc
|
||||
Architecture: all
|
||||
Depends: ${misc:Depends}, secubox-core (>= 1.0.0), lxc, wget
|
||||
Recommends: secubox-mail, secubox-mail-lxc
|
||||
Description: SecuBox Webmail LXC Container (Backend)
|
||||
LXC container management for Roundcube webmail.
|
||||
Backend component consumed by secubox-mail (no standalone UI).
|
||||
.
|
||||
Provides Alpine Linux container with:
|
||||
- Roundcube webmail
|
||||
- Nginx + PHP-FPM
|
||||
- Auto-configuration for mail server
|
||||
.
|
||||
Install secubox-mail for the management UI.
|
||||
Depends: ${misc:Depends}, secubox-mail (>= 2.2)
|
||||
Description: Transitional package — webmail LXC functionality moved to secubox-mail
|
||||
Roundcube webmail now lives inside the single 'mail' LXC managed by
|
||||
secubox-mail (>= 2.2). This package ships no files. Safe to
|
||||
apt-get autoremove after upgrade.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Transitional package (secubox-webmail-lxc 2.2.0) — clean up the old
|
||||
# standalone service if it's still around from <2.2 installs.
|
||||
if [ "$1" = "configure" ]; then
|
||||
systemctl daemon-reload
|
||||
systemctl enable secubox-webmail-lxc.service || true
|
||||
systemctl start secubox-webmail-lxc.service || true
|
||||
if [ -e /lib/systemd/system/secubox-webmail-lxc.service ] \
|
||||
|| [ -e /etc/systemd/system/secubox-webmail-lxc.service ]; then
|
||||
systemctl stop secubox-webmail-lxc.service 2>/dev/null || true
|
||||
systemctl disable secubox-webmail-lxc.service 2>/dev/null || true
|
||||
rm -f /lib/systemd/system/secubox-webmail-lxc.service \
|
||||
/etc/systemd/system/secubox-webmail-lxc.service
|
||||
systemctl daemon-reload || true
|
||||
fi
|
||||
fi
|
||||
|
||||
#DEBHELPER#
|
||||
exit 0
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
if [ "$1" = "remove" ]; then
|
||||
systemctl stop secubox-webmail-lxc.service || true
|
||||
systemctl disable secubox-webmail-lxc.service || true
|
||||
systemctl reload nginx 2>/dev/null || true
|
||||
fi
|
||||
# Transitional package — nothing to undo.
|
||||
#DEBHELPER#
|
||||
exit 0
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
#!/usr/bin/make -f
|
||||
# Transitional package — ships no files.
|
||||
%:
|
||||
dh $@
|
||||
|
||||
override_dh_auto_install:
|
||||
# API (used by secubox-mail for container management)
|
||||
install -d debian/secubox-webmail-lxc/usr/lib/secubox/webmail-lxc
|
||||
cp -r api debian/secubox-webmail-lxc/usr/lib/secubox/webmail-lxc/
|
||||
# Control scripts
|
||||
install -d debian/secubox-webmail-lxc/usr/sbin
|
||||
[ -d sbin ] && install -m 755 sbin/* debian/secubox-webmail-lxc/usr/sbin/ || true
|
||||
# Modular nginx config (API proxy only, no separate menu)
|
||||
install -d debian/secubox-webmail-lxc/etc/nginx/secubox.d
|
||||
[ -f nginx/webmail-lxc.conf ] && cp nginx/webmail-lxc.conf debian/secubox-webmail-lxc/etc/nginx/secubox.d/ || true
|
||||
:
|
||||
|
||||
override_dh_installsystemd:
|
||||
:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
# /etc/nginx/secubox.d/webmail-lxc.conf
|
||||
# Installed by secubox-webmail-lxc package
|
||||
location /api/v1/webmail-lxc/ {
|
||||
proxy_pass http://unix:/run/secubox/webmail-lxc.sock:/;
|
||||
include /etc/nginx/snippets/secubox-proxy.conf;
|
||||
}
|
||||
# DEPRECATED in secubox-webmail-lxc 2.2 — webmail LXC management folded
|
||||
# into secubox-mail's /api/v1/mail/. Empty for one release.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
secubox-webmail (2.2.0-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Transitional package — all functionality moved to secubox-mail >= 2.2.
|
||||
* Closes: #136
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Fri, 15 May 2026 12:00:00 +0200
|
||||
|
||||
secubox-webmail (1.0.0-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Initial release
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
Source: secubox-webmail
|
||||
Section: admin
|
||||
Section: oldlibs
|
||||
Priority: optional
|
||||
Maintainer: Gerald KERMA <devel@cybermind.fr>
|
||||
Build-Depends: debhelper-compat (= 13)
|
||||
|
|
@ -7,15 +7,7 @@ Standards-Version: 4.6.2
|
|||
|
||||
Package: secubox-webmail
|
||||
Architecture: all
|
||||
Depends: ${misc:Depends}, secubox-core (>= 1.0.0)
|
||||
Recommends: secubox-webmail-lxc
|
||||
Description: SecuBox Webmail Module
|
||||
Webmail management API and dashboard.
|
||||
Layer 2 of the 2-layer webmail architecture.
|
||||
.
|
||||
Features:
|
||||
- Auto-detect Roundcube/SOGo
|
||||
- Service control
|
||||
- Cache management
|
||||
.
|
||||
For containerized deployment, install secubox-webmail-lxc.
|
||||
Depends: ${misc:Depends}, secubox-mail (>= 2.2)
|
||||
Description: Transitional package — webmail admin folded into secubox-mail
|
||||
The webmail admin API is now part of secubox-mail (>= 2.2). This
|
||||
package ships no files. Safe to apt-get autoremove after upgrade.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Transitional package (secubox-webmail 2.2.0) — clean up the old
|
||||
# standalone service + www tree if they're still around from <2.2.
|
||||
if [ "$1" = "configure" ]; then
|
||||
systemctl daemon-reload
|
||||
systemctl enable secubox-webmail.service || true
|
||||
systemctl start secubox-webmail.service || true
|
||||
if [ -e /lib/systemd/system/secubox-webmail.service ] \
|
||||
|| [ -e /etc/systemd/system/secubox-webmail.service ]; then
|
||||
systemctl stop secubox-webmail.service 2>/dev/null || true
|
||||
systemctl disable secubox-webmail.service 2>/dev/null || true
|
||||
rm -f /lib/systemd/system/secubox-webmail.service \
|
||||
/etc/systemd/system/secubox-webmail.service
|
||||
systemctl daemon-reload || true
|
||||
fi
|
||||
fi
|
||||
|
||||
#DEBHELPER#
|
||||
exit 0
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
if [ "$1" = "remove" ]; then
|
||||
systemctl stop secubox-webmail.service || true
|
||||
systemctl disable secubox-webmail.service || true
|
||||
systemctl reload nginx 2>/dev/null || true
|
||||
fi
|
||||
# Transitional package — nothing to undo.
|
||||
#DEBHELPER#
|
||||
exit 0
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
#!/usr/bin/make -f
|
||||
# Transitional package — ships no files.
|
||||
%:
|
||||
dh $@
|
||||
|
||||
override_dh_auto_install:
|
||||
install -d debian/secubox-webmail/usr/lib/secubox/webmail
|
||||
cp -r api debian/secubox-webmail/usr/lib/secubox/webmail/
|
||||
install -d debian/secubox-webmail/usr/share/secubox/www/webmail
|
||||
cp -r www/webmail/. debian/secubox-webmail/usr/share/secubox/www/webmail/
|
||||
install -d debian/secubox-webmail/usr/share/secubox/menu.d
|
||||
[ -d menu.d ] && cp -r menu.d/. debian/secubox-webmail/usr/share/secubox/menu.d/ || true
|
||||
# Modular nginx config
|
||||
install -d debian/secubox-webmail/etc/nginx/secubox.d
|
||||
[ -f nginx/webmail.conf ] && cp nginx/webmail.conf debian/secubox-webmail/etc/nginx/secubox.d/ || true
|
||||
:
|
||||
|
||||
override_dh_installsystemd:
|
||||
:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
# /etc/nginx/secubox.d/webmail.conf
|
||||
# Installed by secubox-webmail package
|
||||
location /api/v1/webmail/ {
|
||||
proxy_pass http://unix:/run/secubox/webmail.sock:/;
|
||||
include /etc/nginx/snippets/secubox-proxy.conf;
|
||||
}
|
||||
# DEPRECATED in secubox-webmail 2.2 — webmail admin API is folded into
|
||||
# secubox-mail's /api/v1/mail/. This snippet is kept empty for one release
|
||||
# so a partial-upgrade nginx reload doesn't error on a missing include.
|
||||
|
|
|
|||
|
|
@ -146,6 +146,25 @@ class DashboardCanvas:
|
|||
draw.text((cx - lw // 2, cy + size + 4),
|
||||
label, fill=theme.TEXT_PRIMARY + (255,), font=font)
|
||||
|
||||
def paint_radar_concentric(self, img: Image.Image,
|
||||
center: tuple[int, int],
|
||||
modules: Iterable[Module],
|
||||
metrics: dict,
|
||||
radii: list[int] | None = None,
|
||||
phase: float = 0.0,
|
||||
draw_hub: bool = True,
|
||||
name_to_angle: dict[str, int] | None = None
|
||||
) -> None:
|
||||
"""Phase-aware concentric radar (ring backgrounds + tube arcs +
|
||||
rotating sweep + sweep head + optional hub). Delegates to
|
||||
`secubox_common.painters.radar_concentric.paint`. Drives the
|
||||
OFFLINE-state visual on both round/ and the square's left half.
|
||||
"""
|
||||
from .painters import radar_concentric as _radar
|
||||
_radar.paint(img, center, modules, metrics,
|
||||
radii=radii, phase=phase, draw_hub=draw_hub,
|
||||
name_to_angle=name_to_angle)
|
||||
|
||||
def paint_alert_ribbon(self, img: Image.Image, region_y: int,
|
||||
text: str, severity: str) -> None:
|
||||
"""Bottom strip: solid dark fill + coloured severity text.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
# 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.
|
||||
"""Phase-aware painter modules for animated dashboard variants."""
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
# 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.
|
||||
"""Concentric radar painter — port of fallback_manager._draw_offline_radar().
|
||||
|
||||
Stateless: given a `phase` in [0, 1) it draws the full radar frame
|
||||
(ring backgrounds, balanced tube arcs, rotating segment-coloured sweep,
|
||||
sweep head, centre hub) onto `img` centred on `center`.
|
||||
|
||||
`phase * 2π` is the sweep angle in radians, measured clockwise from
|
||||
12 o'clock. Caller is responsible for advancing phase across frames
|
||||
(typically `(time.monotonic() * rpm / 60) % 1`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import colorsys
|
||||
import math
|
||||
from collections.abc import Iterable
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from ..modules import Module
|
||||
|
||||
# Default radii match the deployed fallback_manager.py layout (6 rings).
|
||||
DEFAULT_RADII: list[int] = [214, 188, 162, 136, 110, 84]
|
||||
|
||||
# Stroke width for ring backgrounds + tube arcs.
|
||||
RING_WIDTH: int = 20
|
||||
|
||||
# Inner hub radius — masks the centre so it can host icons / metrics text.
|
||||
HUB_RADIUS: int = 85
|
||||
|
||||
# Mapping module NAME → PIL arc centre angle so each tube arc sits at the
|
||||
# position on the wheel that matches its colour. PIL convention: 0=3 o'clock,
|
||||
# angles increase counter-clockwise. Decoupling angle from list order means
|
||||
# the painter is invariant to MODULES iteration order — callers can pass
|
||||
# secubox_common.MODULES (AUTH-first) or fallback_manager's local list
|
||||
# (BOOT-first) and get the same wheel layout.
|
||||
DEFAULT_NAME_TO_ANGLE: dict[str, int] = {
|
||||
"AUTH": 0, # red-orange (3 o'clock)
|
||||
"WALL": 60, # orange-yellow
|
||||
"ROOT": 120, # green
|
||||
"MESH": 180, # blue
|
||||
"MIND": 240, # purple
|
||||
"BOOT": 300, # deep red — closes the wheel between purple and red
|
||||
}
|
||||
|
||||
|
||||
def paint(
|
||||
img: Image.Image,
|
||||
center: tuple[int, int],
|
||||
modules: Iterable[Module],
|
||||
metrics: dict,
|
||||
radii: list[int] | None = None,
|
||||
phase: float = 0.0,
|
||||
ring_width: int = RING_WIDTH,
|
||||
hub_radius: int = HUB_RADIUS,
|
||||
draw_hub: bool = True,
|
||||
name_to_angle: dict[str, int] | None = None,
|
||||
) -> None:
|
||||
"""Paint one radar frame at `phase`.
|
||||
|
||||
Args:
|
||||
img: target RGBA image; must be large enough that
|
||||
`radii[0] + 8` fits within ``min(img.size) / 2``.
|
||||
center: (cx, cy) — the radar centre in image coordinates.
|
||||
modules: ordered iterable of Module — drawn outermost-first.
|
||||
metrics: dict passed to `Module.extract` for each ring's fill ratio.
|
||||
radii: per-ring radius, outermost-first. Default matches the
|
||||
deployed Pi Zero W fallback radar.
|
||||
phase: 0..1 maps to 0..2π sweep angle (clockwise from 12 o'clock).
|
||||
ring_width: stroke width for ring backgrounds and tube arcs.
|
||||
hub_radius: radius of the dark centre disc.
|
||||
draw_hub: when False, callers can composite their own centre
|
||||
(e.g. the converged dashboard's central button + pod cluster).
|
||||
"""
|
||||
if radii is None:
|
||||
radii = DEFAULT_RADII
|
||||
if name_to_angle is None:
|
||||
name_to_angle = DEFAULT_NAME_TO_ANGLE
|
||||
modules = list(modules)
|
||||
if len(modules) != len(radii):
|
||||
raise ValueError(
|
||||
f"modules ({len(modules)}) and radii ({len(radii)}) length mismatch"
|
||||
)
|
||||
|
||||
draw = ImageDraw.Draw(img)
|
||||
cx, cy = center
|
||||
sweep_rad = (phase % 1.0) * 2.0 * math.pi
|
||||
|
||||
_paint_ring_backgrounds(draw, cx, cy, radii, ring_width)
|
||||
_paint_tube_arcs(draw, cx, cy, modules, metrics, radii, ring_width,
|
||||
name_to_angle)
|
||||
_paint_sweep(draw, cx, cy, modules, metrics, radii, sweep_rad)
|
||||
_paint_sweep_head(draw, cx, cy, modules[0].colour, radii[0], sweep_rad)
|
||||
if draw_hub:
|
||||
draw.ellipse(
|
||||
(cx - hub_radius, cy - hub_radius, cx + hub_radius, cy + hub_radius),
|
||||
fill=(12, 12, 22, 255),
|
||||
)
|
||||
|
||||
|
||||
def _paint_ring_backgrounds(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
cx: int,
|
||||
cy: int,
|
||||
radii: list[int],
|
||||
ring_width: int,
|
||||
) -> None:
|
||||
for r in radii:
|
||||
draw.ellipse(
|
||||
(cx - r, cy - r, cx + r, cy + r),
|
||||
outline=(20, 20, 28, 255),
|
||||
width=ring_width,
|
||||
)
|
||||
|
||||
|
||||
def _paint_tube_arcs(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
cx: int,
|
||||
cy: int,
|
||||
modules: list[Module],
|
||||
metrics: dict,
|
||||
radii: list[int],
|
||||
ring_width: int,
|
||||
name_to_angle: dict[str, int],
|
||||
) -> None:
|
||||
# PIL arc degrees: 0=3 o'clock, counter-clockwise. Each module's arc
|
||||
# sits at the wheel position matching its colour, looked up by name
|
||||
# — independent of caller's iteration order.
|
||||
for m, r in zip(modules, radii):
|
||||
pct = m.extract(metrics)
|
||||
if pct <= 0.0:
|
||||
continue
|
||||
# Fall back to evenly-spaced positions if name unknown — keeps the
|
||||
# painter robust against custom module sets without bringing in a
|
||||
# hard dependency on the canonical six.
|
||||
center_deg = name_to_angle.get(m.name, 0)
|
||||
half = (pct * 360.0) / 2.0
|
||||
start = center_deg + half
|
||||
end = center_deg - half
|
||||
|
||||
# Outer dark edge (color × 1/3).
|
||||
dark = (m.colour[0] // 3, m.colour[1] // 3, m.colour[2] // 3, 255)
|
||||
draw.arc(
|
||||
(cx - r - 2, cy - r - 2, cx + r + 2, cy + r + 2),
|
||||
end, start, fill=dark, width=ring_width - 2,
|
||||
)
|
||||
# Main color band.
|
||||
draw.arc(
|
||||
(cx - r, cy - r, cx + r, cy + r),
|
||||
end, start, fill=m.colour + (255,), width=ring_width - 6,
|
||||
)
|
||||
# Inner light tube highlight.
|
||||
light = (
|
||||
min(255, m.colour[0] + 80),
|
||||
min(255, m.colour[1] + 80),
|
||||
min(255, m.colour[2] + 80),
|
||||
255,
|
||||
)
|
||||
draw.arc(
|
||||
(cx - r + 2, cy - r + 2, cx + r - 2, cy + r - 2),
|
||||
end, start, fill=light, width=4,
|
||||
)
|
||||
|
||||
|
||||
def _paint_sweep(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
cx: int,
|
||||
cy: int,
|
||||
modules: list[Module],
|
||||
metrics: dict,
|
||||
radii: list[int],
|
||||
sweep_rad: float,
|
||||
) -> None:
|
||||
# Per-ring sweep segments coloured by that ring's metric.
|
||||
max_r = radii[0] + 8
|
||||
min_r = radii[-1] - 8
|
||||
|
||||
for idx, m in enumerate(modules):
|
||||
r = radii[idx]
|
||||
# Segment bounds — each ring owns the slab between its midpoint
|
||||
# to the previous ring and its midpoint to the next ring.
|
||||
r_outer = max_r if idx == 0 else (radii[idx - 1] + r) // 2
|
||||
r_inner = min_r if idx == len(modules) - 1 else (r + radii[idx + 1]) // 2
|
||||
|
||||
pct = m.extract(metrics)
|
||||
intensity = 0.5 + pct * 0.5
|
||||
|
||||
# Fading trail behind the sweep line.
|
||||
for i in range(15):
|
||||
offset = -0.15 * (i / 15.0)
|
||||
a = sweep_rad + offset
|
||||
fade = 1.0 - (i / 15.0)
|
||||
x1 = cx + r_inner * math.sin(a)
|
||||
y1 = cy - r_inner * math.cos(a)
|
||||
x2 = cx + r_outer * math.sin(a)
|
||||
y2 = cy - r_outer * math.cos(a)
|
||||
seg = (
|
||||
int(m.colour[0] * fade * intensity),
|
||||
int(m.colour[1] * fade * intensity),
|
||||
int(m.colour[2] * fade * intensity),
|
||||
255,
|
||||
)
|
||||
draw.line([(x1, y1), (x2, y2)], fill=seg, width=2)
|
||||
|
||||
# Main bright leading edge for this ring.
|
||||
x1 = cx + r_inner * math.sin(sweep_rad)
|
||||
y1 = cy - r_inner * math.cos(sweep_rad)
|
||||
x2 = cx + r_outer * math.sin(sweep_rad)
|
||||
y2 = cy - r_outer * math.cos(sweep_rad)
|
||||
bright = (
|
||||
min(255, m.colour[0] + 60),
|
||||
min(255, m.colour[1] + 60),
|
||||
min(255, m.colour[2] + 60),
|
||||
255,
|
||||
)
|
||||
draw.line([(x1, y1), (x2, y2)], fill=bright, width=3)
|
||||
|
||||
|
||||
def _paint_sweep_head(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
cx: int,
|
||||
cy: int,
|
||||
head_color: tuple[int, int, int],
|
||||
outer_r: int,
|
||||
sweep_rad: float,
|
||||
) -> None:
|
||||
hx = cx + outer_r * math.sin(sweep_rad)
|
||||
hy = cy - outer_r * math.cos(sweep_rad)
|
||||
draw.ellipse(
|
||||
(hx - 4, hy - 4, hx + 4, hy + 4),
|
||||
fill=head_color + (255,),
|
||||
)
|
||||
|
||||
|
||||
# Convenience for callers that want a quick HSV-rotating accent colour
|
||||
# matching the current sweep position (used by the original radar variant
|
||||
# but kept here for downstream painters that want it).
|
||||
def sweep_accent(phase: float) -> tuple[int, int, int]:
|
||||
hue = phase % 1.0
|
||||
r, g, b = colorsys.hsv_to_rgb(hue, 0.9, 0.8)
|
||||
return (int(r * 255), int(g * 255), int(b * 255))
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
# 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.
|
||||
"""Tests for secubox_common.painters.radar_concentric."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from secubox_common.modules import MODULES
|
||||
from secubox_common.painters import radar_concentric
|
||||
|
||||
|
||||
METRICS = {
|
||||
"cpu_percent": 60.0,
|
||||
"mem_percent": 45.0,
|
||||
"disk_percent": 30.0,
|
||||
"load_avg_1": 0.8,
|
||||
"cpu_temp": 55.0,
|
||||
"wifi_rssi": -55,
|
||||
}
|
||||
|
||||
|
||||
def _frame() -> Image.Image:
|
||||
return Image.new("RGBA", (480, 480), (0, 0, 0, 255))
|
||||
|
||||
|
||||
def test_paint_modifies_canvas():
|
||||
"""Painting at any phase must change the canvas from solid black."""
|
||||
img = _frame()
|
||||
radar_concentric.paint(img, (240, 240), MODULES, METRICS, phase=0.0)
|
||||
# At least one pixel outside the inner hub must have changed.
|
||||
sample = img.getpixel((240, 30)) # near outer ring
|
||||
assert sample != (0, 0, 0, 255), \
|
||||
"outer ring area should have radar pixels painted"
|
||||
|
||||
|
||||
def test_paint_differs_across_phases():
|
||||
"""phase=0.0 and phase=0.5 must produce different sweep positions."""
|
||||
a = _frame()
|
||||
b = _frame()
|
||||
radar_concentric.paint(a, (240, 240), MODULES, METRICS, phase=0.0)
|
||||
radar_concentric.paint(b, (240, 240), MODULES, METRICS, phase=0.5)
|
||||
assert a.tobytes() != b.tobytes(), \
|
||||
"frames at phase 0.0 vs 0.5 should differ (sweep rotated 180°)"
|
||||
|
||||
|
||||
def test_paint_wraps_at_phase_one():
|
||||
"""phase=0.0 and phase=1.0 produce the same frame (period = 1)."""
|
||||
a = _frame()
|
||||
b = _frame()
|
||||
radar_concentric.paint(a, (240, 240), MODULES, METRICS, phase=0.0)
|
||||
radar_concentric.paint(b, (240, 240), MODULES, METRICS, phase=1.0)
|
||||
assert a.tobytes() == b.tobytes(), \
|
||||
"phase=1.0 must wrap back to phase=0.0"
|
||||
|
||||
|
||||
def test_paint_uses_default_radii_when_none():
|
||||
"""radii=None must fall back to DEFAULT_RADII (6 entries)."""
|
||||
img = _frame()
|
||||
# 6 modules + 6 default radii must match.
|
||||
radar_concentric.paint(img, (240, 240), MODULES, METRICS, radii=None)
|
||||
sample = img.getpixel((240, 30))
|
||||
assert sample != (0, 0, 0, 255)
|
||||
|
||||
|
||||
def test_paint_rejects_mismatched_radii():
|
||||
"""A radii list of wrong length must raise."""
|
||||
img = _frame()
|
||||
with pytest.raises(ValueError, match="length mismatch"):
|
||||
radar_concentric.paint(
|
||||
img, (240, 240), MODULES, METRICS, radii=[200, 180, 160]
|
||||
)
|
||||
|
||||
|
||||
def test_paint_hub_toggle():
|
||||
"""draw_hub=False must leave the centre area unfilled by the painter
|
||||
(so callers can composite their own central button + icons)."""
|
||||
a = _frame()
|
||||
b = _frame()
|
||||
radar_concentric.paint(a, (240, 240), MODULES, METRICS,
|
||||
phase=0.0, draw_hub=True)
|
||||
radar_concentric.paint(b, (240, 240), MODULES, METRICS,
|
||||
phase=0.0, draw_hub=False)
|
||||
# Sample a point well inside the hub (radius ~85). With draw_hub=True
|
||||
# the colour is the hub fill (12, 12, 22, 255). With False it remains
|
||||
# the canvas background.
|
||||
assert a.getpixel((240, 240)) == (12, 12, 22, 255)
|
||||
assert b.getpixel((240, 240)) == (0, 0, 0, 255)
|
||||
|
||||
|
||||
def test_sweep_accent_in_rgb_range():
|
||||
"""sweep_accent returns valid RGB tuples for any phase."""
|
||||
for p in [0.0, 0.25, 0.5, 0.75, 0.999]:
|
||||
r, g, b = radar_concentric.sweep_accent(p)
|
||||
for chan in (r, g, b):
|
||||
assert 0 <= chan <= 255
|
||||
|
||||
|
||||
def test_canvas_method_delegates():
|
||||
"""DashboardCanvas.paint_radar_concentric must produce the same frame
|
||||
as calling the painter directly — proves the canvas helper is a
|
||||
thin wrapper, not divergent code."""
|
||||
from secubox_common.canvas import DashboardCanvas
|
||||
|
||||
direct = _frame()
|
||||
via_canvas = _frame()
|
||||
radar_concentric.paint(direct, (240, 240), MODULES, METRICS, phase=0.25)
|
||||
DashboardCanvas().paint_radar_concentric(
|
||||
via_canvas, (240, 240), MODULES, METRICS, phase=0.25,
|
||||
)
|
||||
assert direct.tobytes() == via_canvas.tobytes(), \
|
||||
"canvas helper should delegate verbatim to painters.radar_concentric.paint"
|
||||
|
|
@ -284,9 +284,14 @@ gadget_start() {
|
|||
log "Activation sur UDC: ${udc}"
|
||||
echo "$udc" > UDC
|
||||
|
||||
# Attendre que l'interface usb1 (ECM) apparaisse
|
||||
# Note: Le gadget composite crée usb0 (RNDIS/Windows) et usb1 (ECM/Linux-Mac)
|
||||
# On configure usb1 car les hôtes Linux utilisent le driver cdc_ether (ECM)
|
||||
# Attendre que la deuxième interface réseau du gadget composé
|
||||
# apparaisse côté kernel. Le composé crée rndis.usb0 puis ecm.usb0
|
||||
# (dans cet ordre, cf. liens configfs ci-dessus) — le kernel les
|
||||
# enregistre comme /sys/class/net/usb0 et /sys/class/net/usb1.
|
||||
# Côté hôte, RNDIS et ECM partagent le même host_addr donc l'ARP
|
||||
# remonte sur n'importe laquelle des deux interfaces UP du host;
|
||||
# binder /usb1 ici suffit pour rendre 10.55.0.2 joignable depuis
|
||||
# 10.55.0.1, peu importe le canal physique sélectionné par l'hôte.
|
||||
local retry=0
|
||||
while [[ ! -d /sys/class/net/usb1 ]] && [[ $retry -lt 10 ]]; do
|
||||
sleep 0.5
|
||||
|
|
@ -294,9 +299,9 @@ gadget_start() {
|
|||
done
|
||||
|
||||
if [[ -d /sys/class/net/usb1 ]]; then
|
||||
log "Interface usb1 (ECM) créée"
|
||||
log "Interface usb1 (deuxième fonction réseau du gadget) créée"
|
||||
|
||||
# Configurer l'IP sur usb1 uniquement (évite le routage asymétrique)
|
||||
# Configurer l'IP sur usb1 uniquement (évite le routage asymétrique).
|
||||
ip addr flush dev usb1 2>/dev/null || true
|
||||
ip addr add "${OTG_NETWORK_DEV}/30" dev usb1
|
||||
ip link set usb1 up
|
||||
|
|
|
|||
|
|
@ -704,15 +704,12 @@ ln -sf /etc/systemd/system/secubox-serial-console.service \
|
|||
# NOTE: fb-dashboard is DEPRECATED - use fallback-display instead
|
||||
# The old dashboard is kept for reference but NOT enabled by default
|
||||
|
||||
# Network config for usb0
|
||||
mkdir -p "$ROOT_MNT/etc/network/interfaces.d"
|
||||
cat > "$ROOT_MNT/etc/network/interfaces.d/usb0" << 'EOF'
|
||||
allow-hotplug usb0
|
||||
iface usb0 inet static
|
||||
address 10.55.0.2
|
||||
netmask 255.255.255.252
|
||||
gateway 10.55.0.1
|
||||
EOF
|
||||
# IP binding for the gadget's usb1 interface is handled by
|
||||
# /usr/local/sbin/secubox-otg-gadget.sh (the same script that composes
|
||||
# the configfs gadget). The legacy /etc/network/interfaces.d/usb0
|
||||
# stanza was removed (closes #139) — it required `ifupdown` which the
|
||||
# image never installed, so it never did anything except confuse
|
||||
# diagnostics.
|
||||
|
||||
# USB network script (handles both usb0 and usb1 - ECM may create either)
|
||||
mkdir -p "$ROOT_MNT/usr/local/bin"
|
||||
|
|
@ -787,8 +784,9 @@ if ! id secubox &>/dev/null; then
|
|||
echo "secubox:secubox2026" | chpasswd
|
||||
fi
|
||||
|
||||
# Add to groups
|
||||
usermod -aG video,input,gpio,i2c,spi,audio secubox 2>/dev/null || true
|
||||
# Add to groups. `sudo` lets the kiosk user manually recover networking
|
||||
# from the ACM serial console (e.g. when /dev/ttyACM0 is the only path in).
|
||||
usermod -aG sudo,video,input,gpio,i2c,spi,audio secubox 2>/dev/null || true
|
||||
|
||||
# Enable lightdm and nginx
|
||||
systemctl enable lightdm 2>/dev/null || true
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
allow-hotplug usb0
|
||||
iface usb0 inet static
|
||||
address 10.55.0.2
|
||||
netmask 255.255.255.252
|
||||
gateway 10.55.0.1
|
||||
|
|
@ -15,22 +15,32 @@ from secubox_common.modules import MODULES
|
|||
class RoundDashboard(DashboardCanvas):
|
||||
SIZE = (480, 480)
|
||||
CENTER = (240, 240)
|
||||
RING_RADII = [200, 185, 170, 155, 140, 125]
|
||||
# Match the deployed fallback_manager.py radar geometry so the round
|
||||
# rendering stays visually identical after migration.
|
||||
RING_RADII = [214, 188, 162, 136, 110, 84]
|
||||
|
||||
def layout(self, metrics: dict) -> Image.Image:
|
||||
def layout(self, metrics: dict, phase: float = 0.0) -> Image.Image:
|
||||
"""Render one frame at the given animation `phase` (0..1).
|
||||
|
||||
Phase advances the rotating sweep; callers pass `phase=0.0` for
|
||||
a static still frame, or `(time.monotonic() * rpm / 60) % 1` for
|
||||
an animated loop.
|
||||
"""
|
||||
img = Image.new("RGBA", self.SIZE, theme.COSMOS_BLACK + (255,))
|
||||
self.paint_rainbow_ring(img, self.CENTER, 235, 220)
|
||||
self.paint_concentric_arcs(img, self.CENTER, MODULES, metrics,
|
||||
self.RING_RADII)
|
||||
self.paint_pod_cluster(img, MODULES, self.CENTER, radius=70, pod_size=40)
|
||||
self.paint_radar_concentric(
|
||||
img, self.CENTER, MODULES, metrics,
|
||||
radii=self.RING_RADII, phase=phase, draw_hub=True,
|
||||
)
|
||||
# Pods sit on top of the hub; pod_size=48 matches deployed icon
|
||||
# sizes (22/48/96/128). radius=78 keeps the pod inner edge (54)
|
||||
# clear of the central button (44).
|
||||
self.paint_pod_cluster(img, MODULES, self.CENTER, radius=78, pod_size=48)
|
||||
self.paint_central_button(img, self.CENTER, size=44)
|
||||
return img
|
||||
|
||||
# Round-only additional view modes (called by fb_dashboard.py's main
|
||||
# loop when the user long-presses center → radial menu → terminal/flash/auth).
|
||||
def layout_terminal(self, term_state) -> Image.Image:
|
||||
# Delegates to the existing draw_terminal() helper for now;
|
||||
# extracted into a method to give the main loop a class-based API.
|
||||
from fb_dashboard import draw_terminal
|
||||
return draw_terminal(term_state)
|
||||
|
||||
|
|
|
|||
|
|
@ -103,6 +103,17 @@ log "Installing config files (systemd, udev, apparmor, firstboot)..."
|
|||
cp -r "$REPO_ROOT/remote-ui/square/files/." "$ROOT_MNT/"
|
||||
chmod +x "$ROOT_MNT/usr/local/sbin/firstboot.sh"
|
||||
|
||||
# Install the shared OTG gadget composer (round does this at line 618 of
|
||||
# build-eye-remote-image.sh). secubox-otg-gadget.service ExecStarts this path;
|
||||
# without it the gadget never composes and the Pi 4B's USB-C bus stays silent
|
||||
# → MOCHAbin/host enumeration fails (no descriptor events, xhci timeouts).
|
||||
log "Installing OTG gadget composer at /usr/local/sbin/secubox-otg-gadget.sh..."
|
||||
cp "$REPO_ROOT/remote-ui/common/shell/secubox-otg-gadget.sh" \
|
||||
"$ROOT_MNT/usr/local/sbin/secubox-otg-gadget.sh"
|
||||
chmod +x "$ROOT_MNT/usr/local/sbin/secubox-otg-gadget.sh"
|
||||
test -x "$ROOT_MNT/usr/local/sbin/secubox-otg-gadget.sh" || \
|
||||
{ err "secubox-otg-gadget.sh not executable on rootfs"; exit 2; }
|
||||
|
||||
# Ship the shared secubox_common package.
|
||||
log "Embedding remote-ui/common/python at /var/www/common/python/..."
|
||||
mkdir -p "$ROOT_MNT/var/www/common/python"
|
||||
|
|
@ -110,6 +121,17 @@ cp -r "$REPO_ROOT/remote-ui/common/python/." "$ROOT_MNT/var/www/common/python/"
|
|||
test -d "$ROOT_MNT/var/www/common/python/secubox_common" || \
|
||||
{ err "secubox_common not in /var/www/common/python — common/ source incomplete"; exit 2; }
|
||||
|
||||
# Ship the shared icon assets — secubox_common.icons.load_module_icon
|
||||
# resolves at /var/www/common/assets/icons/ first. Without these the pod
|
||||
# cluster falls back to first-letter placeholders.
|
||||
log "Embedding remote-ui/common/assets at /var/www/common/assets/..."
|
||||
mkdir -p "$ROOT_MNT/var/www/common/assets"
|
||||
cp -r "$REPO_ROOT/remote-ui/common/assets/." "$ROOT_MNT/var/www/common/assets/"
|
||||
ICON_COUNT=$(ls "$ROOT_MNT/var/www/common/assets/icons"/*-48.png 2>/dev/null | wc -l)
|
||||
test "$ICON_COUNT" -gt 0 || \
|
||||
{ err "no *-48.png icons in /var/www/common/assets/icons — common/ assets incomplete"; exit 2; }
|
||||
log " → $ICON_COUNT module icons at size 48 shipped"
|
||||
|
||||
log "Installing Python packages..."
|
||||
mkdir -p "$ROOT_MNT/usr/lib/python3/dist-packages"
|
||||
cp -r "$REPO_ROOT/packages/secubox-eye-square/helper/eye_square_helper" \
|
||||
|
|
|
|||
95
tests/scripts/test-mail-phase1-acceptance.sh
Executable file
95
tests/scripts/test-mail-phase1-acceptance.sh
Executable file
|
|
@ -0,0 +1,95 @@
|
|||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
#
|
||||
# tests/scripts/test-mail-phase1-acceptance.sh
|
||||
# Phase 1 rev. 2 acceptance — runs against the live board.
|
||||
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
# shellcheck source=/dev/null
|
||||
source "$REPO/scripts/lib/test-helpers.sh"
|
||||
|
||||
HOST="${1:-root@192.168.1.200}"
|
||||
HOST_IP="${HOST#*@}"
|
||||
|
||||
step() { echo; echo "[acceptance] $*"; }
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
step "1) Source-side: bash -n parses every controller cleanly"
|
||||
for f in "$REPO"/packages/secubox-mail/sbin/{mailctl,mailserverctl,roundcubectl,mail-migrate-to-single-lxc.sh}; do
|
||||
bash -n "$f" || fail "bash -n $f"
|
||||
done
|
||||
pass "controllers parse"
|
||||
|
||||
step "2) Pytest: 62 endpoints respond non-5xx"
|
||||
( cd "$REPO" && python3 -m pytest packages/secubox-mail/api/tests/test_phase1_endpoints.py -q ) >/dev/null \
|
||||
|| fail "pytest endpoint coverage"
|
||||
pass "62 endpoints respond"
|
||||
|
||||
step "3) Board: canonical paths present (LXC + data volumes)"
|
||||
ssh "$HOST" 'set -e
|
||||
test -d /data/lxc/mail/rootfs
|
||||
test -L /var/lib/lxc/mail
|
||||
test -d /data/volumes/mail/vmail
|
||||
' || fail "canonical paths missing on board"
|
||||
pass "canonical paths exist"
|
||||
|
||||
step "4) Board: production users still present in /data/volumes/mail/vmail/secubox.in/"
|
||||
ssh "$HOST" 'ls /data/volumes/mail/vmail/secubox.in/' > /tmp/phase1-users
|
||||
for u in gk2 bat bourdon lemurien ragondin; do
|
||||
grep -wq "$u" /tmp/phase1-users || fail "production user '$u' missing — data preservation invariant I13 violated"
|
||||
done
|
||||
pass "5 production users present (gk2, bat, bourdon, lemurien, ragondin)"
|
||||
|
||||
step "5) Board: host secubox-mail.service active"
|
||||
ssh "$HOST" 'systemctl is-active secubox-mail' | grep -q "^active$" || fail "secubox-mail.service inactive"
|
||||
pass "secubox-mail.service active"
|
||||
|
||||
step "6) Board: mailctl migrate-config is idempotent on already-migrated toml"
|
||||
ssh "$HOST" '/usr/sbin/mailctl migrate-config 2>&1' | tail -3
|
||||
pass "migrate-config ran (idempotent)"
|
||||
|
||||
step "7) Board: /etc/secubox/mail.toml has canonical keys + no live old keys"
|
||||
ssh "$HOST" 'cat /etc/secubox/mail.toml' > /tmp/phase1-toml
|
||||
grep -qE '^container *= *"mail"' /tmp/phase1-toml || fail "missing canonical 'container = \"mail\"'"
|
||||
grep -qE '^lxc_ip *= *"10\.100\.0\.10"' /tmp/phase1-toml || fail "missing canonical 'lxc_ip = \"10.100.0.10\"'"
|
||||
# Old keys should be either gone or commented as DEPRECATED
|
||||
if grep -qE '^[^#]*mail_container *=' /tmp/phase1-toml; then
|
||||
fail "legacy 'mail_container' key still active (not commented)"
|
||||
fi
|
||||
pass "toml uses canonical keys; legacy keys commented or removed"
|
||||
|
||||
step "8) Board: mailctl start brings the mail LXC up"
|
||||
ssh "$HOST" 'mailctl start 2>&1' | tail -5
|
||||
ssh "$HOST" 'lxc-info -n mail 2>&1' | grep -E "State:.*RUNNING" >/dev/null \
|
||||
|| fail "mail LXC not RUNNING after mailctl start"
|
||||
pass "mail LXC RUNNING"
|
||||
|
||||
step "9) Board: Postfix:25 + Dovecot:993 + HTTP:80 listening inside LXC"
|
||||
ssh "$HOST" 'lxc-attach -n mail -- ss -tlnp 2>&1' > /tmp/phase1-ports
|
||||
grep -q ':25 ' /tmp/phase1-ports || fail "postfix:25 not listening"
|
||||
grep -q ':993 ' /tmp/phase1-ports || fail "dovecot:993 not listening"
|
||||
grep -q ':80 ' /tmp/phase1-ports || fail "http:80 (Roundcube) not listening"
|
||||
pass "postfix:25, dovecot:993, http:80 listening"
|
||||
|
||||
step "10) Board: Dovecot greets on IMAPS port"
|
||||
out=$(ssh "$HOST" 'echo "0 LOGOUT" | timeout 5 openssl s_client -connect 10.100.0.10:993 -quiet 2>/dev/null | head -3' || true)
|
||||
echo "$out" | grep -qi "Dovecot" || fail "no Dovecot greeting from 10.100.0.10:993"
|
||||
pass "Dovecot IMAPS greeting received"
|
||||
|
||||
step "11) Board: Roundcube reachable through host proxy (WAF path)"
|
||||
out=$(curl --silent --insecure --resolve "webmail.gk2.secubox.in:443:$HOST_IP" \
|
||||
https://webmail.gk2.secubox.in/ 2>&1 || true)
|
||||
echo "$out" | grep -qiE 'roundcube|webmail|login' \
|
||||
|| fail "Roundcube did not respond through WAF path"
|
||||
pass "Roundcube reachable via WAF (HAProxy → mitmproxy → LXC :80)"
|
||||
|
||||
step "12) Data preservation (gate 4 re-check after the LXC started)"
|
||||
ssh "$HOST" 'ls /data/volumes/mail/vmail/secubox.in/' > /tmp/phase1-users-after
|
||||
diff /tmp/phase1-users /tmp/phase1-users-after || fail "user list changed after LXC start"
|
||||
pass "5 production users still byte-identical after LXC start"
|
||||
|
||||
echo
|
||||
pass "PHASE 1 ACCEPTANCE: all 12 gates green"
|
||||
Loading…
Reference in New Issue
Block a user