secubox-deb/packages/secubox-mail/sbin/mailctl
CyberMind 6aa5e38d7d
Mail Phase 2 — Rspamd migration (closes #153) (#160)
* test(mail): Phase 2 scaffolding — rspamd.sh + templates + routers + bats baseline (ref #153)

* feat(mail): rspamd.sh — install_rspamd + re-entry guard (ref #153)

* feat(mail): Rspamd config templates (9 .conf files) — DKIM/ARC/DMARC/greylist/ratelimit (ref #153)

* feat(mail): lib/mail/rspamd.sh — configure helpers + DKIM keygen + D9 purge gate (ref #153)

* feat(mail): Milestone C — rspamd_client + /rspamd/* router + legacy shims (ref #153)

- rspamd_client.py: httpx wrapper around the Rspamd HTTP controller
  (read /stat /history /graph; write /reload /learnspam /learnham).
- routers/rspamd.py: 11 new endpoints under /api/v1/mail/rspamd/*
  (status, history, scores, reload, learn-{spam,ham}, whitelist CRUD,
  dkim/{domain}/{status,keygen}).
- routers/legacy.py: 14 deprecation shims for the Phase 1 /dkim/*
  /spam/* /grey/* surface. Each emits X-Deprecated-Endpoint: rspamd
  and forwards to the Rspamd equivalent. Removed in v3.0.
- main.py: drops the 14 inline legacy handlers and mounts the two
  routers via app.include_router(). 62/62 Phase 1 endpoint pytest
  still passes.

* feat(mail): Milestone D — mailctl cmd_rspamd dispatcher (ref #153)

Adds rspamd subcommand to mailctl: install / start / stop / restart /
reload / status / dkim-keygen / dns-records / learn-spam / learn-ham /
purge-legacy. The install path calls lib/mail/rspamd.sh helpers in
order: install_rspamd → configure_rspamd_milter → configure_rspamd_controller
→ configure_rspamd_dkim → configure_rspamd_postfix_milter.

The purge-legacy verb requires Rspamd to respond healthy on :11334
before removing SA + OpenDKIM (D9 safety gate).

* feat(mail): Milestone E — install adds rspamd; mail.toml [mail.rspamd]; postinst secrets (ref #153)

- install_mail_packages: now installs rspamd + redis-server + enables
  postfix/dovecot/rspamd at LXC boot (Phase 1 follow-up). Drops Apache/
  Roundcube — they live in the roundcube LXC after the rev. 3 split.
- mail.toml: [mail.rspamd] section (greylist, bayes_autolearn,
  ratelimit_outbound=200/h/user, web_ui_host). horde_url added.
- debian/postinst: on upgrade <2.3, generate /etc/secubox/secrets/
  rspamd-controller.pw (32-byte random) and mkdir /data/volumes/mail/
  rspamd/{dkim,bayes,history,settings} chown 100110 (_rspamd in unpriv).

* feat(mail): Milestone F — rspamd-route-sync-patch deploy helper (ref #153)

Idempotent one-shot script run at deploy time:
1. Removes 10.100.0.{10,11,12} from sync-mitmproxy-routes.sh's
   DEAD_CONTAINER_IPS list so the periodic timer does not reroute
   the mail/horde/roundcube LXC routes to webui.
2. Adds rspamd.gk2.secubox.in → [10.100.0.10, 11334] to both the
   host's /srv/mitmproxy/haproxy-routes.json and the mitmproxy LXC
   copy, then restarts the mitmdump worker.

No source-side nginx vhost added: rspamd UI goes through the
existing HAProxy → mitmproxy_inspector → 10.100.0.10:11334 path.

* feat(mail): bump secubox-mail to 2.3.0 (Phase 2 Rspamd) (ref #153)

* test(mail): Phase 2 13-gate acceptance smoke (ref #153)

Run with: bash tests/scripts/test-mail-phase2-acceptance.sh root@admin.gk2.secubox.in

Gates 1-3 are source-side (parses, pytest, deb path coverage).
Gates 4-11 are board-side (Rspamd listening, Postfix milter wired,
DKIM key generated, modules loaded, web UI via WAF).
Gates 12-13 are post-cutover (legacy purged + Phase 1 regression
check — 5 secubox.in users intact, webmail still WAF-routed).

Every board call uses timeout — Phase 1 lesson, never raw pipes.

* fix(mail): debian/rules ships templates/rspamd/ (path-coverage fix, ref #153)

Phase 1 lesson redux: debian/rules silently misses files unless every
new source-tree subdir gets its explicit cp line. Adds the templates/
rspamd/ install line so the 9 local.d configs + the postfix milter
snippet land at /usr/lib/secubox/mail/templates/rspamd/.

* fix(mail): live-deploy fixes — _rspamd uid via lxc-attach + smoke gate 7/12 (ref #153)

Two issues surfaced during Phase 2 live deploy on admin.gk2.secubox.in:

1. configure_rspamd_controller hardcoded chown to host uid 100110, assuming
   _rspamd is uid 110 inside the LXC. On this Debian 12 image _rspamd is
   uid 107 — rspamd crash-looped with "Permission denied" on secrets.inc.
   Fix: chown via `lxc-attach -- chown _rspamd:_rspamd` so the kernel's
   idmap maps the right uid regardless of image package set. Mode 0640
   instead of 0600 so the worker (uid _rspamd) can read it even if launched
   from a slightly different group context.

2. Smoke gate 7 grepped for the literal string "rspamd" in the first 5
   lines of `rspamc stat`, but rspamc only prints "Results for command:
   stat" + counters. Fix: grep for an always-present stat marker
   ("Messages scanned" or "Pools allocated") with a wider head -10 window.

3. Smoke gate 12 ran `dpkg -l opendkim spamassassin` under `set -e` and
   silently aborted the smoke when the named packages were unknown (the
   success case after purge). Fix: append `|| true` so the grep below is
   the actual gate.

All 13 gates green on admin.gk2.secubox.in after these fixes + the manual
deploy steps (DKIM keygen via lxc-attach, bind-mount entries, HAProxy +
mitmproxy route map for rspamd.gk2.secubox.in).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(mail): rspamd lib + sbin write through live LXC (ref #153)

Phase 2 deploy on admin.gk2.secubox.in surfaced three brittleness points
that the live-deploy fixes patched ad-hoc:

1. configure_rspamd_milter wrote to ${LXC_BASE}/<container>/rootfs/etc/
   rspamd/local.d/ — but on this board the runtime LXC mounts a different
   rootfs (/data/lxc/<container>/rootfs/ per lxc.rootfs.path). Files written
   to the host-side guess landed in a stale shell the running LXC didn't
   see, and the first install's local.d was empty.

2. configure_rspamd_controller had the same path-guess issue plus
   hardcoded chown 100110:100110 (had to be patched in commit 637b2221
   because _rspamd is uid 107 on this image, not 110).

3. rspamd_keygen called rspamadm on the host PATH — rspamadm only exists
   inside the rspamd-installed LXC, so the function errored out on every
   install. The Phase 2 deploy worked around it manually.

This refactor makes all three write THROUGH the live LXC via lxc-attach:
- Each local.d template is streamed in with `lxc-attach -- tee` (kernel
  resolves the rootfs path; idmap applies to the resulting file uid).
- secrets.inc + worker-controller.inc likewise written via tee, then
  chown'd inside the LXC to _rspamd:_rspamd (kernel maps to the correct
  outside-LXC subuid regardless of image).
- rspamd_keygen takes a `container` arg, runs `rspamadm dkim_keygen`
  inside the LXC, writes the keypair to /etc/rspamd-keys/<domain>/ (the
  bind-mounted path; falls back to /var/lib/rspamd/dkim/ if the bind
  mount isn't active yet), and mirrors the DNS TXT to the host data dir
  for DNS-publishing tooling.
- mailctl's `dkim-keygen` subcommand now delegates to the lib function.
- rspamd-route-sync-patch.sh verifies each write (reads back + asserts
  the entry equals expected) and fails loudly on mismatch — the Phase 2
  deploy needed a manual second pass for the mitmproxy LXC copy.

Pre-conditions: configure_rspamd_milter and configure_rspamd_controller
now require the LXC to be RUNNING (added an explicit lxc-info guard).
This matches the cmd_rspamd install path which starts the LXC via
install_rspamd before configuring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:21:23 +02:00

950 lines
29 KiB
Bash
Executable File

#!/bin/bash
# SecuBox Mail Server Controller
# Unified mail + webmail management for Debian
# Three-fold architecture: Components, Status, Access
VERSION="2.2.0"
CONFIG_FILE="/etc/secubox/mail.toml"
LIB_DIR="/usr/lib/secubox/mail/lib"
DATA_PATH="/data/volumes/mail"
LXC_PATH="/var/lib/lxc"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log() { echo -e "${GREEN}[MAIL]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
# Load libraries
[ -f "$LIB_DIR/container.sh" ] && . "$LIB_DIR/container.sh"
[ -f "$LIB_DIR/users.sh" ] && . "$LIB_DIR/users.sh"
# ============================================================================
# Configuration (TOML parsing)
# ============================================================================
config_get() {
local key="$1"
local default="$2"
if [ -f "$CONFIG_FILE" ]; then
local value=$(grep "^${key} *=" "$CONFIG_FILE" 2>/dev/null | head -1 | cut -d= -f2- | tr -d ' "')
[ -n "$value" ] && echo "$value" || echo "$default"
else
echo "$default"
fi
}
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
# ============================================================================
# LXC Helpers
# ============================================================================
lxc_running() {
local name="$1"
lxc-info -n "$name" 2>/dev/null | grep -q "State:.*RUNNING"
}
lxc_exists() {
local name="$1"
[ -d "$LXC_PATH/$name/rootfs" ]
}
lxc_attach() {
local name="$1"
shift
lxc-attach -n "$name" -- "$@"
}
# ============================================================================
# THREE-FOLD ARCHITECTURE: Components (What)
# ============================================================================
cmd_components() {
# Output JSON for API consumption
local mail_installed=false
local mail_running=false
local webmail_installed=false
local webmail_running=false
lxc_exists "$CONTAINER" && mail_installed=true
lxc_running "$CONTAINER" && mail_running=true
lxc_exists "$WEBMAIL_CONTAINER" && webmail_installed=true
lxc_running "$WEBMAIL_CONTAINER" && webmail_running=true
cat <<EOF
{
"components": [
{
"name": "Mail Server",
"type": "lxc",
"container": "$CONTAINER",
"description": "Postfix + Dovecot mail server",
"installed": $mail_installed,
"running": $mail_running,
"ports": [25, 587, 465, 143, 993, 110, 995],
"ip": "$LXC_IP"
},
{
"name": "Webmail",
"type": "lxc",
"container": "$WEBMAIL_CONTAINER",
"description": "Roundcube webmail interface",
"installed": $webmail_installed,
"running": $webmail_running,
"port": $WEBMAIL_PORT
},
{
"name": "User Database",
"type": "file",
"path": "$DATA_PATH/config/users",
"description": "Dovecot passwd-file users",
"installed": true
},
{
"name": "SSL Certificates",
"type": "file",
"path": "$DATA_PATH/ssl/fullchain.pem",
"description": "TLS certificates for mail server",
"installed": $([ -f "$DATA_PATH/ssl/fullchain.pem" ] && echo true || echo false)
}
]
}
EOF
}
# ============================================================================
# THREE-FOLD ARCHITECTURE: Access (How to connect)
# ============================================================================
cmd_access() {
local fqdn="$HOSTNAME.$DOMAIN"
local ssl_enabled=false
[ -f "$DATA_PATH/ssl/fullchain.pem" ] && ssl_enabled=true
cat <<EOF
{
"domain": "$DOMAIN",
"mail_server": "$fqdn",
"connections": {
"imap": {
"host": "$fqdn",
"port": 993,
"ssl": true,
"starttls_port": 143
},
"smtp": {
"host": "$fqdn",
"port": 587,
"ssl": false,
"starttls": true,
"ssl_port": 465
},
"pop3": {
"host": "$fqdn",
"port": 995,
"ssl": true
}
},
"webmail": {
"url": "https://webmail.$DOMAIN",
"local_url": "http://localhost:$WEBMAIL_PORT"
},
"ssl_enabled": $ssl_enabled,
"client_configs": {
"thunderbird": "https://autoconfig.$DOMAIN/mail/config-v1.1.xml",
"outlook": "https://autodiscover.$DOMAIN/autodiscover/autodiscover.xml",
"apple": "https://$fqdn/$DOMAIN.mobileconfig"
}
}
EOF
}
# ============================================================================
# DNS Setup
# ============================================================================
cmd_dns_setup() {
local fqdn="$HOSTNAME.$DOMAIN"
log "Generating DNS records for $DOMAIN..."
echo ""
echo "============================================"
echo " DNS Records for $DOMAIN"
echo "============================================"
echo ""
echo "MX Record:"
echo " $DOMAIN. IN MX 10 $fqdn."
echo ""
echo "A Records:"
echo " $HOSTNAME.$DOMAIN. IN A <YOUR_IP>"
echo " webmail.$DOMAIN. IN A <YOUR_IP>"
echo ""
echo "SPF Record:"
echo " $DOMAIN. IN TXT \"v=spf1 mx a:$fqdn -all\""
echo ""
echo "DKIM Record (generate with: mailctl dkim-setup):"
echo " default._domainkey.$DOMAIN. IN TXT \"v=DKIM1; k=rsa; p=...\""
echo ""
echo "DMARC Record:"
echo " _dmarc.$DOMAIN. IN TXT \"v=DMARC1; p=quarantine; rua=mailto:postmaster@$DOMAIN\""
echo ""
}
# ============================================================================
# User Repair (mailbox resync)
# ============================================================================
cmd_user_repair() {
local email="$1"
[ -z "$email" ] && { echo "Usage: mailctl user-repair <email>"; return 1; }
if ! lxc_running "$CONTAINER"; then
error "Mail container not running"
return 1
fi
log "Repairing mailbox for $email..."
lxc_attach "$CONTAINER" doveadm force-resync -u "$email" '*'
log "Mailbox repair complete"
}
# ============================================================================
# Fix Ports (container port checking)
# ============================================================================
cmd_fix_ports() {
if ! lxc_running "$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 all_ok=true
for port in 25 587 465 143 993; do
if echo "$ports" | grep -q ":$port "; then
echo -e " Port $port: ${GREEN}OK${NC}"
else
echo -e " Port $port: ${RED}NOT LISTENING${NC}"
all_ok=false
fi
done
if [ "$all_ok" = "false" ]; then
warn "Some ports are not listening. Attempting restart..."
lxc_attach "$CONTAINER" /opt/start-mail.sh &
sleep 3
log "Services restarted"
else
log "All ports OK"
fi
}
# ============================================================================
# Install
# ============================================================================
cmd_install() {
[ "$(id -u)" -eq 0 ] || { error "Root required"; return 1; }
log "Installing SecuBox Mail Server (single LXC '$CONTAINER')..."
: "${LIB_DIR:=/usr/lib/secubox/mail/lib}"
if [ ! -f "$LIB_DIR/install.sh" ]; then
local alt="$(dirname "$0")/../lib/mail"
[ -f "$alt/install.sh" ] && LIB_DIR="$alt"
fi
# shellcheck source=/dev/null
source "$LIB_DIR/lxc.sh"
# shellcheck source=/dev/null
source "$LIB_DIR/install.sh"
# Persistent data dirs (host-side, bind-mounted into the LXC)
mkdir -p "$DATA_PATH"/{vmail,config,ssl,backups}
mkdir -p "$LXC_PATH"
# Bootstrap rootfs + render LXC config
if ! lxc_exists "$CONTAINER"; then
LXC_BASE="$LXC_PATH" bootstrap_debian "$CONTAINER"
fi
LXC_BASE="$LXC_PATH" lxc_create_config "$CONTAINER" "$LXC_IP" "$LXC_BRIDGE" "$LXC_GATEWAY"
# Bring the container up so install steps can chroot/apt
LXC_BASE="$LXC_PATH" lxc_start_safely "$CONTAINER" || {
error "container failed to start"; return 1; }
LXC_BASE="$LXC_PATH" install_mail_packages "$CONTAINER"
LXC_BASE="$LXC_PATH" install_webmail_packages "$CONTAINER"
LXC_BASE="$LXC_PATH" DOMAIN="$DOMAIN" HOSTNAME="$HOSTNAME" \
configure_postfix "$CONTAINER"
LXC_BASE="$LXC_PATH" configure_dovecot "$CONTAINER"
LXC_BASE="$LXC_PATH" DOMAIN="$DOMAIN" \
configure_roundcube "$CONTAINER"
log "Installation complete!"
echo ""
echo "Next steps:"
echo " 1. Configure: edit $CONFIG_FILE"
echo " 2. Add users: mailctl user add user@$DOMAIN"
echo " 3. Start: mailctl start"
}
cmd_uninstall() {
warn "This will remove mail containers. Data in $DATA_PATH will be preserved."
read -p "Continue? (yes/no): " confirm
[ "$confirm" != "yes" ] && { echo "Aborted"; return 1; }
log "Stopping services..."
systemctl stop secubox-mail 2>/dev/null
log "Removing containers..."
lxc-stop -n "$CONTAINER" 2>/dev/null
lxc-stop -n "$WEBMAIL_CONTAINER" 2>/dev/null
rm -rf "$LXC_PATH/$CONTAINER"
rm -rf "$LXC_PATH/$WEBMAIL_CONTAINER"
log "Mail server removed. Data preserved in $DATA_PATH"
}
# ============================================================================
# Service Control
# ============================================================================
cmd_start() {
log "Starting mail LXC '$CONTAINER'..."
if lxc_running "$CONTAINER"; then
log "already running"
return 0
fi
if ! lxc_exists "$CONTAINER"; then
error "container '$CONTAINER' not present at $LXC_PATH/$CONTAINER"
return 1
fi
lxc-start -n "$CONTAINER" -d
# Wait up to 10s for RUNNING state.
for _ in 1 2 3 4 5 6 7 8 9 10; do
lxc_running "$CONTAINER" && { log "mail LXC running"; return 0; }
sleep 1
done
error "mail LXC failed to start within 10s"
return 1
}
cmd_stop() {
log "Stopping mail LXC '$CONTAINER'..."
if ! lxc_running "$CONTAINER"; then
log "already stopped"
return 0
fi
lxc-stop -n "$CONTAINER" -t 30 || true
log "mail LXC stopped"
}
cmd_restart() {
cmd_stop
sleep 2
cmd_start
}
cmd_status() {
echo ""
echo "========================================"
echo " SecuBox Mail Server v$VERSION"
echo "========================================"
echo ""
echo "Configuration:"
echo " Domain: $DOMAIN"
echo " Hostname: $HOSTNAME.$DOMAIN"
echo " Mail IP: $LXC_IP"
echo ""
# Mail server status
echo "Mail Server ($CONTAINER):"
if lxc_running "$CONTAINER"; then
echo -e " Status: ${GREEN}Running${NC}"
# Check ports
local ports=$(lxc_attach "$CONTAINER" netstat -tln 2>/dev/null)
for port in 25 587 465 993 995; do
if echo "$ports" | grep -q ":$port "; then
echo -e " Port $port: ${GREEN}listening${NC}"
else
echo -e " Port $port: ${RED}closed${NC}"
fi
done
elif lxc_exists "$CONTAINER"; then
echo -e " Status: ${YELLOW}Stopped${NC}"
else
echo -e " Status: ${RED}Not installed${NC}"
fi
echo ""
# Webmail status
echo "Webmail ($WEBMAIL_CONTAINER):"
if lxc_running "$WEBMAIL_CONTAINER"; then
echo -e " Status: ${GREEN}Running${NC}"
echo " Port: $WEBMAIL_PORT"
elif lxc_exists "$WEBMAIL_CONTAINER"; then
echo -e " Status: ${YELLOW}Stopped${NC}"
else
echo -e " Status: ${RED}Not installed${NC}"
fi
echo ""
# User count
local users_file="$DATA_PATH/config/users"
if [ -f "$users_file" ]; then
local user_count=$(wc -l < "$users_file")
echo "Users: $user_count"
fi
# Storage
if [ -d "$DATA_PATH/mail" ]; then
local storage=$(du -sh "$DATA_PATH/mail" 2>/dev/null | cut -f1)
echo "Storage: ${storage:-0}"
fi
echo ""
}
# ============================================================================
# User Management
# ============================================================================
cmd_sync() {
# Phase 1 rev. 2: persistent config is bind-mounted from
# $DATA_PATH/config into $LXC_PATH/$CONTAINER/rootfs/etc/mail-config,
# so there is no copy step. Rebuild Postfix LMDB indexes if the
# container is running; otherwise it's a no-op until next start.
if lxc_running "$CONTAINER"; then
for map in vmailbox virtual; do
lxc_attach "$CONTAINER" postmap "lmdb:/etc/mail-config/$map" 2>/dev/null || true
done
fi
}
cmd_user() {
local action="${1:-list}"
shift
case "$action" in
add)
local email="$1"
local password="$2"
[ -z "$email" ] && { echo "Usage: mailctl user add <email> [password]"; return 1; }
if [ -z "$password" ]; then
read -s -p "Password: " password
echo ""
fi
user_add "$email" "$password"
cmd_sync
;;
del|delete|remove)
local email="$1"
[ -z "$email" ] && { echo "Usage: mailctl user del <email>"; return 1; }
user_del "$email"
cmd_sync
;;
passwd|password)
local email="$1"
local password="$2"
[ -z "$email" ] && { echo "Usage: mailctl user passwd <email> [password]"; return 1; }
if [ -z "$password" ]; then
read -s -p "New password: " password
echo ""
fi
user_passwd "$email" "$password"
cmd_sync
;;
list)
user_list
;;
*)
echo "User commands:"
echo " user add <email> [pass] Add mail user"
echo " user del <email> Delete mail user"
echo " user list List all users"
echo " user passwd <email> Change password"
;;
esac
}
cmd_alias() {
local action="${1:-list}"
shift
case "$action" in
add)
alias_add "$@"
cmd_sync
;;
del|delete)
alias_del "$@"
cmd_sync
;;
list)
alias_list
;;
*)
echo "Alias commands:"
echo " alias add <alias> <target> Add email alias"
echo " alias del <alias> Delete email alias"
echo " alias list List aliases"
;;
esac
}
# ============================================================================
# Migration from OpenWrt (192.168.255.1)
# ============================================================================
cmd_migrate() {
local source="${1:-192.168.255.1}"
log "Migrating mail data from $source..."
# Check SSH connectivity
if ! ssh -o ConnectTimeout=5 "root@$source" "echo ok" >/dev/null 2>&1; then
error "Cannot connect to $source via SSH"
echo "Ensure SSH key is configured: ssh-copy-id root@$source"
return 1
fi
# Create backup directory
local backup_dir="$DATA_PATH/migration_$(date +%Y%m%d_%H%M%S)"
mkdir -p "$backup_dir"
# Migrate mail data
log "Migrating mailboxes..."
rsync -avz --progress "root@$source:/srv/mailserver/mail/" "$DATA_PATH/mail/"
# Migrate config (users, aliases)
log "Migrating configuration..."
rsync -avz --progress "root@$source:/srv/mailserver/config/" "$DATA_PATH/config/"
# Migrate SSL certificates
log "Migrating SSL certificates..."
rsync -avz --progress "root@$source:/srv/mailserver/ssl/" "$DATA_PATH/ssl/"
# Import UCI config
log "Importing UCI configuration..."
ssh "root@$source" "uci show mailserver" > "$backup_dir/mailserver.uci" 2>/dev/null || true
# Convert UCI to TOML
if [ -f "$backup_dir/mailserver.uci" ]; then
local uci_domain=$(grep "main.domain=" "$backup_dir/mailserver.uci" | cut -d"'" -f2)
local uci_hostname=$(grep "main.hostname=" "$backup_dir/mailserver.uci" | cut -d"'" -f2)
if [ -n "$uci_domain" ]; then
log "Setting domain: $uci_domain"
sed -i "s/^domain = .*/domain = \"$uci_domain\"/" "$CONFIG_FILE" 2>/dev/null || \
echo "domain = \"$uci_domain\"" >> "$CONFIG_FILE"
fi
if [ -n "$uci_hostname" ]; then
log "Setting hostname: $uci_hostname"
sed -i "s/^hostname = .*/hostname = \"$uci_hostname\"/" "$CONFIG_FILE" 2>/dev/null || \
echo "hostname = \"$uci_hostname\"" >> "$CONFIG_FILE"
fi
fi
log "Migration complete!"
log "Data backed up to: $backup_dir"
log "Restart services: systemctl restart secubox-mail"
}
# ============================================================================
# Backup / Restore
# ============================================================================
cmd_backup() {
local name="${1:-$(date +%Y%m%d_%H%M%S)}"
local backup_file="$DATA_PATH/backups/mail_$name.tar.gz"
log "Creating backup: $backup_file"
mkdir -p "$DATA_PATH/backups"
tar -czf "$backup_file" \
-C "$DATA_PATH" \
mail config ssl \
2>/dev/null
log "Backup created: $backup_file"
echo "$backup_file"
}
cmd_restore() {
local backup_file="$1"
if [ -z "$backup_file" ] || [ ! -f "$backup_file" ]; then
echo "Usage: mailctl restore <backup_file>"
echo ""
echo "Available backups:"
ls -la "$DATA_PATH/backups/"*.tar.gz 2>/dev/null || echo " No backups found"
return 1
fi
warn "This will overwrite current mail data!"
read -p "Continue? (yes/no): " confirm
[ "$confirm" != "yes" ] && { echo "Aborted"; return 1; }
log "Stopping services..."
cmd_stop
log "Restoring from: $backup_file"
tar -xzf "$backup_file" -C "$DATA_PATH"
log "Starting services..."
cmd_start
log "Restore complete!"
}
# ============================================================================
# SSL
# ============================================================================
cmd_ssl() {
local action="${1:-status}"
shift
case "$action" in
status)
if [ -f "$DATA_PATH/ssl/fullchain.pem" ]; then
echo "SSL Certificate:"
openssl x509 -in "$DATA_PATH/ssl/fullchain.pem" -noout -subject -dates 2>/dev/null | sed 's/^/ /'
else
echo "No SSL certificate installed"
echo "Use: mailctl ssl setup"
fi
;;
setup)
local fqdn="$HOSTNAME.$DOMAIN"
log "Setting up SSL for $fqdn..."
if command -v certbot >/dev/null 2>&1; then
certbot certonly --standalone -d "$fqdn" \
--cert-path "$DATA_PATH/ssl/fullchain.pem" \
--key-path "$DATA_PATH/ssl/privkey.pem"
elif command -v acme.sh >/dev/null 2>&1; then
acme.sh --issue -d "$fqdn" --standalone
acme.sh --install-cert -d "$fqdn" \
--fullchain-file "$DATA_PATH/ssl/fullchain.pem" \
--key-file "$DATA_PATH/ssl/privkey.pem"
else
error "No ACME client found. Install certbot or acme.sh"
return 1
fi
chmod 600 "$DATA_PATH/ssl/"*.pem
log "SSL certificate installed"
cmd_restart
;;
*)
echo "SSL commands:"
echo " ssl status Show certificate info"
echo " ssl setup Obtain Let's Encrypt certificate"
;;
esac
}
# ============================================================================
# DKIM
# ============================================================================
cmd_dkim() {
local action="${1:-status}"
shift
case "$action" in
setup|generate)
# OpenDKIM setup is deferred to Phase 2 (Rspamd replaces it).
# Phase 1 stub: tell the user where to look.
warn "DKIM setup will move to Rspamd in Phase 2."
warn "Phase 1: configure DKIM manually inside the LXC if needed."
;;
status)
if [ -f "$DATA_PATH/dkim/default.txt" ]; then
log "DKIM key exists"
cat "$DATA_PATH/dkim/default.txt"
else
warn "DKIM not configured. Run: mailctl dkim setup"
fi
;;
*)
echo "DKIM commands:"
echo " dkim setup Generate DKIM keys"
echo " dkim status Show DKIM DNS record"
;;
esac
}
# ============================================================================
# Logs
# ============================================================================
cmd_logs() {
local lines="${1:-50}"
if lxc_running "$CONTAINER"; then
lxc_attach "$CONTAINER" tail -n "$lines" /var/log/mail.log 2>/dev/null
else
error "Mail container not running"
fi
}
# ============================================================================
# Help
# ============================================================================
show_help() {
cat << EOF
SecuBox Mail Server v$VERSION
Three-fold architecture: Components, Status, Access
Usage: mailctl <command> [options]
Information (Three-fold):
components List system components (JSON)
status Show service status
access Show connection info (JSON)
Setup:
install Install mail server + webmail
uninstall Remove mail server
migrate [host] Migrate data from OpenWrt (default: 192.168.255.1)
Service:
start Start mail services
stop Stop mail services
restart Restart mail services
Users:
user add <email> Add mail user
user del <email> Delete mail user
user list List all users
user passwd <email> Change password
user-repair <email> Repair user mailbox
Aliases:
alias add <a> <t> Add email alias
alias del <alias> Delete alias
alias list List aliases
Backup:
backup [name] Create backup
restore <file> Restore from backup
SSL & DNS:
ssl status Show certificate info
ssl setup Obtain Let's Encrypt certificate
dns-setup Show DNS records to configure
Diagnostics:
logs [lines] View mail logs
fix-ports Check and fix listening ports
Examples:
mailctl install
mailctl components # List what's installed
mailctl access # Show connection details
mailctl user add admin@example.com
mailctl migrate 192.168.255.1
mailctl dns-setup # Show DNS records
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}"
# Installed layout: /usr/lib/secubox/mail/lib/migrate.sh
# In-tree layout: packages/secubox-mail/lib/mail/migrate.sh
if [ ! -f "$LIB_DIR/migrate.sh" ]; then
local alt="$(dirname "$0")/../lib/mail"
[ -f "$alt/migrate.sh" ] && LIB_DIR="$alt"
fi
# 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"
}
# ============================================================================
# Phase 2 — Rspamd subcommand
# ============================================================================
cmd_rspamd() {
[ "$(id -u)" -eq 0 ] || { error "Root required"; return 1; }
: "${LIB_DIR:=/usr/lib/secubox/mail/lib}"
[ -f "$LIB_DIR/rspamd.sh" ] || LIB_DIR="$(dirname "$0")/../lib/mail"
# shellcheck source=/dev/null
source "$LIB_DIR/rspamd.sh"
local sub="${1:-status}"
shift || true
case "$sub" in
install)
LXC_BASE="$LXC_PATH" install_rspamd "$CONTAINER"
LXC_BASE="$LXC_PATH" configure_rspamd_milter "$CONTAINER"
LXC_BASE="$LXC_PATH" configure_rspamd_controller "$CONTAINER"
LXC_BASE="$LXC_PATH" configure_rspamd_dkim "$CONTAINER" "$DOMAIN" "default"
LXC_BASE="$LXC_PATH" configure_rspamd_postfix_milter "$CONTAINER"
log "Rspamd installed. Start with: mailctl rspamd start"
;;
start|restart)
lxc-attach -n "$CONTAINER" -- systemctl restart rspamd
;;
stop)
lxc-attach -n "$CONTAINER" -- systemctl stop rspamd
;;
reload)
lxc-attach -n "$CONTAINER" -- systemctl reload rspamd
;;
status)
lxc-attach -n "$CONTAINER" -- rspamc stat 2>&1 | head -40
;;
dkim-keygen)
local domain="${1:-$DOMAIN}"
local sel="${2:-default}"
# Delegate to the lib function — runs rspamadm inside the LXC and
# resolves _rspamd uid via kernel idmap (image-agnostic).
LXC_BASE="$LXC_PATH" DATA_PATH="$DATA_PATH" \
rspamd_keygen "$CONTAINER" "$domain" "$sel"
log "DKIM keypair generated for $domain (selector $sel)"
;;
dns-records)
local domain="${1:-$DOMAIN}"
local sel="${2:-default}"
cat "$DATA_PATH/rspamd/dkim/$domain/$sel.txt" 2>/dev/null \
|| error "no DNS record for $domain/$sel"
;;
learn-spam)
local target="${1:-}"
[ -n "$target" ] || { echo "Usage: mailctl rspamd learn-spam <maildir-or-file>"; return 1; }
lxc-attach -n "$CONTAINER" -- rspamc learn_spam "$target"
;;
learn-ham)
local target="${1:-}"
[ -n "$target" ] || { echo "Usage: mailctl rspamd learn-ham <maildir-or-file>"; return 1; }
lxc-attach -n "$CONTAINER" -- rspamc learn_ham "$target"
;;
purge-legacy)
LXC_BASE="$LXC_PATH" rspamd_purge_legacy "$CONTAINER"
;;
*)
cat <<USAGE
Usage:
mailctl rspamd install
mailctl rspamd start | stop | restart | reload
mailctl rspamd status
mailctl rspamd dkim-keygen [<domain>] [<selector>]
mailctl rspamd dns-records [<domain>] [<selector>]
mailctl rspamd learn-spam <maildir-or-file>
mailctl rspamd learn-ham <maildir-or-file>
mailctl rspamd purge-legacy # purge SA/OpenDKIM after Rspamd verified
USAGE
;;
esac
}
# ============================================================================
# Main
# ============================================================================
case "${1:-}" in
# Three-fold architecture
components) cmd_components ;;
access) cmd_access ;;
status) shift; cmd_status "$@" ;;
# Setup
install) shift; cmd_install "$@" ;;
uninstall) shift; cmd_uninstall "$@" ;;
migrate) shift; cmd_migrate "$@" ;;
migrate-config) shift; cmd_migrate_config "$@" ;;
# Phase 2 — Rspamd
rspamd) shift; cmd_rspamd "$@" ;;
# Service control
start) shift; cmd_start "$@" ;;
stop) shift; cmd_stop "$@" ;;
restart) shift; cmd_restart "$@" ;;
# Users and aliases
user) shift; cmd_user "$@" ;;
user-repair) shift; cmd_user_repair "$@" ;;
alias) shift; cmd_alias "$@" ;;
# Backup
backup) shift; cmd_backup "$@" ;;
restore) shift; cmd_restore "$@" ;;
# SSL, DNS, DKIM
ssl) shift; cmd_ssl "$@" ;;
dkim) shift; cmd_dkim "$@" ;;
dns-setup) cmd_dns_setup ;;
# Diagnostics
logs) shift; cmd_logs "$@" ;;
fix-ports) cmd_fix_ports ;;
sync) cmd_sync ;;
# Help
help|--help|-h|'') show_help ;;
*) error "Unknown command: $1"; show_help >&2; exit 1 ;;
esac
exit 0