docker-mailserver-helm icon indicating copy to clipboard operation
docker-mailserver-helm copied to clipboard

Fetchmail can't deliver messages to postfix [k8s/helm-chart]

Open Xnyle opened this issue 7 months ago • 26 comments

📝 Preliminary Checks

  • [x] I tried searching for an existing issue and followed the debugging docs advice, but still need assistance.

👀 What Happened?

Using the community helm chart. Enabled fetchmail and added one account/config to fetchmail.rc

Fetchmail starts, fetchesmMail but then fails to deliver it due to:

Helo command rejected: need fully-qualified hostname

My fetchmail is simply

poll xxxx with service imaps and timeout 120 and options no dns auth password 
    user "user" there with password "pass" is "existinguser@withuserdomain" here options fetchall ssl nokeep

👟 Reproduction Steps

No response

🐋 DMS Version

15.0.2

💻 Operating System and Architecture

k8s community Helm chart

⚙️ Container configuration files


📜 Relevant log output

2025-05-24T20:23:32.531389+00:00 docker-mailserver-5dfb984fb8-nkvdk fetchmail[67184]: reading message xxxx@xxxx:2 of 2 (7674 header octets) (log message incomplete)
2025-05-24T20:23:32.531398+00:00 docker-mailserver-5dfb984fb8-nkvdk fetchmail[67184]: SMTP error: 504 5.5.2 <docker-mailserver-5dfb984fb8-nkvdk>: Helo command rejected: need fully-qualified hostname
2025-05-24T20:23:32.531905+00:00 docker-mailserver-5dfb984fb8-nkvdk postfix/postscreen[84741]: CONNECT from [::1]:38698 to [::1]:25
2025-05-24T20:23:32.531967+00:00 docker-mailserver-5dfb984fb8-nkvdk postfix/postscreen[84741]: PASS OLD [::1]:38698

Xnyle avatar May 24 '25 20:05 Xnyle

Not a solution to your problem, but you might give getmail a try.

casperklein avatar May 24 '25 20:05 casperklein

fails to deliver it due to:

Helo command rejected: need fully-qualified hostname

This is an error from Postfix and this will be the first result on Google for keywords fetchmail "need fully-qualified hostname".

You could relax security on the Postfix side (via our Postfix override support), or consider why fetchmail isn't providing an acceptable HELO/EHLO.


Alternatively, this mailing list discussion suggests that fetchmail (or possibly Postfix too IIRC) would attempt rDNS on the IP of the network interface being used to connect through, or whatever is configured as the hostname on the system.

As you've mentioned you're using kubernetes, it lacks support for setting an explicit hostname from what I've heard through @georglauterbach , so you need to set our ENV OVERRIDE_HOSTNAME. I'm not familiar with our associated Helm chart project that well, I'd assume it has something in place to handle that?

Without that ENV being set, you'll have a single DNS label as the hostname by default AFAIK, containers usually have that set as a random hexadecimal ID. That is not a fully-qualified hostname AFAIK? (it'd expect a 2nd label I think?)


The alternative is like @casperklein suggested, to prefer Getmail instead. Fetchmail will deliver through SMTP it seems while Getmail differs by configuring the destination to run a command instead, which would avoid the issue entirely.

Do note that the two services behave slightly different beyond that. Our Getmail is not the latest release as we're using Debian packages for it, there are some features that newer Getmail added that it had lacked compared to Fetchmail, one was regarding the read/seen status change to remote IMAP inbox IIRC (if you didn't want to delete). Ensure that once it's setup it functions the way you expect 👍

polarathene avatar May 24 '25 22:05 polarathene

OVERRIDE_HOSTNAME was already set, the other things you mentioned are clear.

IMHO rDNS is not the issue here as fetchmail simply uses localhost in the HELO. Not going to further debug this though.

Also not going to relax standard postfix settings as they are there for a reason, so I switched to getmail as suggested.

My expectation is that there is at least a note in the docs that fetchmail does not work out of the box under k8s.

Xnyle avatar May 24 '25 22:05 Xnyle

IMHO rDNS is not the issue here as fetchmail simply uses localhost in the HELO.

localhost is not a FQDN, mail.localhost would be though.

I switched to getmail as suggested.

👍

My expectation is that there is at least a note in the docs that fetchmail does not work out of the box under k8s.

Kubernetes support is unofficial, it is community managed (hence separate repo, and clarification in our docs).

You are welcome to PR that note to the fetmail docs page, it's just markdown so quite simple to contribute.

It should work though, provided the hostname is resolved to an FQDN. However fetchmail is a community contributed feature as well, no maintainer is that familiar with it's config and support, so if it lacks the ability to override the hostname, then yes without kubernetes supporting a way to configure the hostname of the container, it's not going to work I guess 😅

polarathene avatar May 24 '25 23:05 polarathene

I moved this issue to our K8s repository.

My expectation is [...]

My expectation is that those who open issues have understood that this project is maintained by individuals in their spare time. If there is an issue, you ask for help, and we'll do our best to resolve the issue.


To the matter at hand.

@cfis are you using fetchmail accidentally, or do you know anything about it not working? If so, we should (as was said) add a comment to the docs somewhere.

georglauterbach avatar May 25 '25 12:05 georglauterbach

I am not using fetchmail - so don't know if it works or not. Based on this ticket though - https://github.com/docker-mailserver/docker-mailserver/issues/990 - it seems to have been working on Kubernetes in 2018 so seems like it should work?

As for OVERRIDE_HOSTNAME - you MUST set it. See https://github.com/docker-mailserver/docker-mailserver-helm/blob/master/charts/docker-mailserver/values.yaml#L63.

@Xnyle how do you want to proceed on this issue? Sounds like you have moved to GetMail so should I close this? Or if you want to debug further that would be great also.

cfis avatar May 25 '25 22:05 cfis

For comparison to the 2018 referenced fetchmail config:

This issue:

poll xxxx with service imaps
  and timeout 120
  and options no dns auth password 
  user "user" there
  with password "pass"
  is "existinguser@withuserdomain" here
  options fetchall ssl nokeep

2018 issue:

poll 'imap.mail.eu-west-1.awsapps.com' proto IMAP
  user '[email protected]'
  password 'PPP'
  is 'XXX'
  ssl

There has been many changes since 2018 though, chances are Postfix wasn't as strict on HELO.

EDIT: Postfix config had the config for HELO restriction added in April 2016 into the DMS v2 branch, so presumably it applied to the 2018 issue.

I don't have time to investigate atm, but perhaps it's specific to the fetchmail config differences only? (or possibly container runtimes back then were slightly different, I do recall we also treated hostname config quite differently too)

For reference, this is what our docs advise instead:

Image

So it's unclear where @Xnyle came up with their Fetchmail config, unless they've used fetchmail elsewhere before (presumably in k8s environment too).


We also have a compose.yaml fetchmail example reference for testing, could probably not set the hostname and rely only on the OVERRIDE_HOSTNAME ENV to reproduce there.

Just adjust that config and run the commands detailed here should be sufficient, otherwise might need to be adapted to k8s if that works fine with compose.

polarathene avatar May 25 '25 23:05 polarathene

Yes, my fetchmail config existed b4 outside of any container config.

BUT I already ensured that OVERRIDE_HOSTNAME is set and also even points to the correct Service IP. And I also used the very config (ofc user changed) from the docs.

But that didn't change anything.

I also tried to find a setting in fetchmail that steers the HELO "banner" is sends but couldn't find any that has any change.

Xnyle avatar May 26 '25 06:05 Xnyle

I doubt this would resolve the fetchmail concern, but it is possible to replace the hostname binary with a shell script that emits the hostname you'd prefer instead. We needed that at build when installing Postfix, so you can reference what we did.

Presumably a service would instead use a syscall or similar to check the hostname, in which case that won't work.

I don't know k8s well, but this 2024 comment from a k8s maintainer states Pods have a hostname field that can be configured if that's helpful. Someone also shares a shell script attempt to change the hostname in other locations that might be checked.

polarathene avatar May 26 '25 22:05 polarathene

