ベアメタルk8s上に、docker-mailserverを使っておうちメールサーバをたてることにした


モチベーション

  • 容量を気にせず使えるメールサーバがほしい
    • gmail等のフリーメールも便利だが、電子メールという技術の仕組み上、無料枠を永久に使い潰していくのが精神衛生上よくないので。
  • 運用中のサービスから気軽に利用できるメールサーバがほしい
    • misskeyからのメール送信用。ないとパスワードの再設定等ができない。
    • 死活監視のアラート用。discordのwebhookへの通知とかでも良いが、電子メールという枯れたプロトコルであれば対応しているサービスが多いので、通知の統一がしやすそう。

前提

こちらのコンテナを使っていく。

docker-mailserverには、メールの送受信を行うためのPostfix/Dovecotの他にも、ClamAVというフリーのアンチウイルスソフトやfail2ban等の不正アクセス防止ツールが含まれている。

Advanced | Kubernetes - Docker Mailserver

A fullstack but simple mail-server (SMTP, IMAP, LDAP, Antispam, Antivirus, etc.) using Docker.

docker-mailserver.github.io favicon image docker-mailserver.github.io

k8sへのデプロイに際して上記ドキュメントを参照した。上記リンク内の注にもあるとおり、k8sは正式にサポートされているものではないので注意が必要。

構成

flowchart LR id1([MUA])---|Access email|id3[465]; id1([MUA])---|Send email|993; id3[465]-->id2([docker-mailserver]); 993-->id2([docker-mailserver]); id2([docker-mailserver])-->id4[587]; subgraph OCN network id4[587]-->id5(["MTA Relay server\n[smtp.ocn.ne.jp]"]); end id5(["MTA Relay server\n[smtp.ocn.ne.jp]"])-->25; 25-->id6(["MTA"])

手順

ConfigMapの設定

mail-cm.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: mailserver.environment
  namespace: nginx
immutable: false
data:
  TLS_LEVEL: modern
  POSTSCREEN_ACTION: drop
  OVERRIDE_HOSTNAME: mail.sample.com # - ①
  FAIL2BAN_BLOCKTYPE: drop
  POSTMASTER_ADDRESS: xxx@sample.com
  UPDATE_CHECK_INTERVAL: 10d
  POSTFIX_INET_PROTOCOLS: ipv4
  ONE_DIR: '1'
  ENABLE_CLAMAV: '0'
  ENABLE_POSTGREY: '0'
  ENABLE_FAIL2BAN: '1'
  AMAVIS_LOGLEVEL: '-1'
  SPOOF_PROTECTION: '1'
  MOVE_SPAM_TO_JUNK: '1'
  ENABLE_UPDATE_CHECK: '1'
  ENABLE_SPAMASSASSIN: '1'
  SUPERVISOR_LOGLEVEL: warn
  SPAMASSASSIN_SPAM_TO_INBOX: '1'
  RELAY_HOST: 'smtp.ocn.ne.jp' # - ②
  RELAY_PORT: '587'
  RELAY_USER: xxx@xxx.ocn.ne.jp # - ③
  RELAY_PASSWORD: xxx
  SSL_TYPE: manual # - ④
  SSL_CERT_PATH: /secrets/ssl/rsa/tls.crt
  SSL_KEY_PATH: /secrets/ssl/rsa/tls.key
---
kind: PersistentVolume # - ⑤
apiVersion: v1
metadata:
  name: pv-mail-config
  namespace: nginx
spec:
  storageClassName: mail-config
  capacity:
    storage: 5Mi
  accessModes:
  - ReadWriteOnce
  nfs:
    server: {NFSのIP}
    path: /k8s/mail/config
---
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: pvc-mail-config
  namespace: nginx
spec:
  storageClassName: mail-config
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 5Mi

①: ここをsample.comとすると、ユーザ作成済なのに以下のエラーが出る

postfix/smtpd[2150]: NOQUEUE: reject: RCPT from mail-oo1-f43.google.com[209.85.161.43]: 550 5.1.1 <xxx@sample.com>: Recipient address rejected: User unknown in local recipient table; from=<hogehoge@gmail.com> to=<xxx@sample.com> proto=ESMTP helo=<mail-oo1-f43.google.com>

これはOVERRIDE_HOSTNAMEがメールのドメインと一致していると出るエラーぽいので、mail.sample.comに変えることで解決。

↓参考にしたページ

Postfixの『Recipient address rejected: User unknown in local recipient table』で15分ほどハマった話 - DENET 技術ブログ

blog.denet.co.jp favicon image blog.denet.co.jp

②: P25B対策のため、OCNのSMTPサーバでリレーを行うための設定。

