Compare commits

..

37 Commits

Author SHA1 Message Date
ec1a4641e0 Merge remote-tracking branch 'origin/feature/135-converge-round-square-dashboards-into-re' into feature/138-port-radar-concentric-into-secubox-commo
# Conflicts:
#	packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/__main__.py
#	packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/square_dashboard.py
#	remote-ui/common/python/secubox_common/canvas.py
#	remote-ui/round/round_dashboard.py
#	remote-ui/square/build-eye-square-image.sh
2026-05-15 11:46:27 +02:00
6d12af8624 feat(common): radar_concentric painter + phase-aware dashboards (ref #138)
Port the radar concentric layout from remote-ui/round/agent/display/fallback/
into a stateless painter under secubox_common.painters.radar_concentric.
Both round and square dashboards now consume it; the square's main loop
drives a monotonic-clock phase so its left half rotates the same way the
round's deployed fallback_manager does.

What landed
-----------
- `secubox_common/painters/radar_concentric.py`
  - `paint(img, center, modules, metrics, radii=None, phase=0.0, ...)`
  - phase × 2π = sweep angle radians, clockwise from 12 o'clock.
  - Module → wheel-angle map decoupled from list order via
    DEFAULT_NAME_TO_ANGLE so AUTH/WALL/ROOT/MESH/MIND/BOOT sit at the
    rainbow position matching their colour regardless of MODULES order.
  - DEFAULT_RADII = [214, 188, 162, 136, 110, 84] matches the deployed
    fallback_manager.py geometry.
  - draw_hub=False lets the converged dashboard composite its own
    central button + pod cluster on top.

- `DashboardCanvas.paint_radar_concentric` — thin wrapper, single source
  of truth verified by a test that asserts canvas-vs-painter byte-equal.

- `RoundDashboard.layout(metrics, phase=0.0)` and
  `SquareDashboard.layout(metrics, phase=0.0)` — phase=0 preserves the
  static still-frame contract for tests / callers that don't drive
  animation. RING_RADII bumped to the deployed [214..84] geometry on
  both classes so the converged left-half look matches the round.

- `secubox_eye_square_kiosk.__main__` drives
  `phase = (time.monotonic() * RADAR_RPM / 60) % 1` at 12 RPM, matching
  fallback_manager._sweep_speed.

Tests: 8 new in secubox_common/tests/test_radar_concentric.py — phase
differentiation, period-1 wrap, default-radii fallback, length-mismatch
guard, hub toggle, sweep_accent range, canvas-helper byte-equal.

Total green: 118 / 118 (36 secubox_common + 78 square kiosk + 4 round).

Out of scope for this commit (deferred)
---------------------------------------
- `fallback_manager.py` migration to the painter (#138 acceptance lists it).
  fallback_manager uses its own brighter MODULES list and stateful
  ConnectionState machinery; converting it requires a colour-palette
  decision the user has not yet signed off on. Will land in a follow-up
  fixup after hardware confirms the square radar visual is what we want.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:25:22 +02:00
8996847745 fixup(square): install secubox-otg-gadget.sh to /usr/local/sbin (ref #135)
The square build script enables secubox-otg-gadget.service but never copies
the composer script to /usr/local/sbin/. On boot the service ExecStarts a
missing file → fails silently → the Pi 4B's USB-C bus stays unconfigured →
MOCHAbin/host see no enumeration (zero dmesg events, xhci setup timeouts).

The round build copies it at build-eye-remote-image.sh:618; bringing square
to parity. The script itself lives in remote-ui/common/shell/, already
shared between the two images.

Hardware-confirmed: Pi 4B booted and rendered the dashboard but MOCHAbin
got no USB events whatsoever on either a hub-deep port or a direct port,
across cable swaps. Root cause was the missing composer, not cabling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:41:06 +02:00
f4acd5a9ca fixup(square): ship remote-ui/common/assets/icons to /var/www/common/assets (ref #135)
The square build script shipped remote-ui/common/python/ but skipped
remote-ui/common/assets/. secubox_common.icons.load_module_icon resolves
icons at /var/www/common/assets/icons/ — when that path is missing on the
deployed image, every module pod falls back to a first-letter placeholder.

The round build script already does this at line 925; bringing square to
parity. Fails the build (exit 2) if no *-48.png icons land — same
shape as the existing python-package guard right above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:06:03 +02:00
387fabb4f3 fixup(common): pod_size=48 to match deployed icon sizes (ref #135)
Module icons were falling back to first-letter placeholders because
SquareDashboard/RoundDashboard called paint_pod_cluster(pod_size=40)
while load_module_icon only ships sizes 22/48/96/128. Bump pod_size
to 48 and radius from 70 to 78 so the pod inner edge (54) stays clear
of the central button (44).

Confirmed on Pi 4B square hardware: pods now render with full icons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:50:08 +02:00
5546169971 build(systemd): PYTHONPATH=/var/www/common/python for both kiosks (ref #135) 2026-05-15 07:45:02 +02:00
c9539f8ba6 build(images): ship secubox_common to /var/www/common/python/ on both images (ref #135) 2026-05-15 07:44:55 +02:00
b57891941b refactor(round): fb_dashboard.draw_dashboard delegates to RoundDashboard (ref #135) 2026-05-15 07:41:54 +02:00
39769c318b feat(round): RoundDashboard via secubox_common (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 07:37:56 +02:00
eeb455b673 test(square): cross-form-factor secubox_common API stability test (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 07:35:12 +02:00
4aa36f104e fixup(square): __main__ drop dead touch_input plumbing (ref #135)
T15's commit kept find_touch_devices()/read_events() in the loop with
a `pass` body — the implementer recognised it as duplicate input but
preserved it to keep the call shape. PointerInput already discovers
every device exposing BTN_LEFT or BTN_TOUCH (T12), which covers USB
mouse, USB touchpad, AND the 7" DSI touchscreen in one path. Calling
read_events on the same fds in parallel would consume events from
the shared device stream without dispatching them.

Drop the empty loop and the four unused imports (TouchEvent, classify,
find_touch_devices, read_events). Leave touch_input.py on disk — it
still has tests and might be useful for a different scenario, but
the kiosk main loop no longer uses it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:33:00 +02:00
626e68904f feat(square): __main__ wires SquareDashboard + PointerInput + cursor overlay (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 07:28:06 +02:00
4e03649954 refactor(square): theme + modules_table become re-export shims for secubox_common (ref #135)
theme.py becomes a thin re-export of secubox_common.theme — keeps
`from .theme import COSMOS_BLACK` etc. working in tabs/* without
sweeping import rewrites in this PR. Also exposes DEFAULT_FONT =
load_default_font(12) at module level for legacy module-level imports.

modules_table.py becomes a re-export of secubox_common.modules. The
legacy in-package Module had `radius` and `unit` fields:
- `radius` was form-factor-specific layout (round Pi Zero W used
  214..149, square uses 200..125 via SquareDashboard.RING_RADII).
  Layout properly lives on the dashboard subclass now, not the
  Module dataclass.
- `unit` was defined but never read in production code.

The legacy test_modules_table.py asserted the old [214,201,...] radii
plus extractor behaviour that is fully duplicated in test_modules.py
under secubox_common. Removed as part of the same commit.

Full square/ kiosk suite still 73 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:25:18 +02:00
d2ec163912 fixup(square): cursor sprite uses theme.GOLD_HERMETIC, real test asserts (ref #135)
Code review on T13 flagged four items:
1. Off-canvas guard used hardcoded 12/16 magic numbers; refactor to
   _W/_H constants so the guard stays coupled to the polygon shape.
2. _OUTLINE comment claimed GOLD_HERMETIC but duplicated the hex —
   import theme.GOLD_HERMETIC so palette changes propagate.
3. test_cursor_clamped_to_image_bounds had a comment but no assert —
   silent green even if draw_cursor were deleted. Add an assertion
   that at least one pixel in the clipped 5×5 corner is non-black.
4. test_cursor_negative_coords_dont_crash had no assert either —
   pin the invariant that the canvas is untouched on no-op return.

Also added the sys.path bootstrap line to the test file so the
secubox_common import resolves on dev hosts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:20:20 +02:00
c214c946c2 feat(square): cursor.py — 12×16 arrow sprite for pointer overlay (ref #135) 2026-05-15 07:17:30 +02:00
50a687654c fixup(square): pointer_input motion-event contract + log level (ref #135)
Code review on T12 flagged five items:
1. _handle_evdev_event was silent on REL/ABS but _drain_test_queue
   emitted "motion" events — tests asserted motion presence which
   only worked through the test path. Real Pi 4B + USB mouse would
   never see motion events. Align both paths: emit one motion event
   per axis update from the real path too. T15 ignores motion events,
   so no downstream breakage.
2. log.warning("pointer devices found: ...") fires on every clean
   boot, polluting the log. Switch to log.info for the populated
   case and keep log.warning only when zero devices are found (the
   genuinely unexpected condition).
3. except (OSError, PermissionError) — PermissionError is a subclass
   of OSError, redundant. Drop it.
4. import os, fcntl was inside _discover_devices (PEP 8 E401 +
   non-top-level). Hoist both to module top.
5. _last_motion = 0.0 default keeps the cursor hidden until first
   motion — intentional but non-obvious; add a one-line comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:15:52 +02:00
75991ec5b0 feat(square): pointer_input — mouse + touchpad via python-evdev (ref #135) 2026-05-15 07:08:41 +02:00
238304f351 fixup(square): drop dead tm var, clarify paint_background omission (ref #135)
Code review on T11 flagged two housekeeping items:
1. test_kiosk_smoke.py kept TransportManager assignment from the old
   pre-fixup smoke test, but the new SquareDashboard path doesn't
   need it — F841 dead-var noise. Drop both the import and the
   unused assignment.
2. SquareDashboard.layout() skips paint_background because Image.new()
   already fills with the same colour. Add a one-line comment so
   future readers don't wonder why a base-class primitive went
   unused.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:06:12 +02:00
4c176f8be2 fixup(square): smoke test uses SquareDashboard, drops RingDashboard tap test (ref #135)
T11 deleted ring_dashboard.py but test_kiosk_smoke.py still imported
RingDashboard, breaking collection. Rewrite the compose-frame test to
drive SquareDashboard (which already composes the 800x480 frame
internally) and drop the on_module_tap test — that routing moves
into __main__.py at T15 and will be covered there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:47:07 +02:00
153023b390 feat(square): SquareDashboard via secubox_common; drop ring_dashboard.py (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 06:45:58 +02:00
26e7f45285 fixup(common): paint_alert_ribbon honest fill + docstring (ref #135)
Code review on T10 flagged that the docstring claimed
"semi-transparent fill" but ImageDraw.rectangle on an RGBA image
just overwrites pixels with the raw tuple — no compositing happens.
The kiosk's framebuffer blit then calls .convert("RGB") which drops
the alpha channel entirely, so the visible result was always a
solid black strip regardless of the alpha=200 hint.

Switch fill to (0, 0, 0, 255), rewrite the docstring to describe
what the code actually does, and add an inline comment explaining
why alpha is pinned to 255 (so future authors don't try to "dim"
the ribbon by lowering it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:37:10 +02:00
b15497d668 feat(common): canvas — paint_central_button + paint_alert_ribbon (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 06:25:51 +02:00
8922b2f59e fixup(common): exercise paint_pod_cluster icon-paste path + drop dead asserts (ref #135)
Code review on T9 flagged two test-quality gaps:
1. Both T9 tests used pod_size 20 / 30, which have no matching icon
   files (sizes on disk: 22, 48, 96, 128), so load_module_icon
   always returned None and the icon-paste code path went untested.
   Adds test_paint_pod_cluster_uses_icon_when_available at
   pod_size=48 (real file present) — exercises img.paste(icon, ...)
   on every commit. Calls icons._cache_clear() first to avoid stale
   None cache entries from earlier tests in the session.
2. test_paint_pod_cluster_six_coloured_circles computed dr/dg/db
   colour distances but never asserted on them — dead code that
   misled the contract. Drop the computation; the assertion stays
   the same (loose non-black check, justified because the letter
   fallback may paint the centre pixel white).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:24:19 +02:00
f3fba94bc4 feat(common): canvas — paint_pod_cluster (icon or letter on coloured disc) (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 06:16:45 +02:00
f1e2375e57 fixup(common): type-annotate paint_concentric_arcs modules param (ref #135)
Code review on T8 flagged the `modules` parameter as the only
unannotated arg on `paint_concentric_arcs` — every other arg
(`img`, `center`, `metrics`, `radii`) carries an annotation, so this
one creates an inconsistent surface and gives no IDE assist. Use
`Iterable[Module]` (accurate — zip() only needs an iterable) and
import Module from secubox_common.modules. Required before T9 adds
more callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:15:00 +02:00
1cc3f8ee8e fixup(common): saturate concentric_arcs test metrics for CPU-count portability (ref #135)
T8 commit ea7c2781 left test_paint_concentric_arcs_six_rings_present
brittle: MIND's load_avg_1=4.0 only saturates the arc on a 4-core
machine. On the 20-core dev host _CPU_COUNT=20 makes the MIND ring
fill 20% (4/20), leaving the 3 o'clock sample on the dark track and
failing the colour-distance assertion. Push every metric to 999 so
each ring clamps to 100% regardless of os.cpu_count().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:05:41 +02:00
ea7c278126 feat(common): canvas — paint_concentric_arcs (per-module ring fill) (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 06:04:17 +02:00
43a994fde0 fixup(common): paint_rainbow_ring erase colour + dead test assert (ref #135)
Code review on T7 flagged two plan defects:
1. The inner-disc erase was hardcoded to (0,0,0,255), but both
   RoundDashboard (T17) and SquareDashboard (T11) composite against
   COSMOS_BLACK (8,8,8) — gaps between primitives (r≈22-50,
   r≈90-125) would show a dark-ring artefact. Add a `background`
   parameter defaulting to theme.COSMOS_BLACK so callers blend
   correctly.
2. test_paint_rainbow_ring_pixels_in_band_are_colored had an
   always-false guard `if 240 + 250 < 480` (490 < 480 = False), so
   the outer-radius assertion was dead code. Use radius 238 instead
   (x=478 in-bounds) and drop the guard — the outside pixel stays
   at the fixture's initial (0,0,0) because rainbow_ring never
   touches it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:01:18 +02:00
74c5076b60 feat(common): canvas — paint_rainbow_ring (HSV hue rotation) (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 05:53:42 +02:00
c31c61bf3b feat(common): canvas.py — DashboardCanvas base + paint_background (ref #135) 2026-05-15 05:48:43 +02:00
8809104760 feat(common): icons.py — module icon loader with multi-path resolution (closes round/ ICONS_DIR bug, ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 05:45:19 +02:00
50e4c05a67 fixup(common): MIND extract divides load_avg by os.cpu_count() (ref #135)
Code review on T4 flagged the hardcoded /4.0 divisor: on Pi Zero W
(single-core, round/ target), load_avg=1.0 would render the MIND
arc at only 25% fill even though the CPU is fully saturated.
Reading os.cpu_count() at import time makes the formula self-adapt
per device — round/ (1 core), square/ Pi 4B/400 (4 cores) — without
any per-platform branching.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 02:01:01 +02:00
33fb2a20b0 feat(common): modules.py — canonical Hamiltonian module table (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 01:51:37 +02:00
0f8498a37a fixup(common): remove dead ImageFont import from test_theme (ref #135)
Code review flagged: test_theme.py imported PIL.ImageFont but never
referenced it directly — fonts come back through theme.load_default_font().
F401 noise; clean up before downstream lint runs catch it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:41:17 +02:00
847af3e563 feat(common): theme.py — palette constants + DEFAULT_FONT loader (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:35:18 +02:00
7b0f5306f5 fixup(common): complete SPDX header (ref #135)
Code review on T2 flagged the truncated 1-line SPDX header (project
canon is 4 lines: SPDX + Copyright + License + LICENCE.md ref). The
plan's code blocks propagated the short form; restoring the full
header now so it doesn't ripple through T3-T18+.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:33:17 +02:00
f552411f96 feat(common): seed secubox_common package skeleton (ref #135) 2026-05-14 15:16:42 +02:00
47 changed files with 2605 additions and 3370 deletions

View File

@ -1,117 +1,5 @@
# WIP — Work In Progress
*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) |
*Mis à jour : 2026-05-14*
---
@ -132,24 +20,11 @@ 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`)
### Hardware gates — 3 of 4 closed (2026-05-15)
### Followups
| 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.
- **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.
---

View File

@ -1,46 +0,0 @@
# 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.

View File

@ -1,316 +0,0 @@
# 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** | 23 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 58 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.

View File

@ -1,10 +1,3 @@
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)

View File

@ -1,5 +1,5 @@
Source: secubox-mail-lxc
Section: oldlibs
Section: admin
Priority: optional
Maintainer: Gerald KERMA <devel@cybermind.fr>
Build-Depends: debhelper-compat (= 13)
@ -7,8 +7,15 @@ Standards-Version: 4.6.2
Package: secubox-mail-lxc
Architecture: all
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.
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.

View File

@ -1,18 +1,9 @@
#!/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
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
systemctl daemon-reload
systemctl enable secubox-mail-lxc.service || true
systemctl start secubox-mail-lxc.service || true
fi
#DEBHELPER#
exit 0

View File

@ -1,5 +1,9 @@
#!/bin/sh
set -e
# Transitional package — nothing to undo.
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
#DEBHELPER#
exit 0

View File

@ -1,12 +1,14 @@
#!/usr/bin/make -f
# Transitional package — ships no files. dh handles the metadata only.
%:
dh $@
override_dh_auto_install:
# intentionally empty — no payload in transitional package
:
override_dh_installsystemd:
# do not install/enable the old secubox-mail-lxc.service
:
# 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

View File

@ -1,3 +1,6 @@
# /etc/nginx/secubox.d/mail-lxc.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.
# 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;
}

View File

@ -19,27 +19,17 @@ from pydantic import BaseModel
from secubox_core.auth import require_jwt
from secubox_core.config import get_config
app = FastAPI(title="SecuBox Mail", version="2.2.0")
app = FastAPI(title="SecuBox Mail", version="1.8.0")
config = get_config("mail")
# 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
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")
DOMAIN = config.get("domain", "secubox.local")
HOSTNAME = config.get("hostname", "mail")
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
MAIL_IP = config.get("mail_ip", "192.168.255.30")
WEBMAIL_PORT = config.get("webmail_port", 8027)
def run_cmd(cmd: list, timeout: int = 30) -> tuple:
@ -911,31 +901,21 @@ 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,
# 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,
"mail_ip": MAIL_IP,
"webmail_port": WEBMAIL_PORT,
"mail_container": CONTAINER,
"webmail_container": CONTAINER,
"mail_container": MAIL_CONTAINER,
"webmail_container": WEBMAIL_CONTAINER,
"data_path": str(DATA_PATH),
}
@ -952,19 +932,14 @@ async def update_settings(settings: SettingsUpdate):
key, val = line.split("=", 1)
current[key.strip()] = val.strip().strip('"')
# Update values. Canonical keys take precedence; legacy fields are
# accepted for one release and mapped to canonical.
# Update values
if settings.domain:
current["domain"] = settings.domain
if settings.hostname:
current["hostname"] = settings.hostname
if settings.lxc_ip:
current["lxc_ip"] = settings.lxc_ip
elif settings.mail_ip:
current["lxc_ip"] = settings.mail_ip
if settings.mail_ip:
current["mail_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

View File

@ -1,9 +0,0 @@
"""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))

View File

@ -1,93 +0,0 @@
"""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]}"
)

View File

@ -1,21 +1,21 @@
# SecuBox Mail Server Configuration — Phase 1 rev. 2 (single LXC, canonical paths)
# SecuBox Mail Server Configuration
[mail]
enabled = true
domain = "secubox.local"
hostname = "mail"
data_path = "/srv/mail"
lxc_path = "/srv/lxc"
# 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"
# Mail server container
mail_container = "mailserver"
mail_ip = "192.168.255.30"
# Webmail is served by the same LXC; this URL is the host-side proxy target
webmail_url = "https://webmail.gk2.secubox.in"
# Webmail container
webmail_container = "roundcube"
webmail_ip = "192.168.255.31"
webmail_port = 8027
# SSL settings
ssl_provider = "acme" # acme | manual | none
ssl_provider = "acme" # acme, manual, none
acme_email = ""

View File

@ -1,20 +1,3 @@
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

View File

@ -8,8 +8,6 @@ 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.

View File

@ -1,23 +1,9 @@
#!/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

View File

@ -1,38 +0,0 @@
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

View File

@ -1,66 +0,0 @@
# 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

View File

@ -1,299 +0,0 @@
#!/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"
}

View File

@ -1,83 +0,0 @@
#!/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" -- "$@"
}

View File

@ -1,56 +0,0 @@
#!/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 ]
}

View File

@ -1,67 +0,0 @@
#!/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 "$@"

View File

@ -3,11 +3,11 @@
# Unified mail + webmail management for Debian
# Three-fold architecture: Components, Status, Access
VERSION="2.2.0"
VERSION="1.3.0"
CONFIG_FILE="/etc/secubox/mail.toml"
LIB_DIR="/usr/lib/secubox/mail/lib"
DATA_PATH="/data/volumes/mail"
LXC_PATH="/var/lib/lxc"
DATA_PATH="/srv/mail"
LXC_PATH="/srv/lxc"
# Colors
RED='\033[0;31m'
@ -41,12 +41,10 @@ config_get() {
DOMAIN=$(config_get "domain" "secubox.local")
HOSTNAME=$(config_get "hostname" "mail")
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
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")
# ============================================================================
# LXC Helpers
@ -79,8 +77,8 @@ cmd_components() {
local webmail_installed=false
local webmail_running=false
lxc_exists "$CONTAINER" && mail_installed=true
lxc_running "$CONTAINER" && mail_running=true
lxc_exists "$MAIL_CONTAINER" && mail_installed=true
lxc_running "$MAIL_CONTAINER" && mail_running=true
lxc_exists "$WEBMAIL_CONTAINER" && webmail_installed=true
lxc_running "$WEBMAIL_CONTAINER" && webmail_running=true
@ -90,12 +88,12 @@ cmd_components() {
{
"name": "Mail Server",
"type": "lxc",
"container": "$CONTAINER",
"container": "$MAIL_CONTAINER",
"description": "Postfix + Dovecot mail server",
"installed": $mail_installed,
"running": $mail_running,
"ports": [25, 587, 465, 143, 993, 110, 995],
"ip": "$LXC_IP"
"ip": "$MAIL_IP"
},
{
"name": "Webmail",
@ -211,13 +209,13 @@ cmd_user_repair() {
local email="$1"
[ -z "$email" ] && { echo "Usage: mailctl user-repair <email>"; return 1; }
if ! lxc_running "$CONTAINER"; then
if ! lxc_running "$MAIL_CONTAINER"; then
error "Mail container not running"
return 1
fi
log "Repairing mailbox for $email..."
lxc_attach "$CONTAINER" doveadm force-resync -u "$email" '*'
lxc_attach "$MAIL_CONTAINER" doveadm force-resync -u "$email" '*'
log "Mailbox repair complete"
}
@ -226,13 +224,13 @@ cmd_user_repair() {
# ============================================================================
cmd_fix_ports() {
if ! lxc_running "$CONTAINER"; then
if ! lxc_running "$MAIL_CONTAINER"; then
error "Mail container not running"
return 1
fi
log "Checking mail ports..."
local ports=$(lxc_attach "$CONTAINER" netstat -tln 2>/dev/null)
local ports=$(lxc_attach "$MAIL_CONTAINER" netstat -tln 2>/dev/null)
local all_ok=true
for port in 25 587 465 143 993; do
@ -246,7 +244,7 @@ cmd_fix_ports() {
if [ "$all_ok" = "false" ]; then
warn "Some ports are not listening. Attempting restart..."
lxc_attach "$CONTAINER" /opt/start-mail.sh &
lxc_attach "$MAIL_CONTAINER" /opt/start-mail.sh &
sleep 3
log "Services restarted"
else
@ -290,9 +288,9 @@ cmd_uninstall() {
systemctl stop secubox-mail 2>/dev/null
log "Removing containers..."
lxc-stop -n "$CONTAINER" 2>/dev/null
lxc-stop -n "$MAIL_CONTAINER" 2>/dev/null
lxc-stop -n "$WEBMAIL_CONTAINER" 2>/dev/null
rm -rf "$LXC_PATH/$CONTAINER"
rm -rf "$LXC_PATH/$MAIL_CONTAINER"
rm -rf "$LXC_PATH/$WEBMAIL_CONTAINER"
log "Mail server removed. Data preserved in $DATA_PATH"
@ -332,15 +330,15 @@ cmd_status() {
echo "Configuration:"
echo " Domain: $DOMAIN"
echo " Hostname: $HOSTNAME.$DOMAIN"
echo " Mail IP: $LXC_IP"
echo " Mail IP: $MAIL_IP"
echo ""
# Mail server status
echo "Mail Server ($CONTAINER):"
if lxc_running "$CONTAINER"; then
echo "Mail Server ($MAIL_CONTAINER):"
if lxc_running "$MAIL_CONTAINER"; then
echo -e " Status: ${GREEN}Running${NC}"
# Check ports
local ports=$(lxc_attach "$CONTAINER" netstat -tln 2>/dev/null)
local ports=$(lxc_attach "$MAIL_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}"
@ -348,7 +346,7 @@ cmd_status() {
echo -e " Port $port: ${RED}closed${NC}"
fi
done
elif lxc_exists "$CONTAINER"; then
elif lxc_exists "$MAIL_CONTAINER"; then
echo -e " Status: ${YELLOW}Stopped${NC}"
else
echo -e " Status: ${RED}Not installed${NC}"
@ -657,8 +655,8 @@ cmd_dkim() {
cmd_logs() {
local lines="${1:-50}"
if lxc_running "$CONTAINER"; then
lxc_attach "$CONTAINER" tail -n "$lines" /var/log/mail.log 2>/dev/null
if lxc_running "$MAIL_CONTAINER"; then
lxc_attach "$MAIL_CONTAINER" tail -n "$lines" /var/log/mail.log 2>/dev/null
else
error "Mail container not running"
fi
@ -726,57 +724,6 @@ 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
# ============================================================================
@ -790,7 +737,6 @@ 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

View File

@ -1,10 +1,395 @@
#!/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.
#!/bin/bash
# SecuBox Roundcube Webmail Controller
# LXC container management for Roundcube (Debian bookworm)
set -euo pipefail
echo "[roundcubectl] DEPRECATED — forwarding to mailctl (will be removed in 3.0)" >&2
exec /usr/sbin/mailctl "$@"
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

View File

@ -1,18 +0,0 @@
# 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"
}

View File

@ -1,21 +0,0 @@
#!/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"* ]]
}

View File

@ -1,41 +0,0 @@
#!/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"
}

View File

@ -1,73 +0,0 @@
#!/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 ]
}

View File

@ -1,10 +1,3 @@
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)

View File

@ -1,5 +1,5 @@
Source: secubox-webmail-lxc
Section: oldlibs
Section: admin
Priority: optional
Maintainer: Gerald KERMA <devel@cybermind.fr>
Build-Depends: debhelper-compat (= 13)
@ -7,8 +7,15 @@ Standards-Version: 4.6.2
Package: secubox-webmail-lxc
Architecture: all
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.
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.

View File

@ -1,18 +1,9 @@
#!/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
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
systemctl daemon-reload
systemctl enable secubox-webmail-lxc.service || true
systemctl start secubox-webmail-lxc.service || true
fi
#DEBHELPER#
exit 0

View File

@ -1,5 +1,9 @@
#!/bin/sh
set -e
# Transitional package — nothing to undo.
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
#DEBHELPER#
exit 0

View File

@ -1,10 +1,14 @@
#!/usr/bin/make -f
# Transitional package — ships no files.
%:
dh $@
override_dh_auto_install:
:
override_dh_installsystemd:
:
# 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

View File

@ -1,3 +1,6 @@
# /etc/nginx/secubox.d/webmail-lxc.conf
# DEPRECATED in secubox-webmail-lxc 2.2 — webmail LXC management folded
# into secubox-mail's /api/v1/mail/. Empty for one release.
# 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;
}

View File

@ -1,10 +1,3 @@
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

View File

@ -1,5 +1,5 @@
Source: secubox-webmail
Section: oldlibs
Section: admin
Priority: optional
Maintainer: Gerald KERMA <devel@cybermind.fr>
Build-Depends: debhelper-compat (= 13)
@ -7,7 +7,15 @@ Standards-Version: 4.6.2
Package: secubox-webmail
Architecture: all
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.
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.

View File

@ -1,18 +1,9 @@
#!/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
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
systemctl daemon-reload
systemctl enable secubox-webmail.service || true
systemctl start secubox-webmail.service || true
fi
#DEBHELPER#
exit 0

View File

@ -1,5 +1,9 @@
#!/bin/sh
set -e
# Transitional package — nothing to undo.
if [ "$1" = "remove" ]; then
systemctl stop secubox-webmail.service || true
systemctl disable secubox-webmail.service || true
systemctl reload nginx 2>/dev/null || true
fi
#DEBHELPER#
exit 0

View File

@ -1,10 +1,14 @@
#!/usr/bin/make -f
# Transitional package — ships no files.
%:
dh $@
override_dh_auto_install:
:
override_dh_installsystemd:
:
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

View File

@ -1,4 +1,6 @@
# /etc/nginx/secubox.d/webmail.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.
# Installed by secubox-webmail package
location /api/v1/webmail/ {
proxy_pass http://unix:/run/secubox/webmail.sock:/;
include /etc/nginx/snippets/secubox-proxy.conf;
}

View File

@ -284,14 +284,9 @@ gadget_start() {
log "Activation sur UDC: ${udc}"
echo "$udc" > UDC
# 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.
# 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)
local retry=0
while [[ ! -d /sys/class/net/usb1 ]] && [[ $retry -lt 10 ]]; do
sleep 0.5
@ -299,9 +294,9 @@ gadget_start() {
done
if [[ -d /sys/class/net/usb1 ]]; then
log "Interface usb1 (deuxième fonction réseau du gadget) créée"
log "Interface usb1 (ECM) 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

View File

@ -704,12 +704,15 @@ 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
# 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.
# 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
# USB network script (handles both usb0 and usb1 - ECM may create either)
mkdir -p "$ROOT_MNT/usr/local/bin"
@ -784,9 +787,8 @@ if ! id secubox &>/dev/null; then
echo "secubox:secubox2026" | chpasswd
fi
# 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
# Add to groups
usermod -aG video,input,gpio,i2c,spi,audio secubox 2>/dev/null || true
# Enable lightdm and nginx
systemctl enable lightdm 2>/dev/null || true

View File

@ -0,0 +1,5 @@
allow-hotplug usb0
iface usb0 inet static
address 10.55.0.2
netmask 255.255.255.252
gateway 10.55.0.1

View File

@ -1,95 +0,0 @@
#!/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"