Not sure I'f it's worth investigaring / solving as 90% of what you can do with fetchmail you can also do with getmail. Fetchmail would only be needed if you absolutely need to get the mail through postfix again in order to spam check/redeliver it to some other server?

This use case might be so much out of the scope of a all in one self hosted container solution? But there should be a note (a big bold one) in the docs that fetchmail+k8s is not going to work ATM.

Xnyle avatar May 27 '25 07:05 Xnyle

NOTE: Nobody needs to read this comment and my next follow-up comment, they're verbose for reference purposes.


Reproducible with Docker Compose. There appears to be no ability to fake the hostname for that service without setting hostname in compose.yaml:

services:
  # Your DMS container with fetchmail enabled:
  dms-fetch:
    image: ghcr.io/docker-mailserver/docker-mailserver:latest # :15.0
    # Disabled for OVERRIDE_HOSTNAME reproduction:
    #hostname: mail.example.test
    environment:
      OVERRIDE_HOSTNAME: mail.example.test
      ENABLE_FETCHMAIL: 1
      # We change this setting to 10 for quicker testing:
      FETCHMAIL_POLL: 10
      # Additionally try to override the hostname this way:
      HOSTNAME: hello-dms.test
    # You'd normally use `volumes` here but for simplicity of the example, all config is contained within `compose.yaml`:
    configs:
      - source: dms-accounts-fetch
        target: /tmp/docker-mailserver/postfix-accounts.cf
      - source: fetchmail
        target: /tmp/docker-mailserver/fetchmail.cf
      # Use user-patches.sh to attempt configuring additional fake hostnames:
      - source: try-fake-hostnames
        target: /tmp/docker-mailserver/user-patches.sh
    # This is only for this example, since no real DNS service is configured, this is a Docker internal DNS feature:
    networks:
      default:
        aliases:
          - mail.example.test
          # This is needed to resolve `@example.test` as a postfix security check on the sender address:
          - example.test

  # This container represents your remote IMAP service that receives mail that fetchmail pulls from:
  dms-remote:
    image: ghcr.io/docker-mailserver/docker-mailserver:latest # :15.0
    #hostname: mail.remote.test
    environment:
      OVERRIDE_HOSTNAME: mail.remote.test
      # Allows for us send a test mail easily by trusting any mail client run within this container (`swaks`):
      PERMIT_DOCKER: container
    configs:
      - source: dms-accounts-remote
        target: /tmp/docker-mailserver/postfix-accounts.cf
    # Private DNS entries to resolve these queries to this container in the docker network:
    networks:
      default:
        aliases:
          - mail.remote.test
          # This would be needed if you don't specify `--server` to swaks, resolving the `--to` address for which MTA to deliver to:
          - remote.test

# Using the Docker Compose `configs.content` feature instead of volume mounting separate files.
# NOTE: This feature requires Docker Compose v2.23.1 (Nov 2023) or newer:
# https://github.com/compose-spec/compose-spec/pull/446
configs:
  fetchmail:
    content: |
      poll 'mail.remote.test' proto imap
        user '[email protected]'
        pass 'secret'
        is '[email protected]'
        no sslcertck

  # DMS requires an account to complete setup, configure one for each instance:
  # NOTE: Both accounts are configured with the same password (SHA512-CRYPT hashed), `secret`.
  dms-accounts-fetch:
    content: |
      [email protected]|{SHA512-CRYPT}$$6$$sbgFRCmQ.KWS5ryb$$EsWrlYosiadgdUOxCBHY0DQ3qFbeudDhNMqHs6jZt.8gmxUwiLVy738knqkHD4zj4amkb296HFqQ3yDq4UXt8.

  dms-accounts-remote:
    content: |
      [email protected]|{SHA512-CRYPT}$$6$$sbgFRCmQ.KWS5ryb$$EsWrlYosiadgdUOxCBHY0DQ3qFbeudDhNMqHs6jZt.8gmxUwiLVy738knqkHD4zj4amkb296HFqQ3yDq4UXt8.

  # A `user-patches.sh` script:
  try-fake-hostnames:
    content: |
      #!/bin/bash

      echo 'hello.test' > /etc/hostname
      echo 'world.test' > /etc/mailname

      # Replace the `hostname` command:
      mv /usr/bin/hostname /usr/bin/hostname.bak
      echo -e "#!/bin/bash\necho 'docker-mailserver.invalid'" >/usr/bin/hostname
      chmod +x /usr/bin/hostname

      # This will fail as `/etc/hosts` is an implicit bind mount, but you can edit it manually:
      # NOTE: The extra `$` is because this script is embedded into `compose.yaml`, `$$` is required to escape `$`.
      sed -i "s|$${HOSTNAME}|hello-world.test|" /etc/hosts

Sending mail

Send a mail from the swaks CLI within the dms-remote container for dms-fetch to later retrieve:

$ docker compose exec -it dms-remote swaks --server localhost --from [email protected] --to [email protected] --silent

<** 504 5.5.2 <42365d7ec29b>: Helo command rejected: need fully-qualified hostname
 -> QUIT
<-  221 2.0.0 Bye
=== Connection closed with remote host.

# Related failure logs:
$ docker compose logs dms-remote | grep '[email protected]'

policyd-spf[793]: : prepend X-Comment: SPF check N/A for local connections - client-ip=::1; helo=058cb200d2bd; [email protected]; receiver=remote.test
postfix/smtpd[789]: NOQUEUE: reject: RCPT from localhost[::1]: 504 5.5.2 <058cb200d2bd>: Helo command rejected: need fully-qualified hostname; from=<[email protected]> to=<[email protected]> proto=ESMTP helo=<058cb200d2bd>

Like fetchmail, swaks will default the helo to the detected hostname of the container it's running in.

That is a similar error to fetchmail.. except swaks can fix it by changing the helo value to use via the --helo option:

# Append a `--helo`:
$ docker compose exec -it dms-remote swaks --server localhost --from [email protected] --to [email protected] --silent --helo mail.example.test

# Mail was not rejected this time:
$ docker compose logs dms-remote | grep '[email protected]'

policyd-spf[807]: : prepend X-Comment: SPF check N/A for local connections - client-ip=::1; helo=mail.example.test; [email protected]; receiver=remote.test
postfix/qmgr[711]: 401EFE5C7C: from=<[email protected]>, size=759, nrcpt=1 (queue active)
amavis[764]: (00764-01) Passed CLEAN {RelayedInbound}, [::1]:56796 <[email protected]> -> <[email protected]>, Queue-ID: 14F89E5C74, Message-ID: <20250527073909.000927@42365d7ec29b>, mail_id: 6pkDqfMRJSbO, Hits: -, size: 549, queued_as: 401EFE5C7C, 118 ms


# Related logs for successful receive + store:
$ docker compose logs dms-remote | grep '[email protected]'

postfix/smtp-amavis/smtp[937]: 14F89E5C74: to=<[email protected]>, relay=127.0.0.1[127.0.0.1]:10024, delay=0.2, delays=0.07/0.01/0.01/0.12, dsn=2.0.0, status=sent (250 2.0.0 from MTA(smtp:[127.0.0.1]:10025): 250 2.0.0 Ok: queued as 401EFE5C7C)
dovecot: lmtp([email protected])<940><l+iCEB1sNWisAwAA2SGYFA>: sieve: msgid=<20250527073909.000927@42365d7ec29b>: stored mail into mailbox 'INBOX'
postfix/lmtp[766]: 401EFE5C7C: to=<[email protected]>, relay=mail.remote.test[/var/run/dovecot/lmtp], delay=0.03, delays=0/0/0.01/0.01, dsn=2.0.0, status=sent (250 2.0.0 <[email protected]> l+iCEB1sNWisAwAA2SGYFA Saved)

Successful delivery to dms-remote, great, but fetchmail itself will still fail with only OVERRIDE_HOSTNAME instead of hostname. None of the attempts to fake the hostname worked.

It looks like env.c in the Fetchmail source is where this is checked via glibc call via their host_fqdn() function.