以下プロバイダがOCNの人しか関係ない話。古い文献だと、smtp.xxx.ocn.ne.jp(xxxにはbluemarbleなどが入る)が正しいと書いてある場合もあるが、現状はsmtp.ocn.ne.jpが正。

③: SMTP-AUTHに使うユーザ。プロバイダに通知されているものを入力。@以降も入れる。

④: 既にingress-nginxでのhttps通信に利用している証明書と秘密鍵を利用する。

cert-managerによる証明書取得&自動更新等はこちらの記事で扱っているので省略。

⑤: 参考ページでは以下のようにアカウント情報をConfigMapで決め打ちしていたが、これだとアカウントを動的に追加することができないので、PVを作成する。

---
apiVersion: v1
kind: ConfigMap

metadata:
  name: mailserver.files

data:
  postfix-accounts.cf: |
    test@example.com|{SHA512-CRYPT}$6$someHashValueHere
    other@example.com|{SHA512-CRYPT}$6$someOtherHashValueHere

ちなみに、手動でユーザを追加するにはコンテナ内で以下コマンドを実行する必要がある。

setup email add user@example.com password
setup email list # メールアドレスのリストを確認

ユーザなしの状態でコンテナを起動すると、以下のように120秒以内にメールアドレスを作るように言われるので、コンテナ内で上記コマンドを叩けば良い。

4nm1tsu@ubuntu1:~/manifests/mail$ kubectl logs -n nginx mailserver-b5d69757f-vc6s2 -f
[   INF   ]  Welcome to docker-mailserver 12.1.0
[   INF   ]  Checking configuration
[   INF   ]  Configuring mail server
[ WARNING ]  You need at least one mail account to start Dovecot (120s left for account creation before shutdown)
[ WARNING ]  You need at least one mail account to start Dovecot (110s left for account creation before shutdown)
[ WARNING ]  You need at least one mail account to start Dovecot (100s left for account creation before shutdown)
[ WARNING ]  You need at least one mail account to start Dovecot (90s left for account creation before shutdown)
[ WARNING ]  You need at least one mail account to start Dovecot (80s left for account creation before shutdown)
[ WARNING ]  You need at least one mail account to start Dovecot (70s left for account creation before shutdown)
[   INF   ]  Starting daemons
[   INF   ]  mail.sample.com is up and running

データ用PV作成

メールのデータ保存用にPVを作成する。

mail-pv.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
  name: mail-pv
spec:
  capacity:
    storage: 20Gi
  persistentVolumeReclaimPolicy: Retain
  accessModes:
    - ReadWriteOnce
  storageClassName: mail-nfs
  nfs:
    server: {NFSのIP}
    path: "/k8s/mail"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: mail-pvc
  namespace: nginx
  annotations:
    volume.beta.kubernetes.io/persistent-volume: mail-pv
spec:
  storageClassName: mail-nfs
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 20Gi

サービス公開

MetalLB経由で公開する。

参考ページの例から大きく変更した部分はハイライトしている。

mail-deployment.yaml
apiVersion: v1
kind: Service

metadata:
  name: mailserver
  namespace: nginx
  labels:
    app: mailserver
  annotations:
    metallb.universe.tf/loadBalancerIPs: {公開用IP(MetalLBのIPアドレスプールから選択)}

spec:
  type: LoadBalancer

  externalTrafficPolicy: Local #
  selector:
    app: mailserver

  ports:
    # Transfer
    - name: transfer
      port: 25
      targetPort: transfer
      protocol: TCP
    # ESMTP with implicit TLS
    - name: esmtp-implicit
      port: 465
      targetPort: esmtp-implicit
      protocol: TCP
    # ESMTP with explicit TLS (STARTTLS)
    - name: esmtp-explicit
      port: 587
      targetPort: esmtp-explicit
      protocol: TCP
    # IMAPS with implicit TLS
    - name: imap-implicit
      port: 993
      targetPort: imap-implicit
      protocol: TCP
---
apiVersion: apps/v1
kind: Deployment

metadata:
  name: mailserver
  namespace: nginx

  annotations:
    ignore-check.kube-linter.io/run-as-non-root: >-
      'mailserver' needs to run as root
    ignore-check.kube-linter.io/privileged-ports: >-
      'mailserver' needs privilegdes ports
    ignore-check.kube-linter.io/no-read-only-root-fs: >-
      There are too many files written to make The
      root FS read-only

