#!/usr/bin/env bash
# Create (if needed) and (re)configure srv-mail — the rack's outbound SMTP relay.
#
#   SMTP_PASSWORD=… ./deploy-relay.sh
#   SMTP_PASSWORD=… ./deploy-relay.sh --test        # + probe, including the refusals
#
# Idempotent: re-running re-asserts every map and the smarthost password.
#
# What it is: a Postfix hub. Rack devices submit on plain 25 over the LAN with NO
# credential of their own; this container holds the single copy of the Infomaniak
# password and is the only thing in the rack that talks to a mail server. Devices
# like the APC NMC and iDRAC8 cannot do implicit TLS + AUTH at all, so without
# this they could not send at all — the SInf egress allow is 465-only.
#
# Access control, three independent layers (see docs/infra/services/email.md):
#   1. WHO may submit — mynetworks, an explicit host list. Never a /24: the L2 is
#      flat, and a future test guest must not inherit the right to send.
#   2. WHOM they may write to — the recipient allowlist below. This is the cheap,
#      valuable one: even an abused relay reaches only the admins. It is what
#      stops the ISC Learn DR mirror from mailing 3000 students if its own muzzle
#      slips.
#   3. HOW MUCH — anvil rate limits. The realistic failure is not an attacker but
#      a flapping probe emptying itself into the mailbox Moodle prod also uses.
#
# NOT for Moodle. Moodle prod lives on hannibal, outside the rack and outside the
# SInf filter, and mails students directly with its own credentials — untouched.
# On a real DR the mirror becomes prod and must mail students: point it straight
# at Infomaniak like hannibal, or widen the allowlist deliberately.
set -euo pipefail

CTID="${CTID:-112}"
CT_HOST="${CT_HOST:-srv-mail}"
CT_IP="${CT_IP:-192.168.88.163/24}"
PVE_HOST="${PVE_HOST:-rumba}"
CT_GW="${CT_GW:-192.168.88.1}"
TEMPLATE="${TEMPLATE:-debian-13-standard_13.6-1_amd64.tar.zst}"

SMARTHOST="[mail.infomaniak.com]:465"
MAILBOX="${MAILBOX:-mailer@isc-vs.ch}"

# Layer 1 — who may submit. Plain inline list, so `postconf mynetworks` is itself
# the inventory. Deliberately NOT 192.168.88.0/24, and deliberately not
# 192.168.88.1: that is the CCR2004, which src-NATs legacy WireGuard clients, so
# listing it would make every VPN user an authorised sender as @isc-vs.ch.
MYNETWORKS="127.0.0.0/8 \
192.168.88.51 \
192.168.88.10 \
192.168.88.46 \
192.168.88.35 \
192.168.88.158 \
192.168.88.160 \
192.168.88.161 \
192.168.88.166 \
192.168.88.250 \
192.168.92.10"

# Layer 2 — who may be written to. Anything else is rejected at RCPT TO.
#
# ADMINS is the single place that decides who receives rack mail. No consumer
# names a person: they all write to GROUP, expanded here by virtual(5). Adding or
# removing an admin is this one line plus a re-run — no device, node or
# notification endpoint is touched. The individual addresses stay accepted so a
# consumer that still names one directly keeps working.
GROUP="${GROUP:-rack-admins@isc-vs.ch}"
ADMINS="${ADMINS:-pierre-andre.mudry@hevs.ch yacine.said@hevs.ch}"

RECIPIENTS="$(printf '%s\tOK\n' "$GROUP" $ADMINS)"