Thus it'll be via a kernel syscall I think?:

  • https://linux.die.net/man/2/gethostname
  • cat /proc/sys/kernel/hostname or uname -n both return the hostname configured in the container.
  • As the /proc/sys/.. prefix would imply, you can query that via sysctl kernel.hostname too. Container runtime support for modifying such settings varies I think. Docker is rather limited (1 + 2), kernel.hostname is not one that is permitted to be set, even though it reflects the hostname of the namespaced container?

Will need to explore some alternative solutions..


Side-note

I'm not sure what to think about AI driven solutions ingesting our project and the considerable time I spend to thoroughly document/troubleshoot concerns like this publicly for the benefits of others 😅 (I do this a fair amount, not just the DMS repos)

Image

Our OVERRIDE_HOSTNAME ENV has been interpreted as more generic for Docker / containers, rather than specific to DMS:

Image

On the bright-side, at least the AI response is providing citations by linking to source material at least.

polarathene avatar May 27 '25 09:05 polarathene

3 workarounds for fetchmail when relying on the OVERRIDE_HOSTNAME ENV

TL;DR:

  • PERMIT_DOCKER=container as a quick fix, should be reasonably safe to use.
  • fetchmail.cf config options:
    • --smtphost to deliver direct to LMTP (via unix socket) since the fetchmail service in DMS is bundled into the same container as Dovecot.
    • --mda to delegate to a command for deliver instead of via SMTP. Likewise suitable for local usage, added advantage that we can filter through one of our anti-spam services before delivering to Dovecot.

Presently --mda will be a hassle, DMS will need to have someone contribute changes to make it more viable. PERMIT_DOCKER=container is the least amount of friction to go with if you are ok with trusting any process in the container.

Or as suggested earlier, just go with Getmail 😎


Relax security via PERMIT_DOCKER=container