spec:
  replicas: 1
  selector:
    matchLabels:
      app: mailserver

  template:
    metadata:
      labels:
        app: mailserver

      annotations:
        container.apparmor.security.beta.kubernetes.io/mailserver: runtime/default

    spec:
      hostNetwork: true #
      hostname: mail
      containers:
        - name: mailserver
          image: ghcr.io/docker-mailserver/docker-mailserver:latest
          imagePullPolicy: IfNotPresent

          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: false
            runAsUser: 0
            runAsGroup: 0
            runAsNonRoot: false
            privileged: false
            capabilities:
              add:
                # file permission capabilities
                - CHOWN
                - FOWNER
                - MKNOD
                - SETGID
                - SETUID
                - DAC_OVERRIDE
                # network capabilities
                - NET_ADMIN  # needed for F2B
                - NET_RAW    # needed for F2B
                - NET_BIND_SERVICE
                # miscellaneous  capabilities
                - SYS_CHROOT
                - KILL
              drop: [ALL]
            seccompProfile:
              type: RuntimeDefault

          # You want to tune this to your needs. If you disable ClamAV,
          #   you can use less RAM and CPU. This becomes important in
          #   case you're low on resources and Kubernetes refuses to
          #   schedule new pods.
          resources:
            limits:
              memory: 4Gi
              cpu: 1500m
            requests:
              memory: 2Gi
              cpu: 600m

          volumeMounts:
            - name: files
              mountPath: /tmp/docker-mailserver
              readOnly: false

            # PVCs
            - name: data
              mountPath: /var/mail
              subPath: data
              readOnly: false
            - name: data
              mountPath: /var/mail-state
              subPath: state
              readOnly: false
            - name: data
              mountPath: /var/log/mail
              subPath: log
              readOnly: false

            # certificates
            - name: certificates-rsa
              mountPath: /secrets/ssl/rsa/
              readOnly: true

            # other
            - name: tmp-files
              mountPath: /tmp
              readOnly: false

          ports:
            - name: transfer
              containerPort: 25
              protocol: TCP
            - name: esmtp-implicit
              containerPort: 465
              protocol: TCP
            - name: esmtp-explicit
              containerPort: 587
            - name: imap-implicit
              containerPort: 993
              protocol: TCP

          envFrom:
            - configMapRef:
                name: mailserver.environment

      restartPolicy: Always

      volumes:
        # configuration files
        - name: files
          persistentVolumeClaim:
            claimName: pvc-mail-config

        # PVCs
        - name: data
          persistentVolumeClaim:
            claimName: mail-pvc

        # certificates
        - name: certificates-rsa
          secret:
            secretName: letsencrypt-cert
            items:
              - key: tls.key
                path: tls.key
              - key: tls.crt
                path: tls.crt

        # other
        - name: tmp-files
          emptyDir: {}

ルータのポートフォワーディング設定

以下のポートへの通信を、メールのサービスを公開しているIPへと転送する。

  • 25(SMTP)
  • 465(SMTP over SSL)
  • 587(submission)
  • 993(IMAP3)

MUAからアクセス

クライアントはthunderbirdを利用した。

たとえばno-replyユーザでログインしたい場合、以下のように入力すればメールサーバへアクセスできる。

受信は問題なくできたが、送信時にいくつかトラブルが出た。

sender address rejected: (in reply to RCPT TO command)と言われる

いつからなのかは分からないが、OCNのリレーサーバにメールをリレーするとき、メールのenvelope-fromをOCNのメールアドレスにしないといけなくなったらしい。

大学時代(2018年ごろ)にメールサーバを建てていたときは少なくともその必要はなかったはずなので、知らないうちに仕様が変わっていたようだ。公式のアナウンスはなさそうだった。

OCNメールサーバへのリレー設定 ~Sub-missionポート編~

OCNメールサーバのポリシー変更私が契約するプロバイダのOCNから「2019年10月以降のメール送信において、TCP25による接続を受け付けず、またSub-missionポート(TCP587)においてはSMTP認証は必須である」という内容の

www.lsmodena.com favicon image www.lsmodena.com

自宅サーバのメールがOCNでリレーしてもらえなくなった - お茶漬けぶろぐ

blog.tea-soak.org favicon image blog.tea-soak.org

上記ブログを参考に、/etc/postfix/main.cf内に以下を追記、およびOCNメールアドレスを記載したsender_mapsファイルを追加することで解決した。

/etc/postfix/main.cf
...
local_header_rewrite_clients = permit_mynetworks
sender_canonical_classes = envelope_sender
sender_canonical_maps = regexp:/etc/postfix/sender_maps
/etc/postfix/sender_maps
/^.*$/ xxx@xxx.ocn.ne.jp

これらはコンテナを再起動すると消えてしまうので、以下のようなマニフェストファイルを作っておき、Deploymentからコンテナにマウントしておく。

