الأدلة التقنية

تجربة عملية: تكامل منصة SaaS مع أنظمة العميل

نبني منصة وعميلاً ومزود بريد في Docker، ونرى بالمخرجات الحقيقية كيف تنجح الشهادة والبريد والرسائل والسجلات وكيف تفشل.

تجربة عملية: تكامل منصة SaaS مع أنظمة العميل

في مقال تكامل منصة SaaS مع أنظمة العميل شرحنا الخيارات: كيف تضع شهادة على نطاق العميل، ولماذا ينجح الإرسال عبر بوابة بريده دون أي تغيير في سجلاته، وماذا يعني عنوان الخروج المشترك، وكيف ترسل سجلاته وحدها إلى SIEM الخاص به. وفي هذه التجربة سوف نبني كل ذلك على جهاز واحد في Docker، بمنصة اسمها saasco.test وعميل اسمه client-gov.test، وهو جهة حكومية في مثالنا ويمكن أن يكون أي مؤسسة لها بوابة بريد وجهة إصدار وSIEM، ومزود بريد للمواطن اسمه inbox.test، ونرى كل خيار ينجح بمخرجاته الحقيقية، ثم نكسره عمداً ونقرأ رسالة الخطأ التي سوف تراها في الإنتاج.

دليلتكامل منصة SaaS مع أنظمة العميل دون خلط المستأجرينكيف تربط منصتك متعددة المستأجرين بنطاق العميل وبريده وأنظمته دون أن تفتح باباً على بقية العملاء.دليلالشهادات الرقمية TLS للمبتدئين وصيغ ملفاتهاكيف يعرف المتصفح أنه يتحدث مع الموقع الحقيقي، وماذا يوجد داخل الشهادة، ولماذا تأتي ملفاتها بأكثر من صيغة.دليلشرح SPF وDKIM وDMARC للمبتدئين: كيف تثبت أن رسائلك منك فعلاًلماذا يستطيع أي شخص أن يرسل باسم نطاقك، وكيف يسد كل سجل من هذه السجلات الثلاثة ثغرة تركها الذي قبله.دليلكيف يعمل البريد الإلكتروني؟ SMTP وIMAP للمبتدئينماذا يحدث بين ضغطك على «إرسال» ووصول الرسالة، ولماذا ترتد بعض الرسائل، وأي منفذ تفتح لكل غرض.

وسوف نتناول في هذا المقال ما يلي:

  • شبكة التجربة وملفاتها: DNS بنطاقات وهمية في CoreDNS، وجهة إصدار ACME محلية هي Pebble، وبوابة بريد للعميل، ومزود بريد يفحص SPF وDKIM وDMARC.
  • الشهادة عند الطلب On-Demand TLS في Caddy مع نقطة السؤال ask، ثم طلب CSR توقعه جهة إصدار العميل.
  • شهادة دون الشهادة الوسيطة، وكيف يتجاوزها المتصفح ويفشل curl وPython، ثم السلسلة الكاملة، ثم شهادة منتهية.
  • البريد بالطرق الثلاث مع محادثة SMTP كاملة لكل طريقة، والإرسال عبر بوابة لا تقبل إلا عنواننا مع TLS وتسجيل الدخول.
  • مزود رسائل بقائمة سماح، ومحول لكل مزود، ثم سجلات مفلترة بالمستأجر إلى مستقبل Splunk HEC.
  • كل ما كسرناه عمداً، وما انكسر وحده أثناء البناء.

ما الذي سوف نبنيه؟

كل الأجهزة على شبكة Docker واحدة بالمدى 10.77.77.0/24، وكل عنوان فيها يمثل شيئاً في الإنتاج: العنوان 10.77.77.25 للتطبيق ويمثل عنوان ال NAT Gateway الذي تخرج منه المنصة، والعنوان 10.77.77.80 لـ Caddy ويمثل عنوان ال Load Balancer الذي يدخل إليه الزوار، والعنوان 10.77.77.66 لجهاز آخر لا يوجد في أي قائمة سماح ونسميه المتطفل Intruder. والصورة التالية تبين الشبكة:

ثلاث مناطق. منطقة المنصة saasco.test فيها lab-tenant-app على .25 وهو عنوان الخروج، وlab-tenant-caddy على .80 وهو عنوان ال Load Balancer، وlab-tenant-vector على .60 لنقل السجلات، وlab-tenant-pebble على .14 جهة إصدار ACME محلية، وlab-tenant-intruder على .66 خارج كل قوائم السماح، وlab-tenant-dns على .53 فيه نطاقات CoreDNS. منطقة العميل client-gov.test فيها lab-tenant-gw على .30 بوابة البريد مع DKIM، وlab-tenant-ca على .70 رابط AIA، وlab-tenant-hec على .61 مستقبل Splunk HEC، وlab-tenant-sms على .50 مزود الرسائل، وسجلاتها: portal وsecure CNAME إلى app.saasco.test، وSPF بالعنوانين .30 و.31 وDKIM fm2026 وDMARC p=reject وMX إلى mail .31. منطقة المستلم inbox.test فيها lab-tenant-rx على .40 سيرفر Postfix، يمرر الرسالة عبر milter إلى lab-tenant-rspamd على .41 الذي يفحص SPF وDKIM وDMARC، والصندوق citizen@inbox.test
كل العناوين داخل شبكة واحدة، والمنفذ الوحيد على الجهاز 127.0.0.1:18443

والإصدارات المستخدمة: Docker Engine 29.8.0 وDocker Compose 5.5.1، وCaddy 2.11.7، وPebble 2.10.1 برخصة MPL-2.0، وهو كما يقول مستودعه نسخة مصغرة من Boulder للاختبار ولا يصلح جهة إصدار في الإنتاج، وCoreDNS 1.14.7 برخصة Apache-2.0، وPostfix 3.10.13 مع OpenDKIM 2.11.0 من Debian trixie، وRspamd 4.2.2، وVector 0.59.0 في الطرفين، وPython 3.13.16 مع dkimpy 1.1.8 وdnspython 2.9.0 وrequests 2.34.2، وOpenSSL 3.5.7.

ملفات التجربة

أنشئ مجلداً باسم lab فيه الملفات التالية بالمسارات نفسها، وأولها ملف Compose الذي يعرف الشبكة وكل الأجهزة:

# Lab: a SaaS (saasco.test) and one government client (client-gov.test) on one private network.
# 10.77.77.25 plays our NAT Gateway egress IP, 10.77.77.80 plays our Load Balancer public IP.
name: lab-tenant

x-dns: &dns
  dns: [10.77.77.53]

networks:
  net:
    name: lab-tenant-net
    ipam:
      config:
        - subnet: 10.77.77.0/24

services:
  dns:
    image: coredns/coredns:1.14.7
    container_name: lab-tenant-dns
    command: ["-conf", "/etc/coredns/Corefile"]
    volumes: ["./dns/Corefile:/etc/coredns/Corefile:ro", "./run/zones:/zones:ro"]
    networks: { net: { ipv4_address: 10.77.77.53 } }

  # --- our side ---------------------------------------------------------------
  app:   # our application and mail sender; its address is "our NAT egress IP"
    build: { context: ., dockerfile: Dockerfile.py }
    image: lab-tenant-py
    container_name: lab-tenant-app
    command: ["python", "-u", "/app/control.py"]
    <<: *dns
    volumes: ["./app:/app:ro", "./run:/run/lab"]
    networks: { net: { ipv4_address: 10.77.77.25 } }

  caddy:   # our ingress; its address is "our Load Balancer public IP"
    image: caddy:2.11.7-alpine
    container_name: lab-tenant-caddy
    <<: *dns
    volumes: ["./caddy/Caddyfile:/etc/caddy/Caddyfile:ro", "./run:/run/lab"]
    ports: ["127.0.0.1:18443:443"]
    networks: { net: { ipv4_address: 10.77.77.80 } }

  pebble:   # a local ACME CA for the lab (never use it in production)
    image: ghcr.io/letsencrypt/pebble:2.10.1
    container_name: lab-tenant-pebble
    command: ["-config", "/run/lab/pebble-config.json", "-dnsserver", "10.77.77.53:53"]
    environment: { PEBBLE_VA_NOSLEEP: "1" }
    volumes: ["./run:/run/lab:ro"]
    networks: { net: { ipv4_address: 10.77.77.14, aliases: [pebble] } }

  vector:   # our log forwarder
    image: timberio/vector:0.59.0-debian
    container_name: lab-tenant-vector
    entrypoint: ["sh", "-c", "HEC_TOKEN=$$(cat /run/lab/secrets/hec-token) exec vector --config /etc/vector/ours.yaml"]
    <<: *dns
    volumes: ["./vector/ours.yaml:/etc/vector/ours.yaml:ro", "./run:/run/lab"]
    networks: { net: { ipv4_address: 10.77.77.60 } }
    depends_on: [dns, hec]

  intruder:   # any other machine: not on the client's allowlists
    image: lab-tenant-py
    container_name: lab-tenant-intruder
    command: ["sleep", "infinity"]
    <<: *dns
    volumes: ["./app:/app:ro", "./run:/run/lab:ro"]
    networks: { net: { ipv4_address: 10.77.77.66 } }
    depends_on: [app]

  # --- the client's side --------------------------------------------------------
  gw:   # the client's mail gateway (stand-in for FortiMail / F5 / Exchange connector)
    build: { context: ., dockerfile: Dockerfile.mta }
    image: lab-tenant-mta
    container_name: lab-tenant-gw
    hostname: relay.client-gov.test
    command: ["sh", "/start.sh"]
    <<: *dns
    volumes: ["./gw/start.sh:/start.sh:ro", "./run:/run/lab:ro"]
    networks: { net: { ipv4_address: 10.77.77.30 } }

  ca:   # the client's CA web server: publishes the issuing CA certificate (the AIA "CA Issuers" URL)
    image: lab-tenant-py
    container_name: lab-tenant-ca
    command: ["python", "-m", "http.server", "80", "--directory", "/run/lab/ca-public"]
    volumes: ["./run:/run/lab"]
    networks: { net: { ipv4_address: 10.77.77.70 } }
    depends_on: [app]

  hec:   # the client's Splunk HEC endpoint (Vector's splunk_hec source as a stand-in)
    image: timberio/vector:0.59.0-debian
    container_name: lab-tenant-hec
    entrypoint: ["sh", "-c", "HEC_TOKEN=$$(cat /run/lab/secrets/hec-token) exec vector --config /etc/vector/theirs.yaml"]
    volumes: ["./vector/theirs.yaml:/etc/vector/theirs.yaml:ro", "./run:/run/lab"]
    networks: { net: { ipv4_address: 10.77.77.61 } }

  sms:   # the client's SMS provider (mock)
    image: lab-tenant-py
    container_name: lab-tenant-sms
    command: ["python", "-u", "/sms/provider.py"]
    volumes: ["./sms:/sms:ro", "./run:/run/lab:ro"]
    networks: { net: { ipv4_address: 10.77.77.50 } }
    depends_on: [app]

  # --- the recipient's mailbox provider ----------------------------------------
  rx:
    image: lab-tenant-mta
    container_name: lab-tenant-rx
    hostname: mx.inbox.test
    command: ["sh", "/start.sh"]
    <<: *dns
    volumes: ["./rx/start.sh:/start.sh:ro"]
    networks: { net: { ipv4_address: 10.77.77.40 } }
    depends_on: [gw]

  rspamd:
    image: rspamd/rspamd:4.2.2
    container_name: lab-tenant-rspamd
    volumes: ["./rspamd/local.d:/etc/rspamd/local.d:ro", "./rspamd/override.d:/etc/rspamd/override.d:ro"]
    networks: { net: { ipv4_address: 10.77.77.41 } }

في الإعداد أعلاه لاحظ التالي:

  • كل الأسماء تبدأ بـ lab-tenant-، والمنفذ الوحيد المنشور هو 127.0.0.1:18443، فلا يصل أحد من الشبكة إلى التجربة.
  • الأجهزة التي تحتاج أسماء النطاقات الوهمية تستخدم dns: [10.77.77.53]، فتسأل CoreDNS عن .test، ويبقى Docker يحل أسماء الأجهزة نفسها.
  • المجلد run يولده سكربت التجهيز، وفيه المفاتيح والشهادات وملفات DNS والأسرار، وهو يمثل الخزنة في هذه التجربة، فلا تضعه في Git.