The broader stroke "fix" is to lean into our PERMIT_DOCKER=container feature.

  • Any local connection within the container would then be trusted, skipping security checks (the sender address having valid DNS wouldn't be required either, so the network alias example.test assigned to the dms-fetch container could be dropped too).
  • In fact, if you set that ENV on the dms-fetch container, it'll likewise workaround the problem you encountered with the HELO check. Security wise, it might be acceptable, but I'd encourage considering the other workarounds I've documented here.

With PERMIT_DOCKER=container set on both DMS containers (add this to the previous compose.yaml snippet):

# Add PERMIT_DOCKER=container env:
services:
  dms-fetch:
    environment:
      # Allows for us send a test mail easily by trusting any mail client run within this container (`fetchmail`):
      PERMIT_DOCKER: container

  dms-remote:
    environment:
      # Allows for us send a test mail easily by trusting any mail client run within this container (`swaks`):
      PERMIT_DOCKER: container
$ docker compose up -d --force-recreate
$ docker compose exec -it dms-remote swaks --server localhost --from [email protected] --to [email protected] --silent

# dms-remote has the mail stored until fetchmail retrieves it for dms-fetch:
$ docker compose exec -it dms-remote doveadm mailbox status -u [email protected] messages INBOX
INBOX messages=1
$ docker compose exec -it dms-fetch doveadm mailbox status -u [email protected] messages INBOX
INBOX messages=0

# Once fetchmail is triggered at the polling interval (success, no helo failure):
$ docker compose exec -it dms-remote doveadm mailbox status -u [email protected] messages INBOX
INBOX messages=0
$ docker compose exec -it dms-fetch doveadm mailbox status -u [email protected] messages INBOX
INBOX messages=1

LMTP delivery (deliver direct to Dovecot instead of going through Postfix first)

Using the fetchmail config option smtphost / --smtphost (CLI option), we can give a regular SMTP hostname or a unix socket instead for LMTP. When this setting isn't configured, the default is localhost.

Preferring to deliver via LMTP should technically be ok - in fact going through SMTP is typically discouraged for fetchmail/getmail when they're delivering mail they've retrieved. Postfix internally will hand the mail off to Dovecot via LMTP.

NOTE: This will skip all security checks that Postfix would have normally done. That should be acceptable, given that we're trusting the internal fetchmail service itself, however it also means any anti-spam/virus checks are skipped too.

To do this, we just add to the fetchmailrc configuration (fetchmail.cf) this extra setting:

smtphost /var/run/dovecot/lmtp

That unix socket is defined in /etc/dovecot/conf.d/10-master.conf, where we have ownership and group of docker / 5000. Thus to avoid an error that will occur, the fetchmail user must also exist in the docker group, which we'll workaround for now via a user-patches.sh script (fix-fetchmail):

services:
  dms-fetch:
    image: ghcr.io/docker-mailserver/docker-mailserver:latest # :15.0
    #hostname: mail.example.test
    environment:
      OVERRIDE_HOSTNAME: mail.example.test
      ENABLE_FETCHMAIL: 1
      FETCHMAIL_POLL: 10
    configs:
      - source: dms-accounts-fetch
        target: /tmp/docker-mailserver/postfix-accounts.cf
      - source: fetchmail
        target: /tmp/docker-mailserver/fetchmail.cf
      # Add fetchmail to the postfix group for Dovecot LMTP socket access:
      - source: fix-fetchmail
        target: /tmp/docker-mailserver/user-patches.sh
    networks:
      default:
        aliases:
          - mail.example.test
          - example.test

  dms-remote:
    image: ghcr.io/docker-mailserver/docker-mailserver:latest # :15.0
    #hostname: mail.remote.test
    environment:
      OVERRIDE_HOSTNAME: mail.remote.test
      # Optional - Avoiding the need to override the HELO when using `swaks` CLI:
      PERMIT_DOCKER: container
    configs:
      - source: dms-accounts-remote
        target: /tmp/docker-mailserver/postfix-accounts.cf
    # Private DNS resolve these queries to this container in the docker network:
    networks:
      default:
        aliases:
          - mail.remote.test
          - remote.test

# Using the Docker Compose `configs.content` feature instead of volume mounting separate files.
# NOTE: This feature requires Docker Compose v2.23.1 (Nov 2023) or newer:
# https://github.com/compose-spec/compose-spec/pull/446
configs:
  fetchmail:
    content: |
      poll 'mail.remote.test' proto imap
        user '[email protected]'
        pass 'secret'
        is '[email protected]'
        no sslcertck
        smtphost /var/run/dovecot/lmtp

  # DMS requires an account to complete setup, configure one for each instance:
  # NOTE: Both accounts are configured with the same password (SHA512-CRYPT hashed), `secret`.
  dms-accounts-fetch:
    content: |
      [email protected]|{SHA512-CRYPT}$$6$$sbgFRCmQ.KWS5ryb$$EsWrlYosiadgdUOxCBHY0DQ3qFbeudDhNMqHs6jZt.8gmxUwiLVy738knqkHD4zj4amkb296HFqQ3yDq4UXt8.

  dms-accounts-remote:
    content: |
      [email protected]|{SHA512-CRYPT}$$6$$sbgFRCmQ.KWS5ryb$$EsWrlYosiadgdUOxCBHY0DQ3qFbeudDhNMqHs6jZt.8gmxUwiLVy738knqkHD4zj4amkb296HFqQ3yDq4UXt8.

  fix-fetchmail:
    content: |
      #!/bin/bash

      adduser fetchmail postfix

So that works, and removed the need for a PERMIT_DOCKER=container on our dms-fetch container 👍

$ docker compose up -d --force-recreate
$ docker compose exec -it dms-remote swaks --server localhost --from [email protected] --to [email protected] --silent

# dms-remote has the mail stored until fetchmail retrieves it for dms-fetch:
$ docker compose exec -it dms-remote doveadm mailbox status -u [email protected] messages INBOX
INBOX messages=1
$ docker compose exec -it dms-fetch doveadm mailbox status -u [email protected] messages INBOX
INBOX messages=0

# Once fetchmail is triggered at the polling interval (success, no helo failure):
$ docker compose exec -it dms-remote doveadm mailbox status -u [email protected] messages INBOX
INBOX messages=0
$ docker compose exec -it dms-fetch doveadm mailbox status -u [email protected] messages INBOX
INBOX messages=1

If you need to have a bit more flexibility, including spam checking, then the alternative fetchmail config setting --mda might be better.

Fetchmail config option --mda

--mda (or mda in fetchmailrc config) will allow you to deliver mail by delegating to a command to run instead. Useful if you want to also delegate via spamc or rspamc commands for spam filtering before handing to Dovecot for storing.

Config is the same as the LMTP example, except we replace smtphost /var/run/dovecot/lmtp for mda "/usr/lib/dovecot/deliver -d %T" where fetchmail will change %T to the recipient address. This command is roughly the same as delivering via LMTP AFAIK, just the CLI equivalent? There's also no need for the user-patches.sh script.

Compose config example
services:
  dms-fetch:
    image: ghcr.io/docker-mailserver/docker-mailserver:latest # :15.0
    #hostname: mail.example.test
    environment:
      OVERRIDE_HOSTNAME: mail.example.test
      ENABLE_FETCHMAIL: 1
      FETCHMAIL_POLL: 10
    configs:
      - source: dms-accounts-fetch
        target: /tmp/docker-mailserver/postfix-accounts.cf
      - source: fetchmail
        target: /tmp/docker-mailserver/fetchmail.cf
    networks:
      default:
        aliases:
          - mail.example.test
          - example.test

  dms-remote:
    image: ghcr.io/docker-mailserver/docker-mailserver:latest # :15.0
    #hostname: mail.remote.test
    environment:
      OVERRIDE_HOSTNAME: mail.remote.test
      # Optional - Avoiding the need to override the HELO when using `swaks` CLI:
      PERMIT_DOCKER: container
    configs:
      - source: dms-accounts-remote
        target: /tmp/docker-mailserver/postfix-accounts.cf
    # Private DNS resolve these queries to this container in the docker network:
    networks:
      default:
        aliases:
          - mail.remote.test
          - remote.test

# Using the Docker Compose `configs.content` feature instead of volume mounting separate files.
# NOTE: This feature requires Docker Compose v2.23.1 (Nov 2023) or newer:
# https://github.com/compose-spec/compose-spec/pull/446
configs:
  fetchmail:
    content: |
      poll 'mail.remote.test' proto imap
        user '[email protected]'
        pass 'secret'
        is '[email protected]'
        no sslcertck
        mda "/usr/lib/dovecot/deliver -d %T"

  # DMS requires an account to complete setup, configure one for each instance:
  # NOTE: Both accounts are configured with the same password (SHA512-CRYPT hashed), `secret`.
  dms-accounts-fetch:
    content: |
      [email protected]|{SHA512-CRYPT}$$6$$sbgFRCmQ.KWS5ryb$$EsWrlYosiadgdUOxCBHY0DQ3qFbeudDhNMqHs6jZt.8gmxUwiLVy738knqkHD4zj4amkb296HFqQ3yDq4UXt8.

  dms-accounts-remote:
    content: |
      [email protected]|{SHA512-CRYPT}$$6$$sbgFRCmQ.KWS5ryb$$EsWrlYosiadgdUOxCBHY0DQ3qFbeudDhNMqHs6jZt.8gmxUwiLVy738knqkHD4zj4amkb296HFqQ3yDq4UXt8.

I don't think I've updated the Getmail docs yet with a WIP revision I had which demonstrates using rspamc / spamc for filtering spam, but it'd be roughly the same for fetchmail with --mda.

Full walkthrough (verbose, click to view)

Unlike the LMTP approach, this has quite a few hoops to jump through to get into a workin state. Documenting for reference.

$ docker compose up -d --force-recreate

# Send a mail to dms-remote for dms-fetch to retrieve and store in it's container:
docker compose exec -it dms-remote swaks --server localhost --from [email protected] --to [email protected]

# Once fetchmail polls 10s later, check the logs and we'll see error output:
docker compose logs dms-fetch
fetchmail[679]: 1 message for [email protected] at mail.remote.test.
dovecot: auth: Error: client doesn't have lookup permissions for this user: userdb uid (5000) doesn't match peer uid (101) (to bypass this check, set: service auth { unix_listener /run/dovecot/auth-userdb { mode=0777 } })
dovecot: lda([email protected])<797><>: Error: auth-master: userdb lookup([email protected]): Auth USER lookup failed
dovecot: lda(797): Fatal: Internal error occurred. Refer to server log for more information.
fetchmail[679]: reading message [email protected]@mail.remote.test:1 of 1 (813 header octets) (28 body octets) (log message incomplete)
fetchmail[679]: MDA returned nonzero status 75
fetchmail[679]:  not flushed

/etc/dovecot/conf.d/10-master.conf must be edited to relax permissions (update mode):

service auth {
  # auth_socket_path points to this userdb socket by default. It's typically
  # used by dovecot-lda, doveadm, possibly imap process, etc. Users that have
  # full permissions to this socket are able to get a list of all usernames and
  # get the results of everyone's userdb lookups.
  #
  # The default 0666 mode allows anyone to connect to the socket, but the
  # userdb lookups will succeed only if the userdb returns an "uid" field that
  # matches the caller process's UID. Also if caller's uid or gid matches the
  # socket's uid or gid the lookup succeeds. Anything else causes a failure.
  #
  # To give the caller full permissions to lookup all users, set the mode to
  # something else than 0666 and Dovecot lets the kernel enforce the
  # permissions (e.g. 0777 allows everyone full permissions).
  unix_listener auth-userdb {
    mode = 0777
    user = docker
    group = docker
  }

Then we can run dovecot reload to have Dovecot respond to the update:

$ dovecot reload

# Confirm the changes are applied:
$ stat -c '%a' /var/run/dovecot/auth-userdb
777

Now we get the following failure about the group nogroup / 65534 not matching what was expected:

fetchmail[1884]: 1 message for [email protected] at mail.remote.test.
dovecot: lda([email protected])<1903><x1QWMuoONWhvBwAAEAQt/A>: Fatal: setgid(5000(docker) from userdb lookup) failed with euid=101(fetchmail), gid=65534(nogroup), egid=65534(nogroup): Operation not permitted (This binary should probably be called with process group set to 5000(docker) instead of 65534(nogroup))
fetchmail[1884]: reading message [email protected]@mail.remote.test:1 of 1 (813 header octets) (28 body octets) (log message incomplete)
fetchmail[1884]: MDA returned nonzero status 75
fetchmail[1884]:  not flushed

We can resolve that:

# Replace the primary group of the `fetchmail` user to now be `docker` (5000):
# NOTE: `adduser docker fetchmail` would not work as the primary group is what needs to be matched
usermod -g docker fetchmail

# Restart the service so the fetchmail process now runs with the updated primary group:
supervisorctl restart fetchmail

Instead of a setgid error, we now get the same for setuid:

# ...
Fatal: setuid(5000(docker) from userdb lookup) failed with euid=101(fetchmail): Operation not permitted (This binary should probably be called with process user set to 5000(docker) instead of 101(fetchmail))

Dovecot is using setuid + setgid to switch based on /etc/dovecot/userdb entry (which has 5000:5000 set, aka docker:docker for UID/GID):

[email protected]:{SHA512-CRYPT}$6$sbgFRCmQ.KWS5ryb$EsWrlYosiadgdUOxCBHY0DQ3qFbeudDhNMqHs6jZt.8gmxUwiLVy738knqkHD4zj4amkb296HFqQ3yDq4UXt8.:5000:5000::/var/mail/example.test/john.doe/home::

So fetchmail service would need to run as docker:docker (the Dovecot vmail user/group in DMS). Assigning the supervisord service a user of docker would then use the docker primary group, but that'll fail, but so will running as root due to an owner mismatch for the config at /etc/failmailrc:

# Running fetchmail as root:
$ USER=fetchmail HOME="/var/lib/fetchmail" /usr/bin/fetchmail -f /etc/fetchmailrc --nodetach --nosyslog
fetchmail: WARNING: Running as root is discouraged.
File /etc/fetchmailrc must be owned by you.

# Fix and try again:
$ chown root /etc/fetchmailrc
$ USER=fetchmail HOME="/var/lib/fetchmail" /usr/bin/fetchmail -f /etc/fetchmailrc --nodetach --nosyslog
fetchmail: WARNING: Running as root is discouraged.
fetchmail: starting fetchmail 6.4.37 daemon
1 message for [email protected] at mail.remote.test.
reading message [email protected]@mail.remote.test:1 of 1 (813 header octets) (28 body octets) flushed
fetchmail: sleeping at Tue May 27 04:09:30 2025 for 300 seconds
$ chown docker /etc/fetchmailrc

# Run a process with different UID/GID, similar to what supervisor would do:
$ setpriv --reuid=docker --regid=docker --clear-groups -- bash -c 'USER=fetchmail HOME="/var/lib/fetchmail" /usr/bin/fetchmail -f /etc/fetchmailrc --nodetach --nosyslog'
fetchmail: lstat: /var/lib/fetchmail/.fetchids: Permission denied

# Fix that too, try again and it should work now:
$ chown docker /var/lib/fetchmail
$ setpriv --reuid=docker --regid=docker --clear-groups -- bash -c 'USER=fetchmail HOME="/var/lib/fetchmail" /usr/bin/fetchmail -f /etc/fetchmailrc --nodetach --nosyslog'
fetchmail: starting fetchmail 6.4.37 daemon
1 message for [email protected] at mail.remote.test.
reading message [email protected]@mail.remote.test:1 of 1 (813 header octets) (28 body octets) flushed
fetchmail: sleeping at Tue May 27 04:14:42 2025 for 300 seconds
# Change fetchmail service `user` to `docker`:
nano /etc/supervisor/conf.d/dms-services.conf

# Refresh the service config of supervisord so it runs the updated config:
supervisorctl update fetchmail
supervisorctl start fetchmail

One more permission issue to address:

fetchmail[9148]: fetchmail: lock creation failed, pidfile "/var/run/fetchmail/fetchmail.pid": Permission denied
dms-fetch-1  | 2025-05-27 04:18:53,253 WARN exited: fetchmail (exit status 8; not expected)
chown docker /var/run/fetchmail

Anyway... bulk of that can be skipped with a PR fix to DMS.

Reference

DMS container config files related content:

/etc/passwd:

fetchmail:x:101:65534::/var/lib/fetchmail:/bin/false

/etc/group:

nogroup:x:65534:

Our supervisord config dms-services.conf has these two service configs (/etc/supervisor/conf.d/dms-services.conf in the container):

Supervisor does support user switching via a user setting, but not one for changing the group/GID for the process/service:

Image

As such the only way that's really going to work is for fetchmail to run as our vmail user/group (presently assigned as docker). Or like the referenced service Getmail that is similar in functionality, running it as root to leverage setuid/setgid.

From the official fetchmail docs for the --mda command:

If fetchmail is running as root, it sets its user id while delivering mail through an MDA as follows:

  • First, the FETCHMAILUSER, LOGNAME, and USER environment variables are checked in this order. The value of the first variable from his list that is defined (even if it is empty!) is looked up in the system user database.
  • If none of the variables is defined, fetchmail will use the real user id it was started with.
  • If one of the variables was defined, but the user stated there is not found, fetchmail continues running as root, without checking remaining variables on the list.

Practically, this means that if you run fetchmail as root (not recommended), it is most useful to define the FETCHMAILUSER environment variable to set the user that the MDA should run as.

Some MDAs (such as maildrop) are designed to be setuid root and setuid to the recipient's user id, so you do not lose functionality this way even when running fetchmail as unprivileged user. Check the MDA's manual for details.

Our supervisor config for fetchmail has the following which sets USER and HOME as part of the user switch:

environment=HOME="/var/lib/fetchmail",USER="fetchmail"
command=/usr/bin/fetchmail -f /etc/fetchmailrc --nodetach --daemon "%(ENV_FETCHMAIL_POLL)s" -i /var/lib/fetchmail/.fetchmail-UIDL-cache --pidfile /var/run/fetchmail/fetchmail.pid

Fetchmail can take args here, or as settings it's referenced fetchmailrc file (these are documented as Keyword values after the equivalent CLI option). Additional syntax for fetchmailrc is documented here. I need to update our docs at some point to reference these 🤔

polarathene avatar May 27 '25 09:05 polarathene

How does PERMIT_DOCKER change the fact that postfix is configured to require a valid fqdn in the HELO? Or is that requirement not active for mynetworks? tilt

Bonus Question (answer could come handy for solving another problem with roundcube in a different container):

Use PERMIT_DOCKER=connected-networks in this case. connected-networks => Add all connected docker networks (ipv4 only).

Thats not going to work on k8s, right? How does the container even know what networks are "connected" it only sees his own veth?

Xnyle avatar May 27 '25 09:05 Xnyle

How does PERMIT_DOCKER change the fact that postfix is configured to require a valid fqdn in the HELO? Or is that requirement not active for mynetworks? tilt

Being added to Postfix mynetworks setting will trust those clients, which skips a bunch of security checks:

  • https://www.postfix.org/postconf.5.html#mynetworks
  • https://github.com/docker-mailserver/docker-mailserver/blob/f28fce9cc432f1f447bd963d9e54e44bcf2c27dd/target/postfix/main.cf#L52-L62

Image

The main.cf link shows various Postfix restrictions that begin with permit_mynetworks, that is what trusts any client that is from a network belonging to mynetworks. As the permit_mynetworks is the first check, if it's valid it skips the need to perform any of the security restrictions that would follow after it.


Bonus Question (answer could come handy for solving another problem with roundcube in a different container):

Use PERMIT_DOCKER=connected-networks in this case. connected-networks => Add all connected docker networks (ipv4 only).

Thats not going to work on k8s, right? How does the container even know what networks are "connected" it only sees his own veth?

It's late here and as usual I'm short on time 😓

I've probably explained it this issue which documents flaws with the PERMIT_DOCKER ENV feature, as well as it's functionality.

Image

You can inspect our shellscript for the check if you like. IIRC it queries the network interfaces that are of type veth (belongs to a bridged network) and applies a fixed CIDR mask to trust an entire subnet. As such it's making assumptions based on defaults with docker at the time it was contribtued that may not be correct in other environments/configurations.

polarathene avatar May 27 '25 09:05 polarathene

Ok, assuming I got that right, the documentation on PERMIT_DOCKER is misleading:

host adds /16 of eth0 (veth) that's the whole cluster in k8s but NOT the host itself it' also way more than the connected-networks which just adds all visible networks which in k8s is just the pods /24

tldr; if you trust all containers in the host just go for "host" but don't expect the actual host to work ;-) connected-networks would work as well for fetchmal but not for other pods in the same namespace (e.g. roundcube) restricting to k8s NS only is probably impossible as there is no subnet abstraction between namespaces in flannel

Xnyle avatar May 27 '25 10:05 Xnyle

Ok, assuming I got that right, the documentation on PERMIT_DOCKER is misleading

Yes, the issue I linked you to for PERMIT_DOCKER is all about how it's not implemented well.

I have a large backlog across OSS projects that I am working through, so most of my time in DMS is maintenance and troubleshooting like above. I haven't had as much time as I'd like to actually spin up PRs to resolve everything, but it does get added to the issue tracker should someone else find time to contribute before I do.

As such, apart from other concerns with PERMIT_DOCKER I would not recommend it. You can however observe what it is doing and apply your own modifications via our override config support or user-patches.sh, where you have it configured correctly instead.

Just be careful of trusting subnets which include the gateway if you deploy to an environment like Docker has been known to be problematic in the past (due to the default docker-proxy enabled) which routed IPv6 connections through the IPv4 only docker network via the gateway IP. That made DMS an open relay as any IPv6 client was implicitly trusted due to mynetworks trusting the gateway, I've said similar for PROXY protocol usage too.


tldr; if you trust all containers in the host just go for "host" but don't expect the actual host to work ;-) connected-networks would work as well for fetchmal but not for other pods in the same namespace (e.g. roundcube)

You'd prefer PERMIT_DOCKER=container if fetchmail only needs to deliver to the internal Dovecot service in the same container. If you need to forward mail externally, there is probably better alternatives.

As mentioned above, while it should be trust all containers, it's more to do with the IP the connection appears to be coming from and that won't always be the actual client if there's a misconfiguration. Testing is important.

I'm not familiar with your roundcube concern, but I am with kubernetes ingress needing to preserve the client IP from external traffic via PROXY protocol as that's what prompted me to revise our docs on the subject. Which gets a bit more complicated when your internal containers also need to connect to DMS directly without PROXY protocol (also documented).

Roundcube has some support to preserve client IP IIRC, which can be important as there is the risk of fail2ban (if enabled) rejecting traffic from roundcube for all it's users otherwise, if one of them fails logins to Dovecot enough that the logs from the same IP would trigger a ban.


restricting to k8s NS only is probably impossible as there is no subnet abstraction between namespaces in flannel

I'm not too familiar with networking in k8s, but I have heard it can be rather flexible and you can use a variety of network solutions/plugins? One user I believe had a script that would interact with their choice of networking software to get the IPs of relevant containers and update a file that DMS used for trust (Postfix mynetworks can point to a config file to source which IPs or subnets should be trusted, you might need to issue a postfix reload command after an update to refresh it in Postfix though, I think there is similar for Dovecot).

polarathene avatar May 27 '25 21:05 polarathene

I've done another dive on this topic regarding the earlier concern noted with /etc/hosts.

It seems like we can fix the default behaviour when relying on OVERRIDE_HOSTNAME, I've confirmed the findings below work with fetchmail, the configured hostname via OVERRIDE_HOSTNAME ENV will be the HELO received by Postfix (or any other outbound connection relying on this approach to determine the hostname)

How software queries/resolves the container hostname (via libc, aka glibc/musl)

hostname --fqdn will query /proc/sys/kernel/hostname / sysctl kernel.hostname through glibc (relies on nsswitch.conf):

  • If nsswitch.conf first queries the files resolver, then it will check if the hostname exists in /etc/hosts and return the first hostname from the matched line/entry associating the original queried hostname to an IP.
  • Another resolver in /etc/nsswitch.conf that may follow as a fallback if no /etc/hosts match resolves, is dns which will perform a DNS query.

EDIT: This link provides an excellent overview of glibc calls getaddrinfo() + gethostbyname() (compatible with IPv4 entries only apparently, for both files and dns) behaviour with NSS and other related network config/interactions.

  • EDIT: hostname --fqdn seems to work fine with IPv6 entries in /etc/hosts?
    • Seems like it's due to linux adding a gethostbyname2() variant?
    • Alpine by default with busybox hostname -f fails with IPv6, but with the net-tools package, hostname -f becomes compatible with IPv6 too.
  • The hostname command as noted by that link is performing those calls (may differ a bit on musl?).
  • Also notes the source of error message upon failing to resolve, No address associated with hostname.

HOSTNAME ENV always set in a container:

Additionally, within the container the HOSTNAME environment variable will always be set, even when we have a minimal image like this:

src/main.rs:

use std::env;

// This program will check the environment and print the key/value pairs found:
fn main() {
    for (key, value) in std::env::vars() {
      println!("{key}: {value}");
    }
}
# Create basic rust project and modify `src/main.rs` to content shown above:
cargo init /tmp/example && cd /tmp/example
nano src/main.rs

# Build a static binary with `cargo build` / `cargo zigbuild`:
cargo zigbuild --release --target x86_64-unknown-linux-musl

cp ./target/x86_64-unknown-linux-musl/release/example ./check-env

Dockerfile:

FROM scratch
COPY env-check /env-check
ENTRYPOINT ["/env-check"]
# Create Dockerfile with content shown above, build the image and run:
$ nano Dockerfile
$ docker build --tag localhost/env-check .
$ docker run --rm -it --env EXAMPLE_ENV=42 localhost/env-check

PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME: fe9fd7d09328
TERM: xterm
EXAMPLE_ENV: 42
HOME: /

That image only has that basic program in it, no libc involved or anything else to set HOSTNAME, so that's being handled by Docker itself if --hostname is not used 😅

I assume kubernetes will also behave like that, despite lacking hostname configurability?


SYS_ADMIN capability

The hostname can also be set in a container if permissions are relaxed, but doing so requires the SYS_ADMIN capability to be granted, which is a bad idea.

$ docker run --rm -it --hostname hello.example.test debian:12-slim
$ hostname world.example.test
hostname: you must be root to change the host name

$ whoami
root
$ docker run --rm -it --hostname hello.example.test --cap-add SYS_ADMIN debian:12-slim
$ hostname
hello.example.test

$ hostname world.example.test
$ hostname
world.example.test

Updating the container /etc/hosts - Avoid changing the inode

The other concern we encountered was how to approach automating the update to /etc/hosts in the container since sed --in-place was incompatible:

$ docker run --rm -it debian:12-slim

# Match the container hostname in /etc/hosts and replace it with `example.test` (fails):
$ sed -i "s|${HOSTNAME}|example.test|" /etc/hosts
sed: cannot rename /etc/sedxigGKa: Device or resource busy

This is due to Docker implicitly bind mounting /etc/hosts when you start a container, just like any other volume with a bind mount it is tied to the inode. This is why you'd be better off avoiding bind mounts on specific files (if the inode changes on the host, your container will not receive that update as it still binds to the old file by original inode), while if you bind mount a directory you're free to change files within the container or host and the individual inodes there are not a concern, only the directory inode.

For reference you can check the inode of a file like this:

$ stat -c '%i' /etc/hosts
941270

We can however update a file without changing the inode too:

Here's an example demonstrating a change in hostname resolution to what we'd prefer instead for the canonical FQDN of our container:

$ docker run --rm -it debian:12-slim
$ hostname --fqdn
5f1d0fdb4de2

# Empty output as FQDN is single-label only:
$ hostname --domain

# Install the `sponge` command:
$ apt-get update -qq && apt-get install -qq moreutils

# Update the `/etc/hosts` entry with our desired hostname (OVERRIDE_HOSTNAME):
$ export OVERRIDE_HOSTNAME=mail.example.test
$ sed -E "s|($(hostname --fqdn))|${OVERRIDE_HOSTNAME} \1|" /etc/hosts | sponge /etc/hosts

# It now resolves the preferred hostname:
$ hostname --fqdn
mail.example.test

#
# Remaining commands are for reference purposes
#

# The full hostname (as per `cat /proc/sys/kernel/hostname`):
$ hostname
5f1d0fdb4de2

# Similar output to the default `hostname` command,
# If `sysctl kernel.hostname` has more than one DNS label/component,
# it will output only the first left-most DNS label (mail.example.test => mail)
$ hostname --short
5f1d0fdb4de2

# DNS domain name (truncates the first label, aka truncates `hostname --short`):
# - Effectively `dnsdomainname` / `domainname` command defaults?
# - Like `--fqdn` and `--alias` appears to rely on an NSS match being successful first.
$ hostname --domain
example.test

# All additional hostnames beyond `hostname --fqdn`, as defined for the `/etc/hosts` entry.
# - Resolved via matching `hostname --short` value to an IP.
# - Single line output with values delimited by white-space.
$ hostname --alias
5f1d0fdb4de2

# Not quite sure how this differs from `--fqdn`, I could not get it to output multiple FQDNs:
# UPDATE:
# - Appears to rely on matching /etc/hosts IP? (Did not require any matching hostname/FQDN, uses a DNS query?)
# - Doesn't seem relevant to `--fqdn`, outputs first hostname for that IP entry (FQDN are intended to be declared first)
$ hostname --all-fqdns

# This would fail when `--fqdn` + `--domainname` would too:
$ hostname --ip-address
172.17.0.2

# Reference content:
$ cat /etc/hosts
127.0.0.1       localhost
::1     localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
172.17.0.2    mail.example.test 5f1d0fdb4de2

NOTE: This hostname resolution behaviour may vary on other base image distros:

  • Notably as I thoroughly documented previously in Oct 2021 the Alpine image has hostname command symlinked to BusyBox (/bin/busybox, only supports hostname -f, not long option --fqdn) where I noted a difference in behaviour unless the net-tools package was installed which replaces the /bin/hostname symlink to an actual binary.
  • The ENV HOSTNAME similarly may be set in a manner that varies by base image (reference).

Reference + Docker CLI vs Docker Compose differences

For reference, when the container is configured with a custom hostname in Docker:

$ docker run --rm -it --hostname mail.example.test debian:12-slim

# Includes both FQDN (hostname --fqdn) and short hostname (hostname --short):
$ cat /etc/hosts
127.0.0.1       localhost
::1     localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
172.17.0.7      mail.example.test mail

$ hostname
mail.example.test

$ hostname --fqdn
mail.example.test

$ hostname --short
mail

$ hostname --domain
example.test

Additionally there is another difference between running a container with just the Docker CLI like shown above, and with Docker Compose.

  • The Docker CLI continues to default the network to the legacy docker0 bridge, while Docker Compose defaults to creating custom bridge networks per compose project.
  • If that CLI command were to add --network name-of-compose-network for it to join (or you create your own via docker network create ...), this will provide parity as the legacy bridge is treated differently.

Notably, without the custom network assigned, the hostname command with --fqdn/--domain/--alias/--ip-address will fail if there's no entry in /etc/hosts for the sysctl kernel.hostname configured (--hostname). You can verify this by trying to perform a DNS A record query to the hostname which will be missing. Meanwhile with custom networks that you get out of the box with Docker Compose, you would get the IP of the container for that containers hostname via Docker's embedded DNS service, hence the mentioned hostname options that would otherwise have failed would still succeed with a custom network used.

This is a difference worth noting as if there is no valid DNS to resolve, our usage of hostname --fqdn would fail - yet it would be successful when there's a valid match in /etc/hosts. The short hostname is not relevant for that query, only the sysctl kernel.hostname seems to be used.


Example script

Either of these in user-patches.sh would work:

#!/bin/bash

# Prepends the desired hostname before the known hostname of the matched container in `/etc/hosts`:
# NOTE: It is required to preserve the inode of `/etc/hosts` due to the container using an implicit bind mount for this file into the container.
# - `sed` with `-i` / `--in-place` or `mv` commands are examples that replace the original files inode.
# - Use `nano`, `cp`, or `sponge` to replace file content instead as these will update the original file (preserving the inode).

function replace_fqdn_sed() {
  local CURRENT_HOSTNAME="$(hostname --fqdn)"
  local TARGET_HOSTNAME="${OVERRIDE_HOSTNAME}"

  if [[ "${CURRENT_HOSTNAME}" != "${TARGET_HOSTNAME}" ]]; then
    sed -E "s|(${CURRENT_HOSTNAME})|${TARGET_HOSTNAME} \1|" /etc/hosts > /tmp/hosts.tmp
    cp /tmp/hosts.tmp /etc/hosts
    rm /tmp/hosts.tmp
  fi
}

# Requires the `moreutils` package for the `sponge` command:
function replace_fqdn_sponge() {
  local CURRENT_HOSTNAME="$(hostname --fqdn)"
  local TARGET_HOSTNAME="${OVERRIDE_HOSTNAME}"

  if [[ "${CURRENT_HOSTNAME}" != "${TARGET_HOSTNAME}" ]]; then
    sed -E "s|(${CURRENT_HOSTNAME})|${TARGET_HOSTNAME} \1|" /etc/hosts | sponge /etc/hosts
  fi
}

# Call one of the functions above to update /etc/hosts:
replace_fqdn_sed
replace_fqdn_sponge

NOTE:

  • That won't update HOSTNAME ENV in the shell session (neither does hostname example.test when using CAP_SYS_ADMIN), so keep that in mind.
  • When our user-patches.sh script is run, our internal scripts have already altered the HOSTNAME ENV (until we refactor in future to adopt DMS_FQDN instead).

polarathene avatar May 28 '25 04:05 polarathene

Considering the above information, this is probably important for us to manage as part of the OVERRIDE_HOSTNAME support by patching /etc/hosts when possible? 🤔

As I don't use Kubernetes, I would appreciate it if either @Xnyle @georglauterbach or @cfis could confirm /etc/hosts containing an appropriate entry with the hostname given to the container exists. Presumably there will be an IP assigned to the container with the hostname it was assigned (random hexadecimal value)?

If that is the case in k8s like it is with Docker Compose (when no hostname is explicitly configured), we could match on that as demonstrated below.


Docker + --network=host

FWIW: Docker does not add any hostname when using --network host as there is no docker managed network for that container to have it's own private IP address assigned with a DNS name to resolve to it.

Failing to find a match, we could potentially prepend to 127.0.0.1 localhost 🤔 which may be ok? Depends on what software is interacting with that. Technically with DNS as a fallback, updating /etc/hosts shouldn't be necessary, if sysctl kernel.hostname is configured to the public/private DNS name? Only when the hostname cannot be configured.

For Docker I was able to run a container with host mode networking, while configuring the hostname. The main difference is that the custom hostname is not added to /etc/hosts.

On WSL2 with a Windows 11 host running Docker Desktop, the container is run within the managed Docker WSL2 instance, so the default hostname with --network host is docker-desktop which would not be valid FQDN. Thus the hostname --fqdn (will fail if unable to resolve) / hostname (will not fail) commands could be used to add that when there is no entry in /etc/hosts, and OVERRIDE_HOSTNAME could prepend to that so that hostname --fqdn outputs that result.

$ hostname
docker-desktop

$ hostname --fqdn
docker-desktop

# Empty:
$ hostname --domain

$ hostname --short
docker-desktop

# Empty:
$ hostname --alias

# DNS resolution delay, followed by empty output:
# (might be querying all IP from `hostname --all-ip-addresses`)
$ hostname --all-fqdn

# Default network interface route of the container host I think? (WSL2 instance in this case)
# This IP is from the Docker Desktop configured subnet
$ hostname --ip-address
192.168.65.7

As there is no /etc/hosts match for docker-desktop, the results would be dependent upon DNS from nsswitch.conf being successful.

Modify /etc/hosts to include docker-desktop for resolving, but hello.example.test as the first hostname as the FQDN we want to resolve:

127.0.0.1       hello.example.test localhost docker-desktop
::1     localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
$ docker run --rm -it --network host docker:12-slim bash

$ hostname
docker-desktop

$ hostname --fqdn
hello.example.test

$ hostname --domain
example.test

$ hostname --short
docker-desktop

$ hostname --alias
localhost docker-desktop

# DNS resolution delay, followed by empty output:
$ hostname --all-fqdn

$ hostname --ip-address
127.0.0.1

polarathene avatar May 28 '25 04:05 polarathene

kubernetes ingress needing to preserve the client IP from external traffic via PROXY protocol

There is no Ingress? Just a k8s Service and with the Service being of type LoadBalancer while using MetalLB you get the correct IP delivered to your (container) service out of the box. So I didn't bother with PROXY proto.

Also I don't know what kind of voodoo Docker compose does in terms of ipv4/ipv6 translation but I don't think it applies to k8s? Or at least not to MetalLB?

Regarding unauthorized access from outside, even "host" setting wouldn't help much, You probably need custom config for you very network anyway (https://docker-mailserver.github.io/docker-mailserver/latest/config/advanced/override-defaults/postfix/)

Roundcube problem solved btw. as you can just configure it to use the IMAP Login for SMTP as well.

SYS_ADMIN Is a bad idea IMHO, just pursue other solutions ;-)

Xnyle avatar May 28 '25 08:05 Xnyle

kubernetes ingress needing to preserve the client IP from external traffic via PROXY protocol

There is no Ingress? Just a k8s Service and with the Service being of type LoadBalancer while using MetalLB you get the correct IP delivered to your (container) service out of the box. So I didn't bother with PROXY proto.

🤷‍♂ Like I said kubernetes isn't my area of expertise. I have had it requested though because of kubernetes where public traffic was coming in through Traefik to reach a container in a pod or something like that where the client IP needed to be preserved when it reached DMS, but using PROXY protocol with DMS then needs additional ports for other internal containers that connect to the DMS container directly.

I can't comment much on that further as I am not familiar enough with k8s networking.


Also I don't know what kind of voodoo Docker compose does in terms of ipv4/ipv6 translation but I don't think it applies to k8s? Or at least not to MetalLB?

Docker has NAT for IPv4 and IPv6 (optional) for traffic coming in from the host public network into the private subnet that docker manages for it's containers.

The default networking has userland-proxy enabled which is a service that alters iptables routing for some conveniences, such as IPv6 public client connections being routed to an IPv4 only network internally for the DMS container. With userland-proxy disabled that type of connection would fail by default, unless the DMS container was also added to an IPv6 network. No voodoo.

If you have a load balancer or other service that proxies traffic (be that a reverse proxy you host or cloudflare spectrum IIRC), that is another service inbetween the real client device and DMS, the original client IP will not be preserved unless you use PROXY protocol.

Again I'm not familiar with kubernetes networking, so if that's not the case for you, perhaps it's doing it's own "voodoo" in that kind of setup 🤔

Generally when possible I advise avoiding the indirection for DMS since nothing else should really need to share the mail ports they can be exposed to public traffic directly.


Roundcube problem solved btw. as you can just configure it to use the IMAP Login for SMTP as well.

There is login to Roundcube itself and login from roundcube to Dovecot (Postfix delegates to Dovecot via SASL). Connection from Roundcube to Dovecot is a single IP, unless you have something to preserve the original client IP, then it all appears from the same source.

Roundcube has some plugin with IMAP to workaround that but it requires some modification to Dovecot support IIRC.

Alternatively PROXY protocol can be used, you must configure Dovecot to trust Roundcube as you'd be giving it the ability to manipulate the PROXY protocol header to whatever IP source it likes to claim the traffic as coming from.


SYS_ADMIN Is a bad idea IMHO, just pursue other solutions ;-)