# virtual(5) rewrites every recipient whatever its domain, so isc-vs.ch does not
# have to be a local domain here — and must not be, this box stores nothing.
# Expansion happens in cleanup, i.e. *after* smtpd_recipient_restrictions, which
# is why GROUP needs its own OK line above.
#
# virtual_alias_domains is emptied below: its default is $virtual_alias_maps, so
# leaving it would make every domain appearing as a key in this map a virtual
# alias domain, and any other address in it would be rejected "User unknown in
# virtual alias table" instead of being relayed.
VIRTUAL="$(printf '%s\t%s\n' "$GROUP" "${ADMINS// /, }")"

TEST=0
[[ "${1:-}" == "--test" ]] && TEST=1

if [[ -z "${SMTP_PASSWORD:-}" ]]; then
  read -rsp "Infomaniak password for $MAILBOX: " SMTP_PASSWORD < /dev/tty
  echo >&2
fi

r() { ssh "root@$PVE_HOST" "$@"; }
inct() { ssh "root@$PVE_HOST" "pct exec $CTID -- $*"; }

if ! r "pct status $CTID" >/dev/null 2>&1; then
  echo ">>> creating container $CTID ($CT_HOST)" >&2
  r "pct create $CTID local:vztmpl/$TEMPLATE \
      --hostname $CT_HOST --cores 1 - -memory 512 --swap 256 \
      --rootfs local-lvm:4 \
      --net0 name=eth0,bridge=vmbr0,ip=$CT_IP,gw=$CT_GW \
      --nameserver $CT_GW --searchdomain "isc3" \
      --onboot 1 --unprivileged 1 \
      --description 'Outbound SMTP relay to Infomaniak (managed from provisioning/mail)' \
      --ssh-public-keys /root/.ssh/authorized_keys"
  r "pct start $CTID"
  sleep 8
  inct "systemctl mask dev-mqueue.mount run-lock.mount tmp.mount" >/dev/null
  # debconf would otherwise stop at the mail-name question
  r "pct exec $CTID -- bash -c 'echo \"postfix postfix/main_mailer_type select Internet Site\" | debconf-set-selections; \
        echo \"postfix postfix/mailname string $CT_HOST.isc3\" | debconf-set-selections; \
        apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq postfix libsasl2-modules'"
fi

STAGE="/root/.cache/isc-mail-$CTID"     # not /tmp: fs.protected_regular
r "mkdir -p $STAGE"

printf '%s\n' "$RECIPIENTS" | r "cat > $STAGE/recipients"
printf '%s\n' "$VIRTUAL" | r "cat > $STAGE/virtual"
printf '%s %s:%s\n' "$SMARTHOST" "$MAILBOX" "$SMTP_PASSWORD" | r "cat > $STAGE/sasl_passwd"
# Sender rewriting must be sender_canonical, NOT smtp_generic_maps: generic(5)
# rewrites recipients as well, so a catch-all there sends every mail to the
# mailer mailbox itself.
#
# And it is not enough on its own: Postfix rewrites *header* addresses only for
# clients matching local_header_rewrite_clients, which defaults to locally
# submitted mail. A rack device is a remote client, so its envelope was rewritten
# while `From:` kept e.g. root@rumba.isc3 — and Infomaniak answers
# `550 5.7.1 Sender mismatch`. Hence permit_mynetworks below.
printf '/.+/\t%s\n' "$MAILBOX" | r "cat > $STAGE/sender_canonical"

for f in recipients virtual sasl_passwd sender_canonical; do
  r "pct push $CTID $STAGE/$f /etc/postfix/$f"
done
r "rm -f $STAGE/sasl_passwd"
inct "chmod 600 /etc/postfix/sasl_passwd"

# The whole point of a single relay is a single audit trail, and the Debian
# template ships no syslog daemon — postfix would log into the void. maillog_file
# makes postlogd write the file itself, no rsyslog needed.
inct "postconf -e \
  'compatibility_level = 3.6' \
  'maillog_file = /var/log/postfix.log' \
  'myhostname = $CT_HOST.isc3' \
  'mydestination = localhost' \
  'inet_interfaces = all' \
  'inet_protocols = ipv4' \
  'relayhost = $SMARTHOST' \
  'smtp_sasl_auth_enable = yes' \
  'smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd' \
  'smtp_sasl_security_options = noanonymous' \
  'smtp_tls_wrappermode = yes' \
  'smtp_tls_security_level = encrypt' \
  'smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt' \
  'sender_canonical_maps = regexp:/etc/postfix/sender_canonical' \
  'virtual_alias_maps = hash:/etc/postfix/virtual' \
  'virtual_alias_domains =' \
  'sender_canonical_classes = envelope_sender, header_sender' \
  'local_header_rewrite_clients = permit_mynetworks' \
  'mynetworks = $MYNETWORKS' \
  'smtpd_client_restrictions = permit_mynetworks, reject' \
  'smtpd_relay_restrictions = permit_mynetworks, reject' \
  'smtpd_recipient_restrictions = check_recipient_access hash:/etc/postfix/recipients, reject' \
  'smtpd_client_message_rate_limit = 30' \
  'anvil_rate_time_unit = 60s' \
  'message_size_limit = 10485760' \
  'maximal_queue_lifetime = 1d' \
  'bounce_queue_lifetime = 1d'"

printf '%s\n' '/var/log/postfix.log {
	weekly
	rotate 8
	compress
	missingok
	notifempty
	postrotate
		/usr/sbin/postfix logrotate 2>/dev/null || true
	endscript
}' | r "cat > $STAGE/logrotate"
r "pct push $CTID $STAGE/logrotate /etc/logrotate.d/postfix-maillog"

inct "postmap /etc/postfix/sasl_passwd /etc/postfix/recipients /etc/postfix/virtual"
inct "postfix check"
inct "systemctl enable postfix" >/dev/null 2>&1
inct "systemctl restart postfix"

IP_ONLY="${CT_IP%%/*}"
inct "ss -ltn" | grep -q ':25 ' && echo ">>> listening on 25" >&2 || echo "    WARNING: nothing on 25" >&2

if (( TEST )); then
  echo ">>> probes from rumba (allowed sender)" >&2
  r "python3 - <<'PY'
import smtplib
H='$IP_ONLY'
def probe(label, rcpt):
    try:
        s=smtplib.SMTP(H,25,timeout=15)
        s.ehlo('rumba.isc3')
        s.sendmail('pve@rumba.isc3',[rcpt],
                   f'Subject: srv-mail probe ({label})\r\nTo: {rcpt}\r\n\r\nSent through srv-mail.\r\n')
        s.quit(); print(f'  {label}: ACCEPTED')
    except Exception as e:
        print(f'  {label}: refused -> {e}')
probe('group address','$GROUP')
probe('recipient not on the allowlist','random.student@hevs.ch')
PY"
  echo ">>> group expansion" >&2
  inct "postmap -q $GROUP hash:/etc/postfix/virtual"
  echo ">>> probe from srv-web01 (.150, deliberately NOT in mynetworks)" >&2
  r "pct exec 100 -- timeout 10 bash -c 'exec 3<>/dev/tcp/$IP_ONLY/25; head -1 <&3; printf \"EHLO x\r\nMAIL FROM:<a@b.c>\r\n\" >&3; head -2 <&3'" 2>&1 | sed 's/^/  /'
fi

echo ">>> done" >&2