apiVersion: v1
kind: ConfigMap

metadata:
  name: config
  namespace: nginx

data:
  sender-maps: |
    /^.*$/ xxx@xxx.ocn.ne.jp
  postfix-main.cf: |
    local_header_rewrite_clients = permit_mynetworks
    sender_canonical_classes = envelope_sender
    sender_canonical_maps = regexp:/etc/postfix/sender-maps

OP25Bに引っかかる

OCNのリレーサーバを指定しているのに、何故か以下のエラーが出る。

Jul  2 06:58:31 ubuntu3 postfix/smtp[848]: connect to gmail-smtp-in.l.google.com[64.233.189.26]:25: Connection timed out

コンテナに入って、Postfixの設定を見てみる。

/etc/postfix/main.cf
...
sender_dependent_relayhost_maps = texthash:/etc/postfix/relayhost_map
...
/etc/postfix/relayhost_map
@sample.com    [smtp.ocn.ne.jp]:587

どうやら環境変数RELAY_HOSTRELAY_PORTで指定したリレーサーバの情報が、relayhost_mapとしてホスト毎のリレーサーバ情報として書き出されているようだ。 恐らくOP25Bに引っかかっていたのはrelayhost_mapのdbファイルが生成されていないことが原因だろう。

そもそも、ホストごとにリレーサーバの情報を設定する必要はなく、全てsmtp.ocn.ne.jpにリレーしたい。

改めてmain.cfを調べると、relayhostという項目が空欄になっているのがわかった。

/etc/postfix/main.cf
...
relayhost =
...

どうやらこの項目は、docker-mailserverではDEFAULT_RELAY_HOST環境変数経由で設定できるらしい1

そのため、以下のように環境変数を追加することで、きちんとリレーサーバを経由するようになった。

mail-cm.yaml
...
  DEFAULT_RELAY_HOST: 'smtp.ocn.ne.jp:587'
...

色々試す

送信専用アドレスを作成

サービスからこのメールサーバを利用する上で、送信専用のメールアドレスがほしい。

先程のConfigMapにno-reply宛の外部からのメールを捨てる設定を追加する。

apiVersion: v1
kind: ConfigMap

metadata:
  name: config
  namespace: nginx

data:
  access: |
    no-reply@sample.com DISCARD DEV-NULL
  sender-maps: |
    /^.*$/ xxx@xxx.ocn.ne.jp
  postfix-main.cf: |
    local_header_rewrite_clients = permit_mynetworks
    sender_canonical_classes = envelope_sender
    sender_canonical_maps = regexp:/etc/postfix/sender-maps
    smtpd_recipient_restrictions = permit_sasl_authenticated, permit_mynetworks, reject_unauth_destination, check_policy_service unix:private/policyd-spf, reject_unauth_pipelining, reject_invalid_helo_hostname, reject_non_fqdn_helo_hostname, reject_unknown_recipient_domain, check_policy_service inet:localhost:65265, check_recipient_access hash:/etc/postfix/access

設定ファイルをマウント後、dbを作成するのも忘れずに

mail-deployment.yaml
    spec:
      hostNetwork: true #
      hostname: mail
      containers:
        - name: mailserver
          image: ghcr.io/docker-mailserver/docker-mailserver:latest
          imagePullPolicy: IfNotPresent
          lifecycle:
            postStart:
              exec:
                command: ["postmap", "/etc/postfix/access"]

Postfix で no-reply を実装する | 雑廉堂の雑記帳

巷でよく見かける、no-reply メール。これを Postfix で実装するにはどのようにするのがベストプラクティスなのか、いろいろ調べてみました。

www.rough-and-cheap.jp favicon image www.rough-and-cheap.jp

misskeyのメールサーバ設定を行う

「コントロールパネル」→「メールサーバー」から、以下のように事前に作成したアカウントを入力すれば、ユーザ登録時やパスワード変更時にこのアドレスが利用されるようになる。

動作確認は「配信テスト」から可能。

grafanaからメールでアラート通知する

grafanaでメールを利用する場合、grafana.iniにsmtpサーバの設定を行う必要がある。

helm経由でインストールしている場合2は、grafana.iniを設定するためのパラメータが用意されているので、以下のように設定する。

values.yaml
...
grafana:
  enabled: true
  #namespaceOverride: ""
  deploymentStrategy:
    type: Recreate
  grafana.ini:
    smtp:
      enabled: true
      host: "{メールサーバのホスト}:465"
      user: "grafana@sample.com"
      password: "xxx"
      from_address: "grafana@sample.com" # 必須
      from_name: "Grafana"
  persistence:
    enabled: true