I never said it was good. I'm just documenting why the hostname couldn't be configured within the container by default via the hostname command despite technically being root.

The other solutions aren't perfect either, depends on how software chooses to acquire the hostname of the container if it needs it.

polarathene avatar May 28 '25 10:05 polarathene

Traefik to reach a container in a pod or something

Yeah thats an Ingress in k8s terms, a full blown Layer 6/7 application that translates and routes HTTP/HTTPS traffic, in conjunction with a cert-manager automatically handles TLS/SSL termination, etc.

Not used at all for dms-helm.

Services are Layer 4/5 and a LoadBalancer is Layer 2/3 in order to get your traffic in/out of those to be exposed services. There is a bit of nftables voodoo but no address family translation or even proxying.

So you don't loose the source address and don't need proxy workarounds. (Unless you have something in front of that that does SNAT but thats then not the fault of your k8s stack).

Xnyle avatar May 28 '25 20:05 Xnyle

As I don't use Kubernetes, I would appreciate it if either @Xnyle

cat /etc/hosts
# Kubernetes-managed hosts file.
127.0.0.1       localhost
::1     localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
fe00::0 ip6-mcastprefix
fe00::1 ip6-allnodes
fe00::2 ip6-allrouters
10.76.0.15      docker-mailserver-57b88777b-l7744