ثم ملفا البناء، الأول لجهة المنصة ولمزود الرسائل وجهة إصدار العميل، والثاني لبوابة البريد ولسيرفر المستلم:

# Our application side (and the stand-ins for the client CA and the SMS provider)
FROM python:3.13.16-slim-trixie
RUN apt-get update && apt-get install -y --no-install-recommends openssl curl dnsutils ca-certificates \
 && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir dkimpy==1.1.8 dnspython==2.9.0 requests==2.34.2
WORKDIR /app
# Postfix with OpenDKIM and Cyrus SASL: the client's mail gateway and the receiving mailbox provider
FROM debian:trixie-20261005-slim
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
    postfix postfix-cdb opendkim opendkim-tools sasl2-bin libsasl2-modules swaks dnsutils ca-certificates openssl procps \
 && rm -rf /var/lib/apt/lists/*

وسكربت التجهيز setup.sh يولد مفاتيح DKIM الثلاثة، وجهة إصدار للعميل بشهادة رئيسية Root وشهادة وسيطة Issuing CA مثل أي مؤسسة كبيرة تملك جهة إصدار داخلية، وشهادة لبوابة البريد ولمستقبل السجلات، وملفات النطاقات، والأسرار:

#!/bin/sh
# Generates everything the lab needs into ./run (keys, the client's CA, DNS zones).
# Run once: docker run --rm -v "$PWD/run:/run/lab" -v "$PWD:/src:ro" lab-tenant-py sh /src/setup.sh
set -e
R=/run/lab; mkdir -p $R/dkim $R/ca $R/gw $R/hec $R/zones $R/secure
cd $R

# 1) DKIM keys: ours (saasco.test), the key we hold for the client (delegated by CNAME), and the client's gateway key
for k in saasco-s2026 client-gov-delegated client-gov-fm2026; do
  openssl genrsa -out dkim/$k.key 2048 2>/dev/null
  openssl rsa -in dkim/$k.key -pubout -outform DER 2>/dev/null | base64 -w0 > dkim/$k.pub
done

# 2) The client's PKI: a root and an issuing (intermediate) CA, like a government entity's internal CA
cat > ca/inter.ext <<'X'
basicConstraints=critical,CA:TRUE,pathlen:0
keyUsage=critical,keyCertSign,cRLSign
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid
X
openssl req -x509 -newkey rsa:3072 -nodes -keyout ca/root.key -subj "/C=SA/O=Client Gov Lab/CN=Client Gov Root CA" -days 3650 \
  -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign" -out ca/root.pem 2>/dev/null
openssl req -new -newkey rsa:3072 -nodes -keyout ca/issuing.key -subj "/C=SA/O=Client Gov Lab/CN=Client Gov Issuing CA 1" -out ca/issuing.csr 2>/dev/null
openssl x509 -req -in ca/issuing.csr -CA ca/root.pem -CAkey ca/root.key -CAcreateserial -days 1825 -extfile ca/inter.ext -out ca/issuing.pem 2>/dev/null
openssl x509 -in ca/issuing.pem -outform DER -out ca/issuing-ca-1.der

# a helper the client uses to sign server certificates with the issuing CA
sign() { # name cn days csr out
  printf "basicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=DNS:%s\nauthorityKeyIdentifier=keyid\nauthorityInfoAccess=caIssuers;URI:http://pki.client-gov.test/issuing-ca-1.der\n" "$2" > ca/$1.ext
  openssl x509 -req -in "$4" -CA ca/issuing.pem -CAkey ca/issuing.key -CAcreateserial -days "$3" -extfile ca/$1.ext -out "$5" 2>/dev/null
}
# the gateway's TLS certificate (relay.client-gov.test) and the SIEM collector's (siem.client-gov.test)
openssl req -new -newkey rsa:2048 -nodes -keyout gw/relay.key -subj "/CN=relay.client-gov.test" -out gw/relay.csr 2>/dev/null
sign relay relay.client-gov.test 365 gw/relay.csr gw/relay.pem
cat gw/relay.pem ca/issuing.pem > gw/fullchain.pem
openssl req -new -newkey rsa:2048 -nodes -keyout hec/siem.key -subj "/CN=siem.client-gov.test" -out hec/siem.csr 2>/dev/null
sign siem siem.client-gov.test 365 hec/siem.csr hec/siem.pem
cat hec/siem.pem ca/issuing.pem > hec/fullchain.pem
cp /src/sign-csr.sh ca/sign-csr.sh

# 3) DNS zones (TXT values split into 200-character strings)
split() { echo "$1" | sed 's/.\{200\}/&" "/g'; }
P1=$(split "v=DKIM1; k=rsa; p=$(cat dkim/saasco-s2026.pub)")
P2=$(split "v=DKIM1; k=rsa; p=$(cat dkim/client-gov-delegated.pub)")
P3=$(split "v=DKIM1; k=rsa; p=$(cat dkim/client-gov-fm2026.pub)")
SOA='@ 300 IN SOA ns.lab.test. hostmaster.lab.test. 1 300 60 86400 60'
cat > zones/saasco.test.db <<Z
\$ORIGIN saasco.test.
$SOA
@                      IN NS  ns.lab.test.
app                    IN A   10.77.77.80
mail                   IN A   10.77.77.25
@                      IN MX  10 mail.saasco.test.
@                      IN TXT "v=spf1 ip4:10.77.77.25 -all"
_spf                   IN TXT "v=spf1 ip4:10.77.77.25 -all"
bounces                IN TXT "v=spf1 ip4:10.77.77.25 -all"
s2026._domainkey       IN TXT ( "$P1" )
client-gov._dk         IN TXT ( "$P2" )
_dmarc                 IN TXT "v=DMARC1; p=reject; adkim=r; aspf=r"
Z
cat > zones/client-gov.test.db <<Z
\$ORIGIN client-gov.test.
$SOA
@                      IN NS  ns.lab.test.
@                      IN MX  10 mail.client-gov.test.
mail                   IN A   10.77.77.31
relay                  IN A   10.77.77.30
pki                    IN A   10.77.77.70
siem                   IN A   10.77.77.61
portal                 IN CNAME app.saasco.test.
@                      IN TXT "v=spf1 ip4:10.77.77.30 ip4:10.77.77.31 -all"
fm2026._domainkey      IN TXT ( "$P3" )
_dmarc                 IN TXT "v=DMARC1; p=reject; adkim=r; aspf=r"
Z
cat > zones/inbox.test.db <<Z
\$ORIGIN inbox.test.
$SOA
@                      IN NS  ns.lab.test.
@                      IN MX  10 mx.inbox.test.
mx                     IN A   10.77.77.40
Z
cat > zones/provider.test.db <<Z
\$ORIGIN provider.test.
$SOA
@                      IN NS  ns.lab.test.
sms                    IN A   10.77.77.50
Z

# 4) Lab secrets (in production these live in a secrets manager, one path per tenant)
mkdir -p secrets
rnd() { openssl rand -hex 16; }
rnd > secrets/gw-smtp-password                 # agreed with the client's mail team
rnd > secrets/tenants_client-gov_sms           # the client's own SMS provider API key
rnd > secrets/platform_sms                     # our platform SMS account password
rnd > secrets/hec-token                        # the client's Splunk HEC token
cp dkim/saasco-s2026.key dkim/client-gov-delegated.key secrets/
cat > sms-accounts.json <<J
{
  "client-gov": { "api_key": "$(cat secrets/tenants_client-gov_sms)", "allowed_ips": ["10.77.77.25"], "senders": ["CLIENT-GOV"] },
  "saasco":     { "password": "$(cat secrets/platform_sms)", "allowed_ips": ["10.77.77.25"], "senders": ["SAASCO"] }
}
J
mkdir -p caddy-sites logs out && touch caddy-sites/empty.caddy
# 5) The issuing CA certificate published at the AIA URL, and a TLS certificate for the local ACME server (Pebble)
mkdir -p ca-public pebble && cp ca/issuing-ca-1.der ca-public/
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout pebble/tls.key -out pebble/tls.pem -days 30   -subj "/CN=pebble" -addext "subjectAltName=DNS:pebble" 2>/dev/null
cp /src/pebble-config.json pebble-config.json
chmod -R a+rX $R && chmod -R a+rwX logs out caddy-sites
echo "setup done"

ولاحظ في السكربت أن كل شهادة توقعها جهة العميل تحمل الامتداد authorityInfoAccess = caIssuers;URI:http://pki.client-gov.test/issuing-ca-1.der، وهو الرابط الذي يجلب منه المتصفح الشهادة الوسيطة الناقصة كما سيأتي، وأن سجل SPF للعميل فيه عنوان بوابته .30 وسيرفر بريده الداخل .31 فقط. والسكربت الصغير sign-csr.sh هو ما يفعله موظف جهة الإصدار عند العميل عندما يصله طلب CSR:

#!/bin/sh
# The client's CA operator signs a CSR it received: sign-csr.sh <csr> <hostname> <days> <out.pem>
set -e
cd /run/lab/ca
printf "basicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=DNS:%s\nauthorityKeyIdentifier=keyid\nauthorityInfoAccess=caIssuers;URI:http://pki.client-gov.test/issuing-ca-1.der\n" "$2" > req.ext
openssl x509 -req -in "$1" -CA issuing.pem -CAkey issuing.key -CAcreateserial -days "$3" -extfile req.ext -out "$4"

وملف إعداد Pebble نغير فيه منفذي التحقق إلى 80 و443 لأن Caddy يستمع عليهما:

{
  "pebble": {
    "listenAddress": "0.0.0.0:14000",
    "managementListenAddress": "0.0.0.0:15000",
    "certificate": "/run/lab/pebble/tls.pem",
    "privateKey": "/run/lab/pebble/tls.key",
    "httpPort": 80,
    "tlsPort": 443,
    "ocspResponderURL": "",
    "externalAccountBindingRequired": false,
    "retryAfter": { "authz": 3, "order": 5 },
    "keyAlgorithm": "ecdsa",
    "profiles": {
      "default": { "description": "90-day certificates", "validityPeriod": 7776000 }
    }
  }
}

وملف CoreDNS، والخيار reload 2s يعيد تحميل النطاق عندما يتغير رقمه التسلسلي في سجل SOA، لأننا سوف نضيف سجلات أثناء التجربة كما يفعل العميل:

saasco.test {
    file /zones/saasco.test.db {
        reload 2s
    }
    log
}
client-gov.test {
    file /zones/client-gov.test.db {
        reload 2s
    }
    log
}
inbox.test {
    file /zones/inbox.test.db
}
provider.test {
    file /zones/provider.test.db
}

والسكربت app/dns-set.sh يضيف سجلاً أو يستبدله ويرفع الرقم التسلسلي:

#!/bin/sh
# Adds or replaces a record in a lab zone and bumps the SOA serial so CoreDNS reloads it.
#   dns-set.sh <zone> <name> <type> <value...>      e.g. dns-set.sh client-gov.test secure CNAME app.saasco.test.
set -e
ZONE=$1; Z=/run/lab/zones/$ZONE.db; NAME=$2; TYPE=$3; shift 3
grep -v "^$NAME[[:space:]]\+IN $TYPE " "$Z" > "$Z.tmp" || true
printf '%-22s IN %s %s\n' "$NAME" "$TYPE" "$*" >> "$Z.tmp"
awk '/IN SOA/ { $7 = $7 + 1 } { print }' "$Z.tmp" > "$Z" && rm "$Z.tmp"
echo "$NAME.$ZONE $TYPE $*"

جهة المنصة: Caddy ونقطة السؤال وإعدادات المستأجرين

ملف Caddy يطلب الشهادات من Pebble بدلاً من Let's Encrypt، ويسأل نقطة ask قبل أي إصدار، ويستورد مواقع الشهادات التي يرسلها العملاء من مجلد خاص:

{
	email [email protected]
	# a local ACME CA (Pebble) instead of Let's Encrypt
	acme_ca https://pebble:14000/dir
	acme_ca_root /run/lab/pebble/tls.pem
	on_demand_tls {
		# Caddy asks our control plane before it requests a certificate for any name
		ask http://10.77.77.25:9000/ask
	}
}

# client certificates installed by hand (the CSR flow) are imported from this folder
import /run/lab/caddy-sites/*.caddy

# every other tenant domain: a certificate on demand, but only if /ask approves it
https:// {
	tls {
		on_demand
	}
	respond "tenant site for {host}" 200
}

وإعدادات المستأجرين، وفيها لكل عميل نطاقاته وطريقة بريده ومحول رسائله ومسار أسراره، ولا يوجد فيها أي سر:

{
  "client-gov": {
    "domains": [
      { "name": "portal.client-gov.test", "verified": true },
      { "name": "secure.client-gov.test", "verified": true, "certificate": "client-provided" }
    ],
    "email": { "mode": "relay", "host": "relay.client-gov.test", "port": 25, "user": "saasco-app",
               "secret": "tenants/client-gov/smtp", "from": "[email protected]" },
    "sms": { "adapter": "jsonbearer", "base_url": "http://sms.provider.test:8080",
             "secret": "tenants/client-gov/sms", "sender": "CLIENT-GOV" },
    "siem": { "type": "splunk_hec", "endpoint": "https://siem.client-gov.test:8088" }
  },
  "retail-co": {
    "domains": [],
    "email": { "mode": "platform", "from": "[email protected]" },
    "sms": { "adapter": "legacyform", "base_url": "http://sms.provider.test:8080",
             "user": "saasco", "secret": "platform/sms", "sender": "SAASCO" }
  }
}

ونقطة السؤال توافق على النطاقات التي تحقق منها النظام وتستخدم شهادة ACME فقط، أما النطاق الذي يرسل العميل شهادته فلا يجوز أن يطلب له Caddy شهادة أخرى:

"""Our control plane: the tenant registry and the /ask endpoint Caddy calls before issuing a certificate."""
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs

TENANTS_FILE = "/app/tenants.json"


def custom_domains():
    with open(TENANTS_FILE, encoding="utf-8") as f:
        tenants = json.load(f)
    # approve only verified domains that use certificates on demand (not the ones the client provides)
    return {d["name"]: t for t, cfg in tenants.items() for d in cfg.get("domains", [])
            if d.get("verified") and d.get("certificate", "acme") == "acme"}


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        url = urlparse(self.path)
        if url.path != "/ask":
            self.send_response(404); self.end_headers(); return
        domain = parse_qs(url.query).get("domain", [""])[0].lower()
        tenant = custom_domains().get(domain)
        status = 200 if tenant else 403
        print(f"ask domain={domain} tenant={tenant or '-'} -> {status}", flush=True)
        self.send_response(status); self.end_headers()

    def log_message(self, *args):
        pass


HTTPServer(("0.0.0.0", 9000), Handler).serve_forever()

بوابة بريد العميل

هذه بوابة Postfix تقوم مقام بوابة بريد العميل Mail Gateway، أياً كان المنتج الذي يستخدمه في الإنتاج (FortiMail أو Cisco Secure Email أو موصل Microsoft 365 أو Proofpoint أو Postfix داخلي)، وشروطها هي الشروط التي يضعها فريق البريد عند أي مؤسسة: العنوان، ثم التشفير، ثم تسجيل الدخول، ثم العنوان الذي يملكه الحساب، ثم حد المعدل، ثم توقيع DKIM بمفتاح العميل:

#!/bin/sh
# The client's mail gateway (a Postfix stand-in for FortiMail, an F5 relay or an Exchange connector).
# It accepts mail only from the SaaS egress IP, only after STARTTLS (TLS 1.2+) and SMTP AUTH,
# only for the sender address it owns, then signs it with the client's own DKIM key.
set -e
R=/run/lab
postconf -e \
  myhostname=relay.client-gov.test mydomain=client-gov.test myorigin=client-gov.test \
  mydestination= inet_interfaces=all inet_protocols=ipv4 mynetworks=127.0.0.0/8 \
  maillog_file=/dev/stdout \
  smtpd_banner='$myhostname ESMTP client-gov relay' \
  smtpd_tls_cert_file=/etc/postfix/tls/fullchain.pem smtpd_tls_key_file=/etc/postfix/tls/relay.key \
  smtpd_tls_security_level=encrypt smtpd_tls_auth_only=yes \
  'smtpd_tls_protocols=>=TLSv1.2' 'smtpd_tls_mandatory_protocols=>=TLSv1.2' \
  smtpd_tls_loglevel=1 smtp_tls_security_level=may \
  smtpd_sasl_auth_enable=yes smtpd_sasl_type=cyrus smtpd_sasl_path=smtpd \
  'smtpd_client_restrictions=check_client_access cidr:/etc/postfix/saas_clients.cidr, reject' \
  smtpd_sender_login_maps=hash:/etc/postfix/sender_login \
  'smtpd_sender_restrictions=reject_sender_login_mismatch' \
  'smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination' \
  smtpd_client_message_rate_limit=5 anvil_rate_time_unit=60s \
  smtpd_milters=inet:127.0.0.1:8891 non_smtpd_milters=inet:127.0.0.1:8891 milter_default_action=tempfail
postconf -F '*/*/chroot = n'