...
# values.yamlの再適用
helm upgrade -f values.yaml kube-prometheus-stack prometheus-community/kube-prometheus-stack

これでメールによるアラート通知を行えるようになった。

通知先は「Alerting」→「Contact points」から変更可能だ。「Test」で通知のテストも出来る。

チュートリアル - Grafana Eメール通知設定[ステップバイステップ]

Grafana Eメール通知機能を構成する方法を学びます。 このチュートリアルでは、Grafanaでメールを送信するために必要なすべての手順について説明します。

techexpert.tips favicon image techexpert.tips

余裕があったらやっておきたいこと

DKIM設定

建てたメールサーバからGmailのアドレス宛にメールを送信すると、メールアドレスの隣に「リレーサーバのドメイン」と「経由」という文字が表示されていた。

Gmailでは送信元のドメインとFromのアドレスのドメインが一致しない場合にこの表示がされる仕様らしい。 そしてこの挙動はDKIMで送信ドメイン認証を行うことで回避することができる。

因みにDKIMとは、「送信メールサーバ側でメールに電子署名を追加して送信し、受信メールサーバ側でメールを受信後、公開鍵をDNSサーバに問い合わせて電子署名の検証を行うことで、正しいメールサーバから配信されていることを確認する仕組み」のこと。

幸いdocker-mailserverでは簡単にこの設定ができる仕組みが用意されている。

DKIM, DMARC & SPF - Docker Mailserver

A fullstack but simple mail-server (SMTP, IMAP, LDAP, Antispam, Antivirus, etc.) using Docker.

docker-mailserver.github.io favicon image docker-mailserver.github.io

事前にメールアドレスを作成している前提で、以下のコマンドを実行する。

setup config dkim

すると、 /tmp/docker-mailserver/opendkim/keys/{ドメイン}配下にmail.txtが生成されるので、そのファイルに記載されている内容をDNSサーバのTXTレコードに追加する。

メールサーバを再起動すると、設定が反映されていることを確認できた。

fail2ban設定

運用を始めてからしばらく経つと、運用開始直後に比べて、メールの配信が遅れたり、明らかに動作が不安定になり始めた。

ログを見てみると、複数IPからログイン試行を行う某国からの不審なアクセスが目立ったので、以下の設定を参考にfail2ban設定を厳しくすることにした。

docker-mailserver/config-examples/fail2ban-jail.cf at master · docker-mailserver/docker-mailserver

Production-ready fullstack but simple mail server (SMTP, IMAP, LDAP, Antispam, Antivirus, etc.) running inside a container. - docker-mailserver/docker-mailserver

github.com favicon image github.com

apiVersion: v1
kind: ConfigMap

metadata:
  name: config
  namespace: nginx

data:
...
  fail2ban-jail.cf: |
    [DEFAULT]
    
    # "bantime" is the number of seconds that a host is banned.
    bantime = -1
    
    # A host is banned if it has generated "maxretry" during the last "findtime"
    # seconds.
    findtime = 1w
    
    # "maxretry" is the number of failures before a host get banned.
    maxretry = 3
    
    # "ignoreip" can be a list of IP addresses, CIDR masks or DNS hosts. Fail2ban
    # will not ban a host which matches an address in this list. Several addresses
    # can be defined using space (and/or comma) separator.
    ignoreip = 127.0.0.1/8 4nm1tsu.com
    
    # default ban action
    # nftables-multiport: block IP only on affected port
    # nftables-allports:  block IP on all ports
    banaction = nftables-allports
    
    [dovecot]
    enabled = true
    
    [postfix]
    enabled = true
    # For a reference on why this mode was chose, see
    # https://github.com/docker-mailserver/docker-mailserver/issues/3256#issuecomment-1511188760
    mode = aggressive
    
    [postfix-sasl]
    enabled = true

    # This jail is used for manual bans.
    # To ban an IP address use: setup.sh fail2ban ban <IP>
    [custom]
    enabled = true
    bantime = -1
    port = smtp,pop3,pop3s,imap,imaps,submission,submissions,sieve

ポイントはpostfixのmodeをaggressiveにしているところ。これがextraだと見逃してしまう通信があった。

そして攻撃元のIPが頻繁に変わるので、1週間で3回攻撃を観測したIPを永久BANという厳しめの設定にしている。

この設定のおかげもあってか、設定から1ヶ月ほどで合計360弱のIPをBANすることができ、当初の問題であるメールサーバの遅延もなくなった。

おわりに

おうちk8sクラスタに遊び道具が増えていい感じになった。

お願いだからOCNは自宅鯖主向けにも情報を公開してくれ。