Also: (so those are not bind mounted host files but generated)

/dev/disk/by-uuid/xyz.. on /etc/hosts type ext4 (rw,relatime)
/dev/disk/by-uuid/xyz.. on /etc/hostname type ext4 (rw,relatime)
/dev/disk/by-uuid/xyz.. on /etc/resolv.conf type ext4 (rw,relatime)

10.76 0.0/24 is the pod network. As there is only one pod and only one container (design decison), there can't be other devices on that subnet.

PERMIT_DOCKER=connected-networks would then be basically the same as just my_ip. PERMIT_DOCKER=host would make a /16 out of that which in k8s would add a mask for all containers in all pods of the whole cluster but not the host network itself. PERMIT_DOCKER=network is a hack and the warning in the docs should not be about that docker might use other addresses but rather that you open your relay with that to potentially unwanted networks in half of the 172 range.

Xnyle avatar May 28 '25 20:05 Xnyle

Traefik to reach a container in a pod or something

Yeah thats an Ingress in k8s terms, a full blown Layer 6/7 application that translates and routes HTTP/HTTPS traffic, in conjunction with a cert-manager automatically handles TLS/SSL termination, etc.

Not used at all for dms-helm.

Traefik handles Layer 4 with just TCP when routing to DMS ports no?

I believe I've seen users relying on Traefik for managing the certs on the Docker side, not too sure about with k8s. Regardless DMS expects to terminate TLS, so any such connections are effectively passthrough in Traefik, but PROXY protocol would still be leveraged.


