#!/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