# allowed clients: the SaaS NAT egress IP only
printf '10.77.77.25/32 OK\n' > /etc/postfix/saas_clients.cidr
# which login may use which sender address
printf '[email protected] [email protected]\n' > /etc/postfix/sender_login && postmap /etc/postfix/sender_login

# SMTP AUTH user for the SaaS (sasldb), password from the lab secret file
mkdir -p /etc/postfix/sasl
printf 'pwcheck_method: auxprop\nauxprop_plugin: sasldb\nmech_list: PLAIN LOGIN\n' > /etc/postfix/sasl/smtpd.conf
cat $R/secrets/gw-smtp-password | saslpasswd2 -p -c -u client-gov.test saasco-app
chown postfix:postfix /etc/sasldb2 && chmod 640 /etc/sasldb2

mkdir -p /etc/postfix/tls && cp $R/gw/fullchain.pem $R/gw/relay.key /etc/postfix/tls/ && chmod 600 /etc/postfix/tls/relay.key

# OpenDKIM signs everything From: @client-gov.test with the client's own key
mkdir -p /etc/opendkim/keys /run/opendkim && cp $R/dkim/client-gov-fm2026.key /etc/opendkim/keys/fm2026.key
chown -R opendkim:opendkim /etc/opendkim /run/opendkim && chmod 600 /etc/opendkim/keys/fm2026.key
printf 'fm2026._domainkey.client-gov.test client-gov.test:fm2026:/etc/opendkim/keys/fm2026.key\n' > /etc/opendkim/key.table
printf '*@client-gov.test fm2026._domainkey.client-gov.test\n' > /etc/opendkim/signing.table
printf '127.0.0.1\n10.77.77.25\n' > /etc/opendkim/internal.hosts
cat > /etc/opendkim.conf <<'C'
Syslog no
Mode s
Canonicalization relaxed/simple
Socket inet:[email protected]
KeyTable refile:/etc/opendkim/key.table
SigningTable refile:/etc/opendkim/signing.table
InternalHosts /etc/opendkim/internal.hosts
UserID opendkim
PidFile /run/opendkim/opendkim.pid
C
opendkim -x /etc/opendkim.conf
exec postfix start-fg

في الإعداد أعلاه لاحظ التالي:

  • smtpd_client_restrictions يقبل العنوان 10.77.77.25/32 فقط ويرفض أي عنوان آخر، وهذه قاعدة السماح التي يضع فيها العميل عنوان NAT الخاص بك.
  • smtpd_tls_security_level=encrypt يفرض STARTTLS قبل أي رسالة، وsmtpd_tls_auth_only=yes يخفي الأمر AUTH حتى يبدأ التشفير، فلا تمر كلمة المرور نصاً واضحاً.
  • reject_sender_login_mismatch مع الملف sender_login يربط الحساب بالعنوان [email protected] وحده.
  • smtpd_relay_restrictions لا يمرر البريد إلى نطاقات خارجية إلا لمن سجل الدخول، والقيمة reject_unauth_destination موصوفة في وثائق Postfix.
  • الحد smtpd_client_message_rate_limit=5 في الدقيقة صغير عمداً لنراه يعمل.
  • milter_default_action=tempfail يؤجل الرسالة إذا تعطل التوقيع بدلاً من أن يرسلها دون توقيع.

سيرفر المستلم وRspamd

سيرفر المستلم Postfix عادي يسلم كل رسالة إلى Rspamd عبر milter قبل أن يقبلها:

#!/bin/sh
# The recipient's mailbox provider (inbox.test): Postfix that asks rspamd to check SPF, DKIM and DMARC.
set -e
id citizen >/dev/null 2>&1 || useradd -m citizen
postconf -e \
  myhostname=mx.inbox.test mydomain=inbox.test mydestination=inbox.test \
  inet_interfaces=all inet_protocols=ipv4 mynetworks=127.0.0.0/8 \
  maillog_file=/dev/stdout home_mailbox=Maildir/ \
  smtpd_banner='$myhostname ESMTP inbox' \
  smtpd_milters=inet:10.77.77.41:11332 milter_default_action=accept milter_protocol=6
postconf -F '*/*/chroot = n'
exec postfix start-fg

وإعدادات Rspamd في ملفات صغيرة. الأول في المجلد override.d وليس local.d، وسوف نشرح السبب في قسم ما انكسر أثناء البناء:

# rspamd/override.d/options.inc
# the lab runs on private addresses: check them like any Internet sender, and resolve through the lab DNS
local_addrs = [];
dns {
  nameserver = ["10.77.77.53:53"];
}
# rspamd/local.d/spf.conf (ومثله dkim.conf)
# the lab senders use private addresses: check them anyway
check_local = true;
check_authed = true;
# rspamd/local.d/worker-proxy.inc
milter = yes;
bind_socket = "*:11332";
upstream "local" {
  default = yes;
  self_scan = yes;
}
# rspamd/local.d/milter_headers.conf
use = ["authentication-results", "x-spamd-result"];
authenticated_headers = ["authentication-results"];
skip_local = false;
skip_authenticated = false;
# rspamd/local.d/actions.conf
reject = 150;
add_header = 100;
greylist = null;
# rspamd/local.d/force_actions.conf
# act on the sender's published DMARC policy, with a clear reason in the SMTP reply
rules {
  DMARC_REJECT {
    action = "reject";
    expression = "DMARC_POLICY_REJECT";
    message = "Rejected: DMARC policy of the From domain (no aligned SPF or DKIM pass)";
  }
}

والملف force_actions.conf يجعل Rspamd يرفض الرسالة عندما تفشل في DMARC ونطاق المرسل ينشر سياسة p=reject، كما يفعل Gmail وOutlook، والنص بعد الرمز هو نصنا نحن، فكل مزود بريد يكتب نصه الخاص.

المرسل: الطرق الثلاث في سكربت واحد

السكربت send_mail.py يرسل رسالة بإحدى الطرق، ويطبع المحادثة كما تمر على الشبكة، فالسطر الذي يبدأ بـ C: هو ما نرسله، والسطر الذي يبدأ بـ S: هو رد السيرفر، ويخفي كلمة المرور في أمر AUTH، ويختصر نص الرسالة نفسها في سطر واحد:

"""Sends one message in one of the three ways a SaaS can send email for a tenant, and prints the SMTP conversation.

  python send_mail.py platform        option 1: From [email protected], signed by saasco.test, straight to the recipient's MX
  python send_mail.py direct          option 2: From [email protected], signed with the key the client delegated to us
  python send_mail.py direct-ours     break: From the client's domain but signed only as saasco.test (no alignment)
  python send_mail.py relay           option 3: hand the message to the client's gateway (STARTTLS + AUTH), it signs and delivers
  python send_mail.py relay-noauth    break: relay without AUTH (sender the client tied to our login)
  python send_mail.py relay-open      break: relay without AUTH, another sender of the client
  python send_mail.py relay-plain     break: no STARTTLS
  python send_mail.py relay-sender    break: AUTH works but the envelope sender is not one the client allowed
  python send_mail.py relay-from      break: the gateway accepts, but the visible From is our domain
  python send_mail.py relay-notrust   break: we do not trust the client's CA
"""
import ast, smtplib, ssl, sys, uuid
from email.message import EmailMessage
from email.utils import formatdate
import dkim, dns.resolver