So you don't loose the source address and don't need proxy workarounds. (Unless you have something in front of that that does SNAT but thats then not the fault of your k8s stack).

Apologies, perhaps I made a mistake with the jargon by referring to ingress then 😓 (this comment also seems to clarify to a user that no ingress is involved)

I recently provided feedback to a PR here that was revising the PROXY protocol support: https://github.com/docker-mailserver/docker-mailserver-helm/pull/156#issuecomment-2795344601

We also have this PROXY protocol feature request from Feb 2024 that came from the maintainer of the helm repo here. Quick comment reference:

So I don't quite grok all of that, but that supposedly clarifies why some deployments with k8s need PROXY protocol support? 🤷‍♂


Oh and our DMS k8s docs page also has a section for PROXY protocol:

Image

So if there's a mistake there, such as referring to ingress with Traefik/Nginx in this context let me know :)

polarathene avatar May 28 '25 22:05 polarathene

10.76.0.15      docker-mailserver-57b88777b-l7744

Awesome, thanks!

Is that the same hostname value hostname --fqdn would return when run in the container? (presumably cat /proc/sys/kernel/hostname too?)

Also: (so those are not bind mounted host files but generated)

Thanks, on the docker container side I'm pretty sure they're bind mounts, they at least act like a file being bind mounted 😅 (could be something else causing the error with sed / mv)

