From d3948e970ef76e81fb63f255e5d22578bf49ef8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 1 Oct 2026 12:24:09 +0200 Subject: [PATCH] pepsi: host-net highports + ensure mon-* users --- scripts/pepsi/ensure-mon-users.sh | 57 ++++++ scripts/pepsi/firewalld-pepsi-highports.sh | 66 +++++++ scripts/pepsi/hostnet-pepsi-highports.sh | 204 +++++++++++++++++++++ 3 files changed, 327 insertions(+) create mode 100755 scripts/pepsi/ensure-mon-users.sh create mode 100755 scripts/pepsi/firewalld-pepsi-highports.sh create mode 100755 scripts/pepsi/hostnet-pepsi-highports.sh diff --git a/scripts/pepsi/ensure-mon-users.sh b/scripts/pepsi/ensure-mon-users.sh new file mode 100755 index 0000000..661edaa --- /dev/null +++ b/scripts/pepsi/ensure-mon-users.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Idempotent: ensure mon-* OS users + Maildir inside koopa-pepsi. +# Needed after every container recreate: /home is not in the image and is +# not a volume by default → useradd state vanishes while DB login_assignment +# survives. +# +# UIDs fixed 1000–1004 (match login_assignment / prior live). +# STATUS= only; no secrets. Run on koopa as hernani (rootless podman exec). +set -euo pipefail + +CTR="${PEPSI_CTR:-koopa-pepsi}" +USERS=( + "mon-smtp:1000" + "mon-maildir:1001" + "mon-dkim:1002" + "mon-bounce:1003" + "mon-local:1004" +) + +echo "STATUS=start CTR=$CTR HOST=$(hostname -s)" + +if ! podman container exists "$CTR" 2>/dev/null; then + echo "STATUS=missing_ctr CTR=$CTR" + exit 2 +fi +if [[ "$(podman inspect -f '{{.State.Running}}' "$CTR" 2>/dev/null || echo false)" != "true" ]]; then + echo "STATUS=ctr_not_running CTR=$CTR" + exit 3 +fi + +created=0 +present=0 +for entry in "${USERS[@]}"; do + u="${entry%%:*}" + uid="${entry##*:}" + if podman exec "$CTR" getent passwd "$u" >/dev/null 2>&1; then + echo "USER=$u STATE=present" + present=$((present + 1)) + else + podman exec -u root "$CTR" useradd -u "$uid" -U -m -d "/home/$u" -s /usr/sbin/nologin "$u" + echo "USER=$u STATE=created UID=$uid" + created=$((created + 1)) + fi + # Maildir skeleton (cur/new/tmp) — safe if already present + podman exec -u root "$CTR" bash -c " + set -e + home=/home/$u + mkdir -p \"\$home/Maildir\"/{cur,new,tmp} + chown -R $u:$u \"\$home\" + chmod 700 \"\$home\" + " + echo "MAILDIR=$u OK=1" +done + +echo "STATUS=done CREATED=$created PRESENT_BEFORE=$present EXPECT=${#USERS[@]}" +podman exec "$CTR" getent passwd | grep '^mon-' || true +exit 0 diff --git a/scripts/pepsi/firewalld-pepsi-highports.sh b/scripts/pepsi/firewalld-pepsi-highports.sh new file mode 100755 index 0000000..1387fa1 --- /dev/null +++ b/scripts/pepsi/firewalld-pepsi-highports.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Open rootless pepsi high-ports on koopa (firewalld). +# Compose map (LAN bind 192.168.100.95): +# 1025→25 1465→465 1587→587 1993→993 18443→443 +# Freigabe: human this session. Run on koopa as root / sudo. +set -euo pipefail + +PORTS=(1025/tcp 1465/tcp 1587/tcp 1993/tcp 18443/tcp) +ZONE="${FIREWALLD_ZONE:-}" + +if [[ ${EUID:-$(id -u)} -ne 0 ]]; then + echo "STATUS=need_root HINT='sudo $0'" + exit 2 +fi + +if ! command -v firewall-cmd >/dev/null 2>&1; then + echo "STATUS=no_firewalld" + exit 3 +fi + +if ! firewall-cmd --state >/dev/null 2>&1; then + echo "STATUS=firewalld_down" + exit 4 +fi + +if [[ -z "$ZONE" ]]; then + # Prefer the zone that owns the LAN NIC; else default. + ZONE=$(firewall-cmd --get-zone-of-interface=eno1 2>/dev/null || true) + if [[ -z "$ZONE" ]]; then + ZONE=$(firewall-cmd --get-default-zone) + fi +fi + +echo "STATUS=start ZONE=$ZONE HOST=$(hostname -s)" + +added=0 +present=0 +for p in "${PORTS[@]}"; do + if firewall-cmd --zone="$ZONE" --query-port="$p" >/dev/null 2>&1; then + echo "PORT=$p STATE=present" + present=$((present + 1)) + else + firewall-cmd --zone="$ZONE" --add-port="$p" --permanent + echo "PORT=$p STATE=added_permanent" + added=$((added + 1)) + fi +done + +firewall-cmd --reload +echo "STATUS=reloaded" + +ok=0 +for p in "${PORTS[@]}"; do + if firewall-cmd --zone="$ZONE" --query-port="$p" >/dev/null 2>&1; then + echo "VERIFY=$p OK=1" + ok=$((ok + 1)) + else + echo "VERIFY=$p OK=0" + fi +done + +echo "STATUS=done ZONE=$ZONE ADDED=$added PRESENT_BEFORE=$present VERIFY_OK=$ok EXPECT=${#PORTS[@]}" +if [[ "$ok" -eq "${#PORTS[@]}" ]]; then + exit 0 +fi +exit 1 diff --git a/scripts/pepsi/hostnet-pepsi-highports.sh b/scripts/pepsi/hostnet-pepsi-highports.sh new file mode 100755 index 0000000..aeacc55 --- /dev/null +++ b/scripts/pepsi/hostnet-pepsi-highports.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# Switch koopa-pepsi to network_mode:host on high ports so SMTP client IP +# is preserved (rootlessport SNAT otherwise forces 10.89.16.4 → SPF fail). +# +# Vecigate stays pure DNAT (25/465/587 → 1025/1465/1587). No mail on router. +# DB stays bridged; publish 127.0.0.1:15432→5432 for host-net pepsi. +# +# Freigabe: human this session (Script hinlegen + apply+restart). +# Run on koopa as hernani (rootless podman). Never echoes DB secrets. +set -euo pipefail + +PEPSI_DIR="${PEPSI_DIR:-$HOME/koopa-pepsi}" +TS="$(date +%Y%m%d-%H%M%S)" +BAK="${PEPSI_DIR}/bak-hostnet-${TS}" +COMPOSE_BIN="" + +echo "STATUS=start HOST=$(hostname -s) DIR=$PEPSI_DIR" + +if [[ ! -d "$PEPSI_DIR" ]]; then + echo "STATUS=missing_dir PATH=$PEPSI_DIR" + exit 2 +fi +cd "$PEPSI_DIR" + +if command -v podman-compose >/dev/null 2>&1; then + COMPOSE_BIN="podman-compose" +elif podman compose version >/dev/null 2>&1; then + COMPOSE_BIN="podman compose" +else + echo "STATUS=no_compose" + exit 3 +fi + +if [[ ! -f compose.yml || ! -f conf/pepsi.conf ]]; then + echo "STATUS=missing_files" + exit 4 +fi + +already=0 +if grep -q 'network_mode:[[:space:]]*host' compose.yml \ + && grep -qE '^PORT = 1025$' conf/pepsi.conf \ + && grep -qE '@127\.0\.0\.1:15432/' conf/pepsi.conf; then + already=1 + echo "STATUS=already_hostnet HINT=skip_patch" +fi + +if [[ "$already" -eq 0 ]]; then + mkdir -p "$BAK" + cp -a compose.yml conf/pepsi.conf "$BAK/" + echo "STATUS=bak PATH=$BAK" + + python3 - <<'PY' +from pathlib import Path +import re +import sys + +root = Path(".") +compose_path = root / "compose.yml" +conf_path = root / "conf" / "pepsi.conf" +compose = compose_path.read_text() +conf = conf_path.read_text() + +# Split services crudely on "\n pepsi:" / "\n db:" +def patch_compose(text: str) -> str: + # Retarget env DB URLs first (password stays ${VAR}) + text = text.replace("@db:5432/", "@127.0.0.1:15432/") + + # Ensure db publishes 127.0.0.1:15432:5432 + if "15432:5432" not in text: + text = re.sub( + r"(container_name: koopa-pepsi-db\n)", + r'\1 ports:\n - "127.0.0.1:15432:5432"\n', + text, + count=1, + ) + + # pepsi service: add network_mode host; remove its ports: block + # Match from " pepsi:" until next top-level service or "volumes:" + m = re.search(r"(?ms)^ pepsi:\n(.*?)(?=^volumes:|^ [a-z])", text) + if not m: + print("STATUS=pepsi_service_not_found") + sys.exit(5) + body = m.group(1) + # drop ports block inside pepsi + body2 = re.sub(r"(?m)^ ports:\n(?: [^\n]*\n)+", "", body) + if "network_mode:" not in body2: + body2 = re.sub( + r"(container_name: koopa-pepsi\n)", + r"\1 network_mode: host\n", + body2, + count=1, + ) + text = text[: m.start(1)] + body2 + text[m.end(1) :] + return text + + +def set_port(text: str, section: str, port: int) -> str: + pat = rf"(\[{re.escape(section)}\]\n(?:.*\n)*?PORT = )\d+" + new, n = re.subn(pat, rf"\g<1>{port}", text, count=1) + if n != 1: + print(f"STATUS=port_patch_fail SECTION={section} N={n}") + sys.exit(6) + return new + + +compose = patch_compose(compose) +conf = conf.replace("@db:5432/", "@127.0.0.1:15432/") +conf = set_port(conf, "pepsi-ingress-listener-mx", 1025) +conf = set_port(conf, "pepsi-ingress-listener-submissions", 1465) +conf = set_port(conf, "pepsi-ingress-listener-submission", 1587) +conf = set_port(conf, "pepsi-httpd-listener-https", 18443) + +compose_path.write_text(compose) +conf_path.write_text(conf) + +# sanity STATUS (no secrets) +checks = [ + ("network_mode_host", bool(re.search(r"(?m)^ network_mode:\s*host\s*$", compose))), + ("db_pub_15432", "15432:5432" in compose), + ("no_db_hostname", "@db:5432/" not in compose and "@db:5432/" not in conf), + ("port_1025", bool(re.search(r"(?m)^PORT = 1025\s*$", conf))), + ("port_1465", bool(re.search(r"(?m)^PORT = 1465\s*$", conf))), + ("port_1587", bool(re.search(r"(?m)^PORT = 1587\s*$", conf))), + ("port_18443", bool(re.search(r"(?m)^PORT = 18443\s*$", conf))), + ("pepsi_ports_gone", "192.168.100.95:1025" not in compose), +] +for k, v in checks: + print(f"CHECK={k} OK={int(v)}") +if not all(v for _, v in checks): + print("STATUS=patch_sanity_fail") + sys.exit(7) +print("STATUS=patched") +PY +fi + +echo "STATUS=compose_up BIN=$COMPOSE_BIN" +# shellcheck disable=SC2086 +$COMPOSE_BIN up -d --force-recreate +sleep 4 + +echo "STATUS=verify" +ss_out="$(ss -lntp 2>/dev/null | grep -E ':1025|:1465|:1587|:18443|:15432' || true)" +# redact pid/user clutter lightly +echo "$ss_out" | sed -E 's/users:\(\("([^"]+)".*/users=(("\1"…)/' || true + +rootless_hit=0 +if echo "$ss_out" | grep -q rootlessport; then + rootless_hit=1 +fi +db_pub=0 +if echo "$ss_out" | grep -qE '127\.0\.0\.1:15432'; then + db_pub=1 +fi +mx_ok=0 +if echo "$ss_out" | grep -qE ':1025\b'; then + mx_ok=1 +fi + +podman ps --format '{{.Names}} {{.Status}}' | grep pepsi || true + +if command -v pg_isready >/dev/null 2>&1; then + if pg_isready -h 127.0.0.1 -p 15432 -d pepsi >/dev/null 2>&1; then + echo "STATUS=db_ready HOST=127.0.0.1 PORT=15432" + else + echo "STATUS=db_not_ready HOST=127.0.0.1 PORT=15432" + fi +else + if timeout 2 bash -c 'echo >/dev/tcp/127.0.0.1/15432' 2>/dev/null; then + echo "STATUS=db_tcp_open HOST=127.0.0.1 PORT=15432" + else + echo "STATUS=db_tcp_closed HOST=127.0.0.1 PORT=15432" + fi +fi + +# recent log lines (strip any postgres URLs) +podman logs --tail 40 koopa-pepsi 2>/dev/null \ + | grep -Ei 'listen|error|fatal|spf|client|1025|postgres|ready' \ + | sed -E 's#postgres://[^[:space:]]+#postgres://*#g' \ + | tail -12 || true + +echo "STATUS=done MX_1025=$mx_ok DB_PUB=$db_pub ROOTLESSPORT_ON_HIGH=$rootless_hit BAK=${BAK:-none}" +echo "HINT=next ensure-mon-users.sh after recreate; then taler-mail send --expect-pong" +echo "HINT=Vecigate DNAT unchanged (no mail on router); /home ephemeral unless volume+ensure" +# Optional: run ensure if sibling script present (same dir or PEPSI_DIR/scripts) +ENSURE="" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +for cand in "$here/ensure-mon-users.sh" "$PEPSI_DIR/scripts/ensure-mon-users.sh" "$PEPSI_DIR/ensure-mon-users.sh"; do + if [[ -x "$cand" ]]; then + ENSURE="$cand" + break + fi +done +if [[ -n "$ENSURE" ]]; then + echo "STATUS=ensure_mon_users SCRIPT=$ENSURE" + PEPSI_CTR=koopa-pepsi "$ENSURE" || echo "STATUS=ensure_warn" +fi +if [[ "$mx_ok" -eq 1 && "$rootless_hit" -eq 0 && "$db_pub" -eq 1 ]]; then + exit 0 +fi +if [[ "$mx_ok" -eq 1 && "$db_pub" -eq 1 ]]; then + echo "STATUS=warn_rootlessport_still_present" + exit 0 +fi +exit 1