RCPT = "[email protected]"
SECRETS = "/run/lab/secrets"
CLIENT_CA = "/run/lab/ca/root.pem"


class TracedSMTP(smtplib.SMTP):
    """smtplib with a readable trace: C: what we send, S: what the server answers."""
    def _print_debug(self, *args):
        if len(args) < 2 or args[0] not in ("send:", "reply:"):
            return
        data = ast.literal_eval(args[1])          # smtplib passes repr() of what went over the wire
        text = data.decode(errors="replace") if isinstance(data, (bytes, bytearray)) else data
        if args[0] == "reply:":
            print("S: " + text.rstrip())
            return
        if text.upper().startswith("AUTH PLAIN"):
            text = "AUTH PLAIN ********"
        if text.count("\r\n") > 1:               # the message itself (DATA content)
            print(f"C: [message headers and body, {len(text)} bytes]")
        else:
            print("C: " + text.rstrip())


def build(from_addr, subject):
    msg = EmailMessage()
    msg["From"] = from_addr
    msg["To"] = RCPT
    msg["Subject"] = subject
    msg["Date"] = formatdate(localtime=False)
    msg["Message-ID"] = f"<{uuid.uuid4()}@saasco.test>"
    msg.set_content("Your appointment is confirmed.\n")
    return msg


def sign(msg, domain, selector, keyfile):
    raw = msg.as_bytes()
    with open(keyfile, "rb") as f:
        sig = dkim.sign(raw, selector.encode(), domain.encode(), f.read(),
                        include_headers=[b"from", b"to", b"subject", b"date", b"message-id"])
    return sig + raw


def mx_of(domain):
    answers = sorted(dns.resolver.resolve(domain, "MX"), key=lambda r: r.preference)
    host = str(answers[0].exchange).rstrip(".")
    print(f"# MX {domain} -> {host}")
    return host


def deliver_direct(envelope_from, raw):
    host = mx_of(RCPT.split("@")[1])
    s = TracedSMTP(host, 25, local_hostname="mail.saasco.test")
    s.set_debuglevel(1)
    s.ehlo()
    try:
        s.sendmail(envelope_from, [RCPT], raw)
    except smtplib.SMTPException as e:
        print(f"# rejected: {e}")
    s.quit()


def relay(envelope_from, msg, auth=True, trust=True):
    ctx = ssl.create_default_context(cafile=CLIENT_CA if trust else None)
    ctx.minimum_version = ssl.TLSVersion.TLSv1_2
    s = TracedSMTP("relay.client-gov.test", 25, local_hostname="mail.saasco.test")
    s.set_debuglevel(1)
    try:
        s.ehlo()
        s.starttls(context=ctx)
        print(f"# TLS: {s.sock.version()} {s.sock.cipher()[0]}")
        s.ehlo()
        if auth:
            password = open(f"{SECRETS}/gw-smtp-password").read().strip()
            s.login("[email protected]", password)
        s.sendmail(envelope_from, [RCPT], msg.as_bytes())
        s.quit()
    except (smtplib.SMTPException, ssl.SSLError, OSError) as e:
        print(f"# failed: {type(e).__name__}: {e}")


mode = sys.argv[1]
if mode == "platform":
    m = build("ClientGov via SaaSCo <[email protected]>", "Option 1: platform sender")
    deliver_direct("[email protected]", sign(m, "saasco.test", "s2026", f"{SECRETS}/saasco-s2026.key"))
elif mode == "direct":
    m = build("Client Gov <[email protected]>", "Option 2: direct as the client domain")
    deliver_direct("[email protected]", sign(m, "client-gov.test", "saas", f"{SECRETS}/client-gov-delegated.key"))
elif mode == "direct-ours":
    m = build("Client Gov <[email protected]>", "Break: signed only by saasco.test")
    deliver_direct("[email protected]", sign(m, "saasco.test", "s2026", f"{SECRETS}/saasco-s2026.key"))
elif mode == "relay":
    relay("[email protected]", build("Client Gov <[email protected]>", "Option 3: through the client's gateway"))
elif mode == "relay-noauth":
    relay("[email protected]", build("Client Gov <[email protected]>", "Break: no AUTH"), auth=False)
elif mode == "relay-open":
    relay("[email protected]", build("Client Gov <[email protected]>", "Break: no AUTH, another sender"), auth=False)
elif mode == "relay-plain":
    s = TracedSMTP("relay.client-gov.test", 25, local_hostname="mail.saasco.test")
    s.set_debuglevel(1)
    s.ehlo()
    try:
        s.sendmail("[email protected]", [RCPT], build("Client Gov <[email protected]>", "Break: no TLS").as_bytes())
    except smtplib.SMTPException as e:
        print(f"# failed: {type(e).__name__}: {e}")
elif mode == "relay-sender":
    relay("[email protected]", build("Client Gov <[email protected]>", "Break: wrong envelope sender"))
elif mode == "relay-from":
    relay("[email protected]", build("SaaSCo <[email protected]>", "Break: From is our domain"))
elif mode == "relay-notrust":
    relay("[email protected]", build("Client Gov <[email protected]>", "Break: CA not trusted"), trust=False)

لاحظ أن الطريقتين 1 و2 تبحثان عن MX لنطاق المستلم inbox.test وتسلمان الرسالة له مباشرة بعد توقيعها بمكتبة dkimpy، أما الطريقة 3 فلا تبحث عن أي MX، وإنما تتصل باسم البوابة relay.client-gov.test مباشرة، وهذا هو الفرق الذي شرحناه في قسم سجلات MX.

الرسائل القصيرة والسجلات

مزود الرسائل الوهمي يقدم واجهتين، واحدة بأسلوب JSON مع مفتاح، وواحدة بأسلوب قديم بنموذج فيه اسم المستخدم وكلمة المرور، ويفحص عنوان المصدر واسم المرسل لكل حساب:

"""A mock SMS provider with two API styles, per-account API keys, an IP allowlist and registered sender names."""
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs

ACCOUNTS = json.load(open("/run/lab/sms-accounts.json"))


class Handler(BaseHTTPRequestHandler):
    def reply(self, status, body):
        data = json.dumps(body).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(data)
        print(f"{self.client_address[0]} {self.path.split('?')[0]} -> {status} {body}", flush=True)

    def check(self, account, sender):
        ip = self.client_address[0]
        if ip not in account["allowed_ips"]:
            return self.reply(403, {"error": f"source IP {ip} is not on the allowlist"})
        if sender not in account["senders"]:
            return self.reply(422, {"error": f"sender name '{sender}' is not registered for this account"})
        return True

    def do_POST(self):
        url = urlparse(self.path)
        length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(length).decode()
        if url.path == "/v1/messages":            # style A: JSON + Bearer key
            key = self.headers.get("Authorization", "").removeprefix("Bearer ")
            account = next((a for a in ACCOUNTS.values() if a.get("api_key") == key), None)
            if not account:
                return self.reply(401, {"error": "invalid API key"})
            msg = json.loads(body)
            if self.check(account, msg["sender"]) is True:
                self.reply(201, {"id": "msg-1001", "status": "queued", "to": msg["to"]})
        elif url.path == "/api/send":              # style B: form fields with user and password
            form = {k: v[0] for k, v in parse_qs(body).items()}
            account = ACCOUNTS.get(form.get("user"))
            if not account or account.get("password") != form.get("pass"):
                return self.reply(401, {"code": 1, "error": "bad user or password"})
            if self.check(account, form.get("from")) is True:
                self.reply(200, {"code": 0, "msgid": "B-77", "numbers": form.get("numbers")})
        else:
            self.reply(404, {"error": "not found"})

    def log_message(self, *args):
        pass


HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()

والمحول في المنصة، دالة واحدة يستدعيها الكود، ومحول لكل أسلوب:

"""Per-tenant SMS: the application calls send_otp(tenant, phone, text); the tenant's config picks the adapter.

  python sms_adapter.py <tenant> <phone>
"""
import json, sys
import requests

SECRETS = "/run/lab/secrets"
TENANTS = json.load(open("/app/tenants.json"))


def secret(name):
    # in production this is a read from the secrets manager (OpenBao, OCI Vault...), audited per tenant
    return open(f"{SECRETS}/{name.replace('/', '_')}").read().strip()


class JsonBearerAdapter:
    """Providers with a JSON API and an API key in the Authorization header."""
    def __init__(self, cfg):
        self.cfg = cfg

    def send(self, phone, text):
        r = requests.post(f"{self.cfg['base_url']}/v1/messages", timeout=10,
                          headers={"Authorization": f"Bearer {secret(self.cfg['secret'])}"},
                          json={"to": phone, "sender": self.cfg["sender"], "text": text})
        return r.status_code, r.json()


class LegacyFormAdapter:
    """Providers with a form POST that carries a user name and a password."""
    def __init__(self, cfg):
        self.cfg = cfg

    def send(self, phone, text):
        r = requests.post(f"{self.cfg['base_url']}/api/send", timeout=10,
                          data={"user": self.cfg["user"], "pass": secret(self.cfg["secret"]),
                                "numbers": phone.lstrip("+"), "from": self.cfg["sender"], "msg": text})
        return r.status_code, r.json()


ADAPTERS = {"jsonbearer": JsonBearerAdapter, "legacyform": LegacyFormAdapter}


def send_otp(tenant, phone, text):
    cfg = TENANTS[tenant]["sms"]
    adapter = ADAPTERS[cfg["adapter"]](cfg)
    status, body = adapter.send(phone, text)
    print(f"tenant={tenant} adapter={cfg['adapter']} sender={cfg['sender']} -> {status} {body}")


if __name__ == "__main__":
    send_otp(sys.argv[1], sys.argv[2], "Your code is 482913. It expires in 5 minutes.")

ومولد السجلات يكتب أحداثاً لمستأجرين في ملف واحد مشترك، كما يحدث في أي منصة مشتركة:

"""Writes structured audit events for two tenants into the shared application log (one JSON object per line)."""
import json, sys, time
from datetime import datetime, timezone

EVENTS = [
    ("client-gov", "auth.login.success", {"user": "[email protected]", "src_ip": "10.20.1.15", "mfa": True}),
    ("retail-co",  "auth.login.failure", {"user": "[email protected]", "src_ip": "10.30.4.2", "reason": "bad_password"}),
    ("client-gov", "admin.role.granted", {"actor": "[email protected]", "target": "[email protected]", "role": "approver"}),
    ("retail-co",  "data.export",        {"actor": "[email protected]", "object": "customers", "rows": 1200}),
    ("client-gov", "data.export",        {"actor": "[email protected]", "object": "requests", "rows": 340}),
]

with open("/run/lab/logs/app.log", "a", encoding="utf-8") as f:
    for tenant, event, fields in EVENTS + ([("", "auth.login.failure", {"user": "unknown", "src_ip": "203.0.113.9"})] if "--untagged" in sys.argv else []):
        rec = {"ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds"), "tenant": tenant or None,
               "event": event, **fields, "service": "saasco-api"}
        if not tenant:
            rec.pop("tenant")
        f.write(json.dumps(rec) + "\n")
        time.sleep(0.05)
print("written")

وناقل السجلات عندنا يقرأ الملف، ويمرر أحداث العميل client-gov فقط، ويرسلها إلى HEC الخاص به عبر HTTPS ويتحقق من شهادته:

# Our side: read the shared application log, keep only one tenant's events, send them to that tenant's Splunk HEC.
sources:
  app_log:
    type: file
    include: ["/run/lab/logs/app.log"]
    read_from: beginning

transforms:
  parsed:
    type: remap
    inputs: ["app_log"]
    source: |
      . = parse_json!(.message)
      # keep the time the event happened, not the time it was shipped
      .timestamp = parse_timestamp!(.ts, "%+")
  only_client_gov:
    type: filter
    inputs: ["parsed"]
    # fail closed: an event without a tenant field never leaves
    condition: 'exists(.tenant) && .tenant == "client-gov"'

sinks:
  client_gov_hec:
    type: splunk_hec_logs
    inputs: ["only_client_gov"]
    endpoint: "https://siem.client-gov.test:8088"
    default_token: "${HEC_TOKEN}"
    index: "saas_audit"
    sourcetype: "saasco:audit"
    encoding:
      codec: json
    tls:
      ca_file: "/run/lab/ca/root.pem"