So if we add a solution it would need to preserve the inode so that the change can be applied in that environment.


Regarding the feedback on PERMIT_DOCKER, as per the issue I linked you to, there's really not much to discuss there.

Someone could take the time to contribute to our docs for better clarity, or try tackle a refactor of the feature to address the known flaws the linked issue already documents. Otherwise I'll eventually get to it myself.

In the meantime I discourage using PERMIT_DOCKER since you shouldn't really ever need it, just giving clients credentials to authenticate with works well (with fetchmail it's not because it's attempting standard delivery to port 25 I think, rather than authenticated submission which should skip the HELO check just like PERMIT_DOCKER would). The feature itself is legacy and only really exists as an escape hatch for troubleshooting.

polarathene avatar May 28 '25 22:05 polarathene

Traefik handles Layer 4 with just TCP when routing to DMS ports no? I believe I've seen users relying on Traefik for managing the certs on the Docker side

No idea what users do with docker, it's a dead horse to me ;-)

Traefik or any other Ingess is in practice not involved in anything else than HTTP(S) communication. I don't know if the k8s concept allows other uses, but I've never seen that, probably because there simply is no mainstream software that does for instance "smtp/imap reverse proxying", what would that even be?

The routing is done via a CNI-Plugin like flannel and the "ingress" into the cluster network is (if not done via NodePort) via a LoadBalancer.

Certs (in dms-helm) are managed either manually or via a cert-manager but even that doesn't use an Ingress as no external resources need to talk to it (via HTTP).

Xnyle avatar May 29 '25 07:05 Xnyle

probably because there simply is no mainstream software that does for instance "smtp/imap reverse proxying", what would that even be?

Uhh you can do that with Caddy, Nginx or Traefik? Traffic comes into them on the public ports and they route to the actual service private ports.

I think Nginx has improved support for StartTLS, but for standard implicit TLS you can route by SNI:

Image

Depending on what you're setup is, you could potentially workaround the starttls ports concern for routing too, similar to how you can for reverse proxying SSH by SNI (outbound connection from the client is TLS wrapped via proxy command setting, reverse proxy inspects SNI and terminates the connection to unwrap it before forwarding to the desired service).

But the more common case is what I mentioned, if you have other hops between DMS and the connecting client (IP you want to preserve), you need to have the PROXY protocol used from the service that the real client connects to, then any forwarded TCP traffic has that information when it arrives to DMS (configured to trust the proxy service(s)).

polarathene avatar May 29 '25 08:05 polarathene