وعند العميل مستقبل يفهم بروتوكول Splunk HEC، وهو المصدر splunk_hec في Vector، يقبل التوكن الصحيح فقط ويكتب ما يصله في ملف، ويقوم هنا مقام Splunk نفسه:

# The client's side: a Splunk HEC-compatible receiver (Vector's splunk_hec source) writing what arrives to a file.
sources:
  hec:
    type: splunk_hec
    address: "0.0.0.0:8088"
    valid_tokens: ["${HEC_TOKEN}"]
    tls:
      enabled: true
      crt_file: "/run/lab/hec/fullchain.pem"
      key_file: "/run/lab/hec/siem.key"

sinks:
  received:
    type: file
    inputs: ["hec"]
    path: "/run/lab/out/siem-received.json"
    encoding:
      codec: json

التشغيل

cd lab
docker compose build
mkdir -p run
docker run --rm --name lab-tenant-setup -v "$PWD/run:/run/lab" -v "$PWD:/src:ro" lab-tenant-py sh /src/setup.sh
docker compose up -d
docker compose ps --format '{{.Name}} {{.Status}}'

والمخرج سوف يكون كما يلي:

lab-tenant-app Up 32 seconds
lab-tenant-ca Up 17 seconds
lab-tenant-caddy Up 28 seconds
lab-tenant-dns Up 36 seconds
lab-tenant-gw Up 25 seconds
lab-tenant-hec Up 20 seconds
lab-tenant-intruder Up 29 seconds
lab-tenant-pebble Up 34 seconds
lab-tenant-rspamd Up 39 seconds (healthy)
lab-tenant-rx Up 11 seconds
lab-tenant-sms Up 13 seconds
lab-tenant-vector Up 45 seconds

وتأكد أن DNS يجيب عن النطاقات الوهمية من داخل جهاز التطبيق:

docker exec lab-tenant-app sh -c 'dig +short MX client-gov.test; dig +short portal.client-gov.test; dig +short TXT _dmarc.client-gov.test'
10 mail.client-gov.test.
app.saasco.test.
10.77.77.80
"v=DMARC1; p=reject; adkim=r; aspf=r"

وإذا كنت تعمل على Windows في Git Bash فاكتب export MSYS_NO_PATHCONV=1 قبل أوامر docker exec التالية، لأن Git Bash يحول المسارات التي تبدأ بـ / إلى مسارات Windows.

الشهادات

الخطوة 1: شهادة عند الطلب لنطاق العميل

نطاق العميل portal.client-gov.test يشير إلى المنصة بسجل CNAME، وهو مسجل ومتحقق منه في إعدادات المستأجرين. نطلب الصفحة لأول مرة، ثم مرة ثانية، ثم نطلب نطاقاً لا يعرفه النظام وجهناه إلى المنصة:

docker exec lab-tenant-app sh -c 'curl -sk https://pebble:15000/roots/0 > /run/lab/pebble-root.pem
curl -sS -w "\nHTTP %{http_code} in %{time_total}s\n" --cacert /run/lab/pebble-root.pem https://portal.client-gov.test/
curl -sS -w "\nHTTP %{http_code} in %{time_total}s\n" --cacert /run/lab/pebble-root.pem https://portal.client-gov.test/
echo | openssl s_client -connect portal.client-gov.test:443 -servername portal.client-gov.test 2>/dev/null | openssl x509 -noout -issuer -ext subjectAltName -dates
curl -sS --cacert /run/lab/pebble-root.pem --resolve random-site.test:443:10.77.77.80 https://random-site.test/'
docker logs lab-tenant-app
tenant site for portal.client-gov.test
HTTP 200 in 5.393259s
tenant site for portal.client-gov.test
HTTP 200 in 0.272769s
issuer=CN=Pebble Intermediate CA 0488c6
X509v3 Subject Alternative Name: critical
    DNS:portal.client-gov.test
notBefore=Oct 11 10:54:34 2026 GMT
notAfter=Jan  9 10:54:33 2027 GMT
curl: (35) TLS connect error: error:0A000438:SSL routines::tlsv1 alert internal error
ask domain=portal.client-gov.test tenant=client-gov -> 200
ask domain=random-site.test tenant=- -> 403

لاحظ أن الطلب الأول استغرق خمس ثوان تقريباً، لأن Caddy أوقف المصافحة وسأل نقطة ask، ثم طلب الشهادة من Pebble، وPebble اتصل بالمنفذ 80 على عنوان النطاق ليتحقق عبر HTTP، ثم صدرت الشهادة لمدة 90 يوماً، والطلب الثاني استغرق ربع ثانية لأن الشهادة أصبحت محفوظة. وهذا ما سجله Caddy:

{"logger":"http.acme_client","msg":"trying to solve challenge","identifier":"portal.client-gov.test","challenge_type":"http-01","ca":"https://pebble:14000/dir"}
{"logger":"http","msg":"served key authentication","identifier":"portal.client-gov.test","challenge":"http-01","remote":"10.77.77.14:39508","distributed":false}
{"logger":"http.acme_client","msg":"authorization finalized","identifier":"portal.client-gov.test","authz_status":"valid"}
{"logger":"tls.obtain","msg":"certificate obtained successfully","identifier":"portal.client-gov.test","issuer":"pebble:14000-dir"}

أما النطاق الذي لا يعرفه النظام فقد رفضته نقطة السؤال بالرمز 403، فلم يطلب Caddy أي شهادة، وفشلت المصافحة بالخطأ tlsv1 alert internal error، وهذا هو السلوك الذي تصفه وثائق Caddy: أي رد غير 2xx يلغي الإصدار ويفشل المصافحة. وفي الإنتاج هذا الفحص هو ما يمنع أي شخص من توجيه آلاف النطاقات إلى منصتك واستنزاف حدود جهة الإصدار.

الخطوة 2: طلب CSR توقعه جهة إصدار العميل

العميل يريد أن تكون شهادة secure.client-gov.test من جهة إصداره هو. يضيف سجل CNAME، ونولد نحن المفتاح والطلب، ثم ترسل الطلب إليه:

docker exec lab-tenant-app sh /app/dns-set.sh client-gov.test secure CNAME app.saasco.test.
docker exec lab-tenant-app sh -c 'cd /run/lab/secure
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out secure.key && chmod 600 secure.key
openssl req -new -key secure.key -subj "/C=SA/O=Client Gov/CN=secure.client-gov.test" \
  -addext "subjectAltName=DNS:secure.client-gov.test" -out secure.csr
openssl req -in secure.csr -noout -verify -subject -text | grep -E "verify|subject=|Public Key Algorithm|DNS:"
ls -l'
secure.client-gov.test CNAME app.saasco.test.
Certificate request self-signature verify OK
            Public Key Algorithm: id-ecPublicKey
                    DNS:secure.client-gov.test
subject=C=SA, O=Client Gov, CN=secure.client-gov.test
total 0
-rw-r--r-- 1 root root 493 Oct 11 10:56 secure.csr
-rw------- 1 root root 241 Oct 11 10:56 secure.key

ثم يوقع موظف جهة الإصدار عند العميل الطلب، ونتأكد نحن عند الاستلام أن الشهادة لمفتاحنا بمقارنة بصمة المفتاح العام:

docker exec lab-tenant-ca sh /run/lab/ca/sign-csr.sh /run/lab/secure/secure.csr secure.client-gov.test 365 /run/lab/secure/secure.pem
docker exec lab-tenant-app openssl x509 -in /run/lab/secure/secure.pem -noout -subject -issuer -ext authorityInfoAccess -enddate
docker exec lab-tenant-app sh -c 'cd /run/lab/secure; openssl pkey -in secure.key -pubout | sha256sum; openssl x509 -in secure.pem -noout -pubkey | sha256sum'
Certificate request self-signature ok
subject=C=SA, O=Client Gov, CN=secure.client-gov.test
subject=C=SA, O=Client Gov, CN=secure.client-gov.test
issuer=C=SA, O=Client Gov Lab, CN=Client Gov Issuing CA 1
Authority Information Access:
    CA Issuers - URI:http://pki.client-gov.test/issuing-ca-1.der
notAfter=Oct 11 10:56:37 2027 GMT
5fc73f427be8bb7f0e546ca1433515f5c9fae9e0a1335a26525bf32c915e0979  -
5fc73f427be8bb7f0e546ca1433515f5c9fae9e0a1335a26525bf32c915e0979  -

البصمتان متطابقتان، فالشهادة لمفتاحنا، والمفتاح لم يغادر الجهاز.

الخطوة 3: نركب شهادة النطاق وحدها

وهذا ما يحدث كثيراً: يعيد العميل ملف secure.pem وحده، فنركبه كما وصل. نكتب موقعاً في مجلد مواقع العملاء، ونعيد تحميل Caddy، ثم نختبر كما يختبر تطبيق أو سيرفر آخر:

docker exec lab-tenant-app sh -c 'cat > /run/lab/caddy-sites/secure.caddy <<EOF
secure.client-gov.test {
	tls /run/lab/secure/secure.pem /run/lab/secure/secure.key
	respond "tenant site for {host} (client-provided certificate)" 200
}
EOF'
docker exec lab-tenant-caddy caddy reload --config /etc/caddy/Caddyfile
docker exec lab-tenant-app sh -c 'echo | openssl s_client -connect secure.client-gov.test:443 -servername secure.client-gov.test -CAfile /run/lab/ca/root.pem -showcerts 2>&1 | grep -E "^ *[0-9] s:|^ *i:|Verify return code|verify error"'
docker exec lab-tenant-app curl -sS --cacert /run/lab/ca/root.pem https://secure.client-gov.test/
docker exec lab-tenant-app python -c 'import requests
try:
    print(requests.get("https://secure.client-gov.test/", verify="/run/lab/ca/root.pem").text)
except Exception as e:
    print(type(e).__name__, e)'
verify error:num=20:unable to get local issuer certificate
verify error:num=21:unable to verify the first certificate
 0 s:C=SA, O=Client Gov, CN=secure.client-gov.test
   i:C=SA, O=Client Gov Lab, CN=Client Gov Issuing CA 1
Verify return code: 21 (unable to verify the first certificate)
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the webpage mentioned above.
SSLError HTTPSConnectionPool(host='secure.client-gov.test', port=443): Max retries exceeded with url: / (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1032)')))

لاحظ أننا أعطينا الأدوات الثلاث الشهادة الرئيسية للعميل root.pem، أي أنها تثق بجهة العميل، ومع ذلك فشلت، والسبب أن السيرفر أرسل شهادة واحدة 0 s: موقعة من Client Gov Issuing CA 1، والأدوات لا تعرف هذه الشهادة الوسيطة، فلا تستطيع أن تبني الطريق إلى الشهادة الرئيسية. والآن نفعل ما يفعله المتصفح عندما يجد هذا النقص ولا يجد الشهادة الوسيطة في ذاكرته: يقرأ رابط AIA من الشهادة، وينزل الشهادة الوسيطة منه، ثم يتحقق:

docker exec lab-tenant-app sh -c 'cd /tmp
echo | openssl s_client -connect secure.client-gov.test:443 -servername secure.client-gov.test 2>/dev/null | openssl x509 > served-leaf.pem
URL=$(openssl x509 -in served-leaf.pem -noout -ext authorityInfoAccess | sed -n "s/.*CA Issuers - URI://p")
echo "AIA URL: $URL"
curl -sS -o issuer.der "$URL" && openssl x509 -inform DER -in issuer.der -out issuer.pem && openssl x509 -in issuer.pem -noout -subject
openssl verify -CAfile /run/lab/ca/root.pem -untrusted issuer.pem served-leaf.pem'
AIA URL: http://pki.client-gov.test/issuing-ca-1.der
subject=C=SA, O=Client Gov Lab, CN=Client Gov Issuing CA 1
served-leaf.pem: OK

هذا هو سبب «يعمل في Chrome ولا يعمل في التطبيق»: المتصفح يصلح خطأ السيرفر بنفسه، والتطبيقات والسيرفرات الأخرى لا تفعل.

الخطوة 4: السلسلة الكاملة

نطلب من العميل الشهادة الوسيطة بصيغة PEM، ونفحص أنها تكمل الطريق قبل التركيب، ثم نبني الملف بالترتيب الصحيح، شهادة النطاق أولاً ثم الوسيطة:

docker exec lab-tenant-app sh -c 'cd /run/lab/secure
cp /run/lab/ca/issuing.pem chain.pem
openssl verify -CAfile /run/lab/ca/root.pem -untrusted chain.pem secure.pem
cat secure.pem chain.pem > fullchain.pem
grep -c "BEGIN CERTIFICATE" fullchain.pem
sed -i "s#/run/lab/secure/secure.pem#/run/lab/secure/fullchain.pem#" /run/lab/caddy-sites/secure.caddy'
docker exec lab-tenant-caddy caddy reload --config /etc/caddy/Caddyfile
docker exec lab-tenant-app sh -c 'echo | openssl s_client -connect secure.client-gov.test:443 -servername secure.client-gov.test -CAfile /run/lab/ca/root.pem -showcerts 2>&1 | grep -E "^ *[0-9] s:|^ *i:|Verify return code"
curl -sS --cacert /run/lab/ca/root.pem https://secure.client-gov.test/; echo
python -c "import requests;print(requests.get(\"https://secure.client-gov.test/\", verify=\"/run/lab/ca/root.pem\").status_code)"'
secure.pem: OK
2
 0 s:C=SA, O=Client Gov, CN=secure.client-gov.test
   i:C=SA, O=Client Gov Lab, CN=Client Gov Issuing CA 1
 1 s:C=SA, O=Client Gov Lab, CN=Client Gov Issuing CA 1
   i:C=SA, O=Client Gov Lab, CN=Client Gov Root CA
Verify return code: 0 (ok)
tenant site for secure.client-gov.test (client-provided certificate)
200

الآن يرسل السيرفر شهادتين، والشهادة الرئيسية لا ترسل لأنها عند الكلاينت، وتنجح الأدوات الثلاث.

الخطوة 5: شهادة منتهية، وفحص المراقبة

نوقع الطلب نفسه بتاريخ انتهى، ونركبه مؤقتاً، ثم نشغل الفحص الذي نضعه في المراقبة على الملفين، ثم نعيد السلسلة الصحيحة. والخياران -not_before و-not_after في الأمر openssl x509 متاحان في OpenSSL 3.4 وما بعده:

docker exec lab-tenant-ca sh -c 'cd /run/lab/ca && openssl x509 -req -in /run/lab/secure/secure.csr -CA issuing.pem -CAkey issuing.key -CAcreateserial \
  -not_before 20250901000000Z -not_after 20251001000000Z -extfile req.ext -out /run/lab/secure/expired.pem \
  && cat /run/lab/secure/expired.pem issuing.pem > /run/lab/secure/expired-fullchain.pem'
docker exec lab-tenant-app sed -i "s#/run/lab/secure/fullchain.pem#/run/lab/secure/expired-fullchain.pem#" /run/lab/caddy-sites/secure.caddy
docker exec lab-tenant-caddy caddy reload --config /etc/caddy/Caddyfile
docker exec lab-tenant-app sh -c 'curl -sS --cacert /run/lab/ca/root.pem https://secure.client-gov.test/
echo | openssl s_client -connect secure.client-gov.test:443 -servername secure.client-gov.test -CAfile /run/lab/ca/root.pem 2>&1 | grep -E "verify error|Verify return code" | head -2
for f in fullchain expired-fullchain; do printf "%s: " $f; openssl x509 -in /run/lab/secure/$f.pem -noout -enddate -checkend 2592000; done'
docker exec lab-tenant-app sed -i "s#/run/lab/secure/expired-fullchain.pem#/run/lab/secure/fullchain.pem#" /run/lab/caddy-sites/secure.caddy
docker exec lab-tenant-caddy caddy reload --config /etc/caddy/Caddyfile
curl: (60) SSL certificate problem: certificate has expired
More details here: https://curl.se/docs/sslcerts.html
...
verify error:num=10:certificate has expired
Verify return code: 10 (certificate has expired)
fullchain: notAfter=Oct 11 10:56:37 2027 GMT
Certificate will not expire
expired-fullchain: notAfter=Oct  1 00:00:00 2025 GMT
Certificate will expire

والفحص -checkend 2592000 يعني «هل تنتهي خلال 30 يوماً؟»، ويخرج برمز غير الصفر عندما يكون الجواب نعم، فيصلح لأي نظام مراقبة. وفي الإنتاج شغله على ما يرسله السيرفر فعلاً كما في قسم التجديد، وليس على الملف.

البريد بالطرق الثلاث

لكل طريقة نشغل السكربت، ثم نقرأ ترويسات آخر رسالة وصلت إلى صندوق المواطن بهذا الأمر، وقد شرحنا معنى الترويسات في قسم قراءة الرسالة الأصلية:

docker exec lab-tenant-rx sh -c 'f=$(ls -t /home/citizen/Maildir/new/* | head -1); sed "/^$/q" "$f" | awk "/^[^ \t]/{p=0} /^(Return-Path|Received|From|DKIM-Signature|Authentication-Results):/{p=1} p"'

الطريقة 1: من نطاقنا

docker exec lab-tenant-app python /app/send_mail.py platform
# MX inbox.test -> mx.inbox.test
C: ehlo mail.saasco.test
S: 250-mx.inbox.test
S: 250-PIPELINING
S: 250-SIZE 10240000
S: 250-VRFY
S: 250-ETRN
S: 250-ENHANCEDSTATUSCODES
S: 250-8BITMIME
S: 250-DSN
S: 250-SMTPUTF8
S: 250 CHUNKING
C: mail from:<[email protected]> size=910
S: 250 2.1.0 Ok
C: rcpt to:<[email protected]>
S: 250 2.1.5 Ok
C: data
S: 354 End data with <CR><LF>.<CR><LF>
C: [message headers and body, 915 bytes]
S: 250 2.0.0 Ok: queued as A84211CBEBC
C: quit
S: 221 2.0.0 Bye

وهذه ترويسات الرسالة عند المستلم:

Return-Path: <[email protected]>
Authentication-Results: mx.inbox.test;
	dkim=pass header.d=saasco.test header.s=s2026 header.b=x5ELhpNw;
	dmarc=pass (policy=reject) header.from=saasco.test;
	spf=pass (mx.inbox.test: domain of [email protected] designates 10.77.77.25 as permitted sender) [email protected]
From: ClientGov via SaaSCo <[email protected]>

في المخرج أعلاه لاحظ التالي: السكربت بحث عن MX لنطاق المستلم، ثم أرسل عنوان الظرف [email protected] في الأمر mail from، وهو ما فحصه SPF، أما From الذي يراه القارئ فهو [email protected]، ونجح DMARC لأن النطاق الفرعي للظرف يتطابق مع النطاق الرئيسي في التطابق المرن، ولأن التوقيع d=saasco.test يطابقه كذلك. وسيرفر المستلم في التجربة لا يقدم STARTTLS لأننا لم نعطه شهادة، لذلك لا يظهر الأمر في المحادثة.

الطريقة 2: مباشرة باسم نطاق العميل

نرسل أولاً قبل أن يضيف العميل أي سجل، ثم نقرأ سجل Rspamd لنعرف السبب:

docker exec lab-tenant-app python /app/send_mail.py direct
docker logs lab-tenant-rspamd 2>&1 | grep rspamd_task_write_log | tail -1 \
  | grep -oE "R_SPF_[A-Z]+\([^)]*\)\{[^}]*\}|R_DKIM_[A-Z]+\([^)]*\)\{[^}]*\}|DMARC_POLICY_[A-Z]+\([^)]*\)\{[^}]*\}"
C: mail from:<[email protected]> size=923
S: 250 2.1.0 Ok
C: rcpt to:<[email protected]>
S: 250 2.1.5 Ok
C: data
S: 354 End data with <CR><LF>.<CR><LF>
C: [message headers and body, 928 bytes]
S: 554 5.7.1 Rejected: DMARC policy of the From domain (no aligned SPF or DKIM pass)
C: rset
S: 250 2.0.0 Ok
# rejected: (554, b'5.7.1 Rejected: DMARC policy of the From domain (no aligned SPF or DKIM pass)')

DMARC_POLICY_REJECT(2.00){client-gov.test : No valid SPF;reject;}
R_SPF_FAIL(1.00){-all:c;}
R_DKIM_PERMFAIL(0.00){client-gov.test:s=saas;}

لاحظ أن الرفض جاء بعد نص الرسالة، لأن DKIM يحتاج الرسالة كاملة ليتحقق منها، وأن السبب مكتوب في السجل: SPF للعميل فيه -all ولا يعرف عنواننا، والمفتاح s=saas غير موجود في DNS العميل فكان PERMFAIL. والآن يضيف العميل السجلين اللذين شرحناهما في المقال الأساسي:

docker exec lab-tenant-app sh /app/dns-set.sh client-gov.test @ TXT '"v=spf1 ip4:10.77.77.30 ip4:10.77.77.31 include:_spf.saasco.test -all"'
docker exec lab-tenant-app sh /app/dns-set.sh client-gov.test saas._domainkey CNAME client-gov._dk.saasco.test.
docker exec lab-tenant-app sh -c 'dig +short TXT client-gov.test; dig +short TXT saas._domainkey.client-gov.test | cut -c1-80'
docker exec lab-tenant-app python /app/send_mail.py direct | grep -E "^S: 250 2.0.0|^C: mail|^S: 5"
"v=spf1 ip4:10.77.77.30 ip4:10.77.77.31 include:_spf.saasco.test -all"
client-gov._dk.saasco.test.
"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApwzVmFMQEMHAR+v5Q
C: mail from:<[email protected]> size=923
S: 250 2.0.0 Ok: queued as 832C11D95AC
Authentication-Results: mx.inbox.test;
	dkim=pass header.d=client-gov.test header.s=saas header.b=jbdq1SIM;
	dmarc=pass (policy=reject) header.from=client-gov.test;
	spf=fail (mx.inbox.test: domain of [email protected] does not designate 10.77.77.25 as permitted sender) [email protected]

وهنا مفاجأة صغيرة: نجح DMARC ولكن SPF فشل، مع أن السجل الجديد ظاهر في dig. والسبب أن Rspamd حفظ سجل SPF القديم في ذاكرته المؤقتة بحسب مدة TTL، وهذا ما يحدث في الإنتاج تماماً عندما يغير العميل سجلاً ويختبر بعد دقيقة، ولاحظ أن DMARC نجح بتوقيع DKIM المتطابق وحده، لأن DMARC يكفيه نجاح واحد متطابق. وبعد أن أعدنا تشغيل Rspamd ليبدأ بذاكرة فارغة، كما لو انتظرنا انتهاء المدة، أعدنا الإرسال:

Authentication-Results: mx.inbox.test;
	dkim=pass header.d=client-gov.test header.s=saas header.b=AZ6fbDcY;
	dmarc=pass (policy=reject) header.from=client-gov.test;
	spf=pass (mx.inbox.test: domain of [email protected] designates 10.77.77.25 as permitted sender) [email protected]

ثم نكسرها بالخطأ الأكثر شيوعاً في هذه الطريقة: From من نطاق العميل، ولكن التوقيع بنطاقنا والظرف من نطاقنا:

docker exec lab-tenant-app python /app/send_mail.py direct-ours | grep -E "^S: 250 2.0.0|^C: mail|^S: 5"
C: mail from:<[email protected]> size=912
S: 554 5.7.1 Rejected: DMARC policy of the From domain (no aligned SPF or DKIM pass)

DMARC_POLICY_REJECT(2.00){client-gov.test : SPF not aligned (relaxed), DKIM not aligned (relaxed);reject;}
R_SPF_ALLOW(-0.20){+ip4:10.77.77.25;}
R_DKIM_ALLOW(-0.10){saasco.test:s=s2026;}

SPF نجح وDKIM نجح، والرسالة رفضت، لأن كليهما لنطاق saasco.test وليس للنطاق في From.

الطريقة 3: عبر بوابة العميل

نعيد سجل SPF للعميل إلى حاله الأصلي دون أي include لنا، لأن هذه الطريقة لا تحتاج أي تغيير عنده، ثم نرسل عبر البوابة:

docker exec lab-tenant-app sh /app/dns-set.sh client-gov.test @ TXT '"v=spf1 ip4:10.77.77.30 ip4:10.77.77.31 -all"'
docker restart lab-tenant-rspamd
docker exec lab-tenant-app python /app/send_mail.py relay
C: ehlo mail.saasco.test
S: 250-relay.client-gov.test
S: 250-PIPELINING
S: 250-SIZE 10240000
S: 250-VRFY
S: 250-ETRN
S: 250-STARTTLS
S: 250-ENHANCEDSTATUSCODES
S: 250-8BITMIME
S: 250-DSN
S: 250-SMTPUTF8
S: 250 CHUNKING
C: STARTTLS
S: 220 2.0.0 Ready to start TLS
# TLS: TLSv1.3 TLS_AES_256_GCM_SHA384
C: ehlo mail.saasco.test
S: 250-relay.client-gov.test
S: 250-PIPELINING
S: 250-SIZE 10240000
S: 250-VRFY
S: 250-ETRN
S: 250-AUTH PLAIN LOGIN
S: 250-ENHANCEDSTATUSCODES
S: 250-8BITMIME
S: 250-DSN
S: 250-SMTPUTF8
S: 250 CHUNKING
C: AUTH PLAIN ********
S: 235 2.7.0 Authentication successful
C: mail from:<[email protected]> size=339
S: 250 2.1.0 Ok
C: rcpt to:<[email protected]>
S: 250 2.1.5 Ok
C: data
S: 354 End data with <CR><LF>.<CR><LF>
C: [message headers and body, 344 bytes]
S: 250 2.0.0 Ok: queued as 1D0271D95F1
C: quit
S: 221 2.0.0 Bye

في المخرج أعلاه لاحظ التالي:

  • في أول رد ehlo لا يوجد AUTH، والبوابة تعرض STARTTLS فقط، وبعد التشفير يظهر 250-AUTH PLAIN LOGIN، وهذا أثر smtpd_tls_auth_only=yes.
  • الاتصال بـ TLS 1.3، والسكربت يرفض أي إصدار أقدم من TLS 1.2 ويتحقق من شهادة البوابة بالشهادة الرئيسية لجهة العميل.
  • المنصة لم توقع الرسالة، ولم تبحث عن أي MX، وحجم الرسالة 339 بايت فقط لأنه لا يوجد فيها توقيع DKIM.

وهذا ما سجلته البوابة، ثم ما وصل إلى المستلم:

relay postfix/smtpd[183]: connect from lab-tenant-app.lab-tenant-net[10.77.77.25]
relay postfix/smtpd[183]: Anonymous TLS connection established from lab-tenant-app.lab-tenant-net[10.77.77.25]: TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange x25519 server-signature RSA-PSS (2048 bits) server-digest SHA256
relay postfix/smtpd[183]: 1D0271D95F1: client=lab-tenant-app.lab-tenant-net[10.77.77.25], sasl_method=PLAIN, [email protected]
relay postfix/qmgr[181]: 1D0271D95F1: from=<[email protected]>, size=595, nrcpt=1 (queue active)
relay postfix/smtp[190]: 1D0271D95F1: to=<[email protected]>, relay=mx.inbox.test[10.77.77.40]:25, delay=0.49, delays=0.14/0.02/0/0.33, dsn=2.0.0, status=sent (250 2.0.0 Ok: queued as 4358A1D994A)
Return-Path: <[email protected]>
Received: from relay.client-gov.test (lab-tenant-gw.lab-tenant-net [10.77.77.30])
	by mx.inbox.test (Postfix) with ESMTP id 4358A1D994A
	for <[email protected]>; Sun, 11 Oct 2026 11:03:41 +0000 (UTC)
Authentication-Results: mx.inbox.test;
	dkim=pass header.d=client-gov.test header.s=fm2026 header.b=DIoMXRMD;
	dmarc=pass (policy=reject) header.from=client-gov.test;
	spf=pass (mx.inbox.test: domain of [email protected] designates 10.77.77.30 as permitted sender) [email protected]
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=client-gov.test;
	s=fm2026; t=1791716621;
	bh=pOb4ItCjNqmFpMmu0uZuTThqlJZuNcGV0/sBaLCOubE=;
	h=From:To:Subject:Date;
	b=DIoMXRMDtXj+5cQuFyvm1MUMhl+DdqrOjGdd/pdKOtU3of6p9/qQS3+ZMtOGG4Irs
	 ...
Received: from mail.saasco.test (lab-tenant-app.lab-tenant-net [10.77.77.25])
	by relay.client-gov.test (Postfix) with ESMTPSA id 1D0271D95F1
	for <[email protected]>; Sun, 11 Oct 2026 11:03:41 +0000 (UTC)
From: Client Gov <[email protected]>

وهذا هو الجواب على سؤال فريق العميل كاملاً: الحجم عند البوابة أصبح 595 بايت لأنها أضافت توقيعها، وSPF فحص عنوان البوابة 10.77.77.30 الموجود في سجل العميل منذ البداية، والتوقيع d=client-gov.test s=fm2026 بمفتاح العميل، فنجح DMARC دون أي سجل جديد.

نكسر الإرسال عبر البوابة

أولاً من جهاز آخر يملك كلمة المرور الصحيحة، كأنها سرقت أو كأن عنوان NAT تغير بعد إعادة بناء الشبكة، وحذفنا من المخرجات أسطر 250- المكررة:

docker exec lab-tenant-intruder python /app/send_mail.py relay | grep -vE "^S: 250-"
C: ehlo mail.saasco.test
S: 250 CHUNKING
C: STARTTLS
S: 220 2.0.0 Ready to start TLS
# TLS: TLSv1.3 TLS_AES_256_GCM_SHA384
C: ehlo mail.saasco.test
S: 250 CHUNKING
C: AUTH PLAIN ********
S: 235 2.7.0 Authentication successful
C: mail from:<[email protected]> size=339
S: 250 2.1.0 Ok
C: rcpt to:<[email protected]>
S: 554 5.7.1 <lab-tenant-intruder.lab-tenant-net[10.77.77.66]>: Client host rejected: Access denied
C: rset
S: 250 2.0.0 Ok
# failed: SMTPRecipientsRefused: {'[email protected]': (554, b'5.7.1 <lab-tenant-intruder.lab-tenant-net[10.77.77.66]>: Client host rejected: Access denied')}

لاحظ أن تسجيل الدخول نجح، ومع ذلك رفض الطلب عند rcpt to، لأن Postfix يؤخر الرفض بقيود العميل إلى هذه المرحلة افتراضياً. ثم دون تسجيل دخول، مرة بالعنوان المرتبط بحسابنا ومرة بعنوان آخر للعميل:

docker exec lab-tenant-app python /app/send_mail.py relay-noauth | grep -E "^C: mail|^S: 5"
docker exec lab-tenant-app python /app/send_mail.py relay-open | grep -E "^C: mail|^S: 5"
C: mail from:<[email protected]> size=315
S: 553 5.7.1 <[email protected]>: Sender address rejected: not logged in
C: mail from:<[email protected]> size=330
S: 554 5.7.1 <[email protected]>: Relay access denied

ثم دون STARTTLS، ثم بعنوان ظرف لا يملكه الحساب، ثم دون أن نثق بجهة إصدار العميل:

docker exec lab-tenant-app python /app/send_mail.py relay-plain | grep -vE "^S: 250-"
docker exec lab-tenant-app python /app/send_mail.py relay-sender | grep -E "^C: mail|^S: 5"
docker exec lab-tenant-app python /app/send_mail.py relay-notrust | grep -vE "^S: 250-"
C: ehlo mail.saasco.test
S: 250 CHUNKING
C: mail from:<[email protected]> size=314
S: 530 5.7.0 Must issue a STARTTLS command first
C: rset
S: 530 5.7.0 Must issue a STARTTLS command first
# failed: SMTPSenderRefused: (530, b'5.7.0 Must issue a STARTTLS command first', '[email protected]')

C: mail from:<[email protected]> size=329
S: 553 5.7.1 <[email protected]>: Sender address rejected: not owned by user [email protected]

C: ehlo mail.saasco.test
S: 250 CHUNKING
C: STARTTLS
S: 220 2.0.0 Ready to start TLS
# failed: SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1032)

وفي الحالة الأخيرة سجلت البوابة من جهتها SSL_accept error وtlsv1 alert unknown ca، أي أن منصتنا هي التي قطعت الاتصال لأنها لا تثق بشهادة البوابة، وهذا هو التصرف الصحيح، والحل أن تضيف الشهادة الرئيسية للعميل وليس أن تعطل التحقق. ثم الخطأ الذي لا يظهر في المحادثة: البوابة تقبل الرسالة، ولكن From من نطاقنا:

docker exec lab-tenant-app python /app/send_mail.py relay-from | grep -E "^C: mail|^S: 250 2.0.0|^S: [45]"
docker logs lab-tenant-gw 2>&1 | grep status=bounced | tail -1
C: mail from:<[email protected]> size=318
S: 250 2.0.0 Ok: queued as 0C4971CB230
relay postfix/smtp[190]: 0C4971CB230: to=<[email protected]>, relay=mx.inbox.test[10.77.77.40]:25, delay=0.17, delays=0.05/0/0.01/0.11, dsn=5.7.1, status=bounced (host mx.inbox.test[10.77.77.40] said: 554 5.7.1 Rejected: DMARC policy of the From domain (no aligned SPF or DKIM pass) (in reply to end of DATA command))

DMARC_POLICY_REJECT(2.00){saasco.test : SPF not aligned (relaxed), No valid DKIM;reject;}
R_SPF_ALLOW(-0.20){+ip4:10.77.77.30:c;}
R_DKIM_NA(0.00){}

منصتنا رأت 250 وظنت أن كل شيء بخير، والبوابة لم توقع لأن From ليس من نطاق العميل، وSPF نجح لعنوان البوابة ولكن لنطاق الظرف client-gov.test الذي لا يطابق saasco.test، فرفض المستلم الرسالة، وارتدت إلى [email protected] عند العميل، ولن نعرف بها إلا إذا اتفقنا معه على تقارير الارتداد. وأخيراً حد المعدل، سبع رسائل متتالية:

for i in 1 2 3 4 5 6 7; do printf "%s: " $i; docker exec lab-tenant-app python /app/send_mail.py relay \
  | grep -E "^S: 250 2.0.0 Ok: queued|^S: [45][0-9][0-9] " | head -1; done
1: S: 250 2.0.0 Ok: queued as 868DF184D23
2: S: 250 2.0.0 Ok: queued as 580E8E57E
3: S: 250 2.0.0 Ok: queued as 8AEF8185126
4: S: 450 4.7.1 Error: too much mail from 10.77.77.25
5: S: 450 4.7.1 Error: too much mail from 10.77.77.25
6: S: 450 4.7.1 Error: too much mail from 10.77.77.25
7: S: 450 4.7.1 Error: too much mail from 10.77.77.25

بدأ الرفض عند الرسالة الرابعة وليس السادسة، لأن رسائل الخطوات السابقة في الدقيقة نفسها دخلت في العداد، والرمز 450 مؤقت، فطابور الإرسال في منصتك يجب أن يعيد المحاولة بعد مهلة وليس أن يسجل الرسالة فاشلة.

الرسائل القصيرة عبر مزود العميل

نرسل رمزاً لمستخدم عند العميل client-gov، ورمزاً لمستخدم عند عميل آخر يستخدم مزود المنصة، ثم نرسل من جهاز المتطفل الذي يملك مفتاح العميل، ثم باسم مرسل غير مسجل لحساب العميل:

docker exec lab-tenant-app python /app/sms_adapter.py client-gov +966500000001
docker exec lab-tenant-app python /app/sms_adapter.py retail-co +966500000002
docker exec lab-tenant-intruder python /app/sms_adapter.py client-gov +966500000001
docker exec lab-tenant-app python -c '
import sys; sys.argv=["x"]; sys.path.insert(0,"/app")
import sms_adapter as s
s.TENANTS["client-gov"]["sms"]["sender"]="SAASCO"
s.send_otp("client-gov","+966500000001","test")'
docker logs lab-tenant-sms
tenant=client-gov adapter=jsonbearer sender=CLIENT-GOV -> 201 {'id': 'msg-1001', 'status': 'queued', 'to': '+966500000001'}
tenant=retail-co adapter=legacyform sender=SAASCO -> 200 {'code': 0, 'msgid': 'B-77', 'numbers': '966500000002'}
tenant=client-gov adapter=jsonbearer sender=CLIENT-GOV -> 403 {'error': 'source IP 10.77.77.66 is not on the allowlist'}
tenant=client-gov adapter=jsonbearer sender=SAASCO -> 422 {'error': "sender name 'SAASCO' is not registered for this account"}

10.77.77.25 /v1/messages -> 201 {'id': 'msg-1001', 'status': 'queued', 'to': '+966500000001'}
10.77.77.25 /api/send -> 200 {'code': 0, 'msgid': 'B-77', 'numbers': '966500000002'}
10.77.77.66 /v1/messages -> 403 {'error': 'source IP 10.77.77.66 is not on the allowlist'}
10.77.77.25 /v1/messages -> 422 {'error': "sender name 'SAASCO' is not registered for this account"}

لاحظ أن الكود الذي يطلب الرمز واحد في الحالات كلها، والفرق في الإعدادات فقط: العميل client-gov يمر عبر محول JSON إلى حسابه هو باسم المرسل الخاص به، والعميل الآخر يمر عبر المحول القديم إلى حساب المنصة، وأن المفتاح الصحيح من عنوان آخر رفض بالرمز 403، وأن اسم المرسل مربوط بالحساب فلا يستطيع الكود أن يرسل باسم المنصة من حساب العميل.

سجلات العميل إلى Splunk HEC

نكتب ستة أحداث في السجل المشترك: ثلاثة للعميل client-gov، واثنان لعميل آخر، وواحد دون حقل مستأجر، ثم نقرأ ما وصل إلى مستقبل العميل:

docker exec lab-tenant-app python /app/emit_logs.py --untagged
docker exec lab-tenant-app cat /run/lab/logs/app.log
cat run/out/siem-received.json
{"ts": "2026-10-11T11:07:17.291+00:00", "tenant": "client-gov", "event": "auth.login.success", "user": "[email protected]", "src_ip": "10.20.1.15", "mfa": true, "service": "saasco-api"}
{"ts": "2026-10-11T11:07:17.342+00:00", "tenant": "retail-co", "event": "auth.login.failure", "user": "[email protected]", "src_ip": "10.30.4.2", "reason": "bad_password", "service": "saasco-api"}
{"ts": "2026-10-11T11:07:17.392+00:00", "tenant": "client-gov", "event": "admin.role.granted", "actor": "[email protected]", "target": "[email protected]", "role": "approver", "service": "saasco-api"}
{"ts": "2026-10-11T11:07:17.442+00:00", "tenant": "retail-co", "event": "data.export", "actor": "[email protected]", "object": "customers", "rows": 1200, "service": "saasco-api"}
{"ts": "2026-10-11T11:07:17.492+00:00", "tenant": "client-gov", "event": "data.export", "actor": "[email protected]", "object": "requests", "rows": 340, "service": "saasco-api"}
{"ts": "2026-10-11T11:07:17.543+00:00", "event": "auth.login.failure", "user": "unknown", "src_ip": "203.0.113.9", "service": "saasco-api"}

{"event":"auth.login.success","mfa":true,"service":"saasco-api","source_type":"splunk_hec","splunk_channel":"d416dede-2ecd-4155-83ee-b140364f6c45","splunk_index":"saas_audit","splunk_sourcetype":"saasco:audit","src_ip":"10.20.1.15","tenant":"client-gov","timestamp":"2026-10-11T11:07:20.864236649Z","ts":"2026-10-11T11:07:17.291+00:00","user":"[email protected]"}
{"actor":"[email protected]","event":"admin.role.granted","role":"approver","service":"saasco-api","source_type":"splunk_hec","splunk_channel":"d416dede-2ecd-4155-83ee-b140364f6c45","splunk_index":"saas_audit","splunk_sourcetype":"saasco:audit","target":"[email protected]","tenant":"client-gov","timestamp":"2026-10-11T11:07:20.864236649Z","ts":"2026-10-11T11:07:17.392+00:00"}
{"actor":"[email protected]","event":"data.export","object":"requests","rows":340,"service":"saasco-api","source_type":"splunk_hec","splunk_channel":"d416dede-2ecd-4155-83ee-b140364f6c45","splunk_index":"saas_audit","splunk_sourcetype":"saasco:audit","tenant":"client-gov","timestamp":"2026-10-11T11:07:20.864236649Z","ts":"2026-10-11T11:07:17.492+00:00"}

وصلت أحداث العميل client-gov الثلاثة وحدها، ولم يصل حدثا العميل الآخر ولا الحدث الذي لا يحمل مستأجراً، وهذا معنى الشرط المغلق عند الفشل. ولكن انظر إلى الحقل timestamp: قيمته 11:07:20 في الأحداث الثلاثة، وهو وقت إرسالها وليس وقت حدوثها، والسبب أن Vector يضع وقت قراءة السطر إذا لم نخبره بغيره، وال SIEM عند العميل سوف يرتب الأحداث بهذا الحقل. لذلك أضفنا إلى ملف ours.yaml سطر .timestamp = parse_timestamp!(.ts, "%+") الموجود أعلاه، وأعدنا التشغيل، فأصبح الحقلان متطابقين:

{"event":"auth.login.success", ... ,"timestamp":"2026-10-11T11:07:54.312000036Z","ts":"2026-10-11T11:07:54.312+00:00","user":"[email protected]"}

وأخيراً، هل يقبل مستقبل العميل أي شيء يصله؟ نرسل حدثاً بتوكن خاطئ مباشرة إلى المسار الذي تصفه وثائق Splunk HEC:

docker exec lab-tenant-app curl -sS --cacert /run/lab/ca/root.pem -H "Authorization: Splunk 00000000-wrong" \
  -d '{"event":"test","fields":{"tenant":"client-gov"}}' https://siem.client-gov.test:8088/services/collector/event
{"text":"Invalid authorization","code":3}

ما الذي انكسر أثناء البناء؟

Rspamd لا يفحص شيئاً

في أول رسالة وصلت إلى المستلم لم تكن فيها ترويسة Authentication-Results، وفي سجل Rspamd ثلاثة أسطر توضح السبب:

dkim_symbol_callback: skip DKIM checks for local networks and authorized users
spf.lua:209: skip SPF checks for local networks and authorized users
dmarc.lua:476: skip DMARC checks as either SPF or DKIM were not checked

فالتجربة تعمل على عناوين خاصة 10.77.77.0/24، وRspamd يعتبرها شبكة محلية موثوقة ولا يفحص بريدها. وكتبنا في البداية local_addrs = []; في المجلد local.d فلم يتغير شيء، والسبب أن ملفات local.d تدمج مع الإعداد الافتراضي، والقائمة الافتراضية بقيت، فنقلنا الملف إلى override.d الذي يستبدل القيمة، وأضفنا check_local = true; في spf.conf وdkim.conf، وأضفنا skip_local = false; في milter_headers.conf لتضاف الترويسة. وفي الإنتاج لا تحتاج شيئاً من هذا لأن مرسليك على عناوين عامة.

Vector يفشل في فحص البداية

في أول تشغيل ظهر في سجل ناقل السجلات:

ERROR vector::topology::builder: msg="Healthcheck failed." error=Failed to make HTTP(S) request: error trying to connect: dns error: failed to lookup address information: Temporary failure in name resolution

والسبب أن Vector بدأ قبل CoreDNS، فلم يجد اسم siem.client-gov.test، وأضفنا depends_on: [dns, hec] كما في ملف Compose أعلاه. وفي الإنتاج المعنى نفسه: إذا كان SIEM العميل لا يصل عند البداية فلا تجعل خدمتك تتوقف، وإنما تحتفظ بالأحداث وتعيد المحاولة.

مخرجات smtplib غير مقروءة

الخيار set_debuglevel(1) في Python يطبع كل شيء بصيغة repr، بما في ذلك نص الرسالة كاملاً وكلمة المرور بترميز Base64 في أمر AUTH PLAIN، وهذه الأخيرة لا تريدها في أي سجل، لذلك كتبنا الكلاس TracedSMTP الذي يحول كل سطر إلى C: وS:، ويخفي كلمة المرور، ويختصر نص الرسالة.

إزالة التجربة

نحذف ما أنشأناه بالاسم فقط، ولا نستخدم أي أمر prune:

docker compose -p lab-tenant down -v
docker image rm lab-tenant-py lab-tenant-mta
rm -rf run

والمخرج بعد اختصار الأسطر المتكررة:

 Container lab-tenant-rx Stopping
 Container lab-tenant-sms Stopping
 ...
 Container lab-tenant-app Removed
 Network lab-tenant-net Removing
 Network lab-tenant-net Removed
Untagged: lab-tenant-py:latest
Untagged: lab-tenant-mta:latest

من التجربة إلى الإنتاج عند أي مزود

  • Caddy على .80 يصبح طبقة Proxy من عدة سيرفرات خلف Network Load Balancer بعنوان محجوز، مع تخزين مشترك للشهادات بين السيرفرات، أو موزع الحمل المدار عند مزودك بشهادة SAN أو قائمة شهادات إذا كانت النطاقات قليلة، كما شرحنا في قسم الشهادات على موزع الحمل المدار مع مثال OCI ومقابله في AWS وAzure.
  • Pebble يصبح Let's Encrypt أو جهة ACME أخرى، وابدأ ببيئة الاختبار Staging عندها قبل الإنتاج.
  • العنوان .25 يصبح NAT Gateway بعنوان محجوز كما شرحنا في مقال المدخل والمخرج، أو NAT خاص بشبكة فرعية للعميل الذي يحتاج عنواناً غير مشترك.
  • المجلد run/secrets يصبح خزنة أسرار بمسار لكل مستأجر وسجل تدقيق.
  • بوابة Postfix هي بوابة بريد العميل أياً كان منتجها، والشروط نفسها، ونصوص الردود تختلف من منتج لآخر.
  • Vector عند العميل هو Splunk HEC الحقيقي، والإعداد عندك لا يتغير إلا العنوان والتوكن.

الخلاصة

  • نقطة ask وافقت على نطاق العميل ورفضت غيره، فصدرت الشهادة في أول طلب خلال خمس ثوان ولم تصدر لنطاق لا نعرفه.
  • طلب CSR أبقى المفتاح عندنا، ومقارنة بصمة المفتاح العام أكدت أن الشهادة العائدة لمفتاحنا.
  • شهادة النطاق وحدها أفشلت curl وPython برسالة unable to get local issuer certificate مع أنهما يثقان بجهة العميل، ورابط AIA أصلحها يدوياً كما يفعل المتصفح، والسلسلة الكاملة أصلحتها للجميع.
  • الطرق الثلاث للبريد نجحت في DMARC، والطريقة الثالثة دون أي سجل جديد عند العميل، لأن البوابة في SPF وتوقع بمفتاحه، والتوقيع بنطاق لا يطابق From يفشل حتى مع نجاح SPF وDKIM.
  • البوابة رفضت المتطفل بكلمة مرور صحيحة، ورفضت الإرسال دون TLS ودون تسجيل دخول وبعنوان لا يملكه الحساب، وأعطت 450 مؤقتاً عند الحد، أما From الخاطئ فقبلته ثم ارتد عند العميل.
  • مزود الرسائل رفض المفتاح الصحيح من عنوان آخر، والمحول لكل مزود أبقى الكود واحداً.
  • السجلات وصلت لمستأجرها وحده، والحدث دون مستأجر لم يخرج، ووقت الحدث يجب أن يضبط صراحة.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال وتنفيذ التجربة على Docker Engine 29.8.0 وDocker Compose 5.5.1 مع Caddy 2.11.7 وPebble 2.10.1 وCoreDNS 1.14.7 وPostfix 3.10.13 وOpenDKIM 2.11.0 وRspamd 4.2.2 وVector 0.59.0 وPython 3.13.16 وOpenSSL 3.5.7.
  • أكتوبر 2026: تسمية بديل Postfix «بوابة بريد العميل» دون منتج معين، وجعل العميل في المثال أي مؤسسة والجهة الحكومية مثالاً واحداً، وتحديث روابط المقال الأساسي بعد تغيير عنوانه ورابطه.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.