Using HAProxy with Halon
Halon MTA natively supports HAProxy (PROXY protocol) for both inbound and outbound SMTP routing. Outbound connections can go out via the HAProxy server, selecting any of the available source IPs. Inbound connections can be routed from the outside network back to Halon.
Together with Delivery Orchestrator clustering this provides a complete solution for multiple instances sharing a set of IPs (both IPv4 and IPv6) with cluster-wide control of concurrency, rates, and connection intervals.
Both of the following approaches are supported by Halon and are equally efficient:
-
Active/Passive mode with keepalived. This depends on VRRP multicast protocol which works at network layer 2, and is not applicable to public clouds.
Address the proxy tier as a single entity that hosts all the source IPs. Then configure Halon in one place, to use the proxy at transport (or transportgroup) level. Keepalived takes care of high-availability..
-
Active/Active mode. This is the simplest approach when hosting on public clouds.
Split the IP ranges up, and host each source IP on a specific proxy instance. Then configure in Halon to select the proxy at the address level. We achieve High Availability by having at least two addresses for each transport. Halon routes messages via proxies that are known to be up, with round-robin load sharing when both are up.
You can also combine both approaches if you have four HAProxy servers, i.e. a passive backup for each active/active.
It is also possible to have any combination of:
- Directly bound delivery source IPs;
- Proxied delivery source IPs;
- Direct inbound message listeners;
- Proxied inbound message listeners.
Outbound routing
This is easily configured in running configuration using the transportgroups[].connection.proxyprotocol server
and port
settings. This can be done at the transportgroup
or transport
level.
transportgroups:
- id: default
connection:
proxyprotocol:
server: 192.168.1.2 # IP address of the proxy server, can be specified here if all addresses are on the same proxy tier
port: 2525
You can also specify the proxy to use at an individual address level using the addresses[].proxyprotocol server
and port
settings:
addresses:
- id: "proxied-addr-1"
hostname: host.example.com
address: 199.60.103.103 # Source IP on the proxy
proxyprotocol:
server: 192.168.1.2
port: 2525
Your transports refer to each address. This is useful when you need to explicitly map which proxy has each address, for example in active/active mode.
Applying proxy settings in scripts
You can also apply proxy settings Pre-delivery script, via Try()
call parameters:
Try([
"proxyprotocol" => ["server" => "192.168.1.2", "port" => 2525],
"sourceip" => [
["address" => "1.2.3.4", "helo" => "smtp4.example.com"],
["address" => "1.2.3.5", "helo" => "smtp5.example.com"],
["address" => "1.2.3.6", "helo" => "smtp6.example.com"],
]]);
With all the above configuration methods, Halon prepends the PROXY header to the outbound connection, telling HAProxy which source IP(s) to use.
Inbound routing
Connections will be routed to Halon with a PROXY header prepended by HAProxy. We configure a Halon listener to expect this header, in the startup configuration servers.proxyprotocol
:
servers:
- id: viaproxy
type: smtp
listeners:
- port: 25
proxyprotocol: true
This listener can coexist with direct (non-proxied) listeners. A common use-case would be to inject messages from your internal network directly into Halon (e.g. over port 587) for onward delivery, while the async bounce and FBL listener is routed via your proxy tier.
HAProxy example configuration
This is the HAProxy configuration to use. For extra safety we've added a placeholder for a restricted network access policy. Adjust the 192.168.x.y
example addresses to suit your internal network.
listen outboundsmtp
acl is_local src 192.168.0.0/16 # Define the internal network range
tcp-request connection reject if !is_local # access-policy
bind 192.168.1.2:2525 accept-proxy # adjust to the proxy's internal network address
mode tcp
option tcplog
use-server v4 if { src 0.0.0.0/0 }
use-server v6 if { src ::/0 }
server v4 0.0.0.0 source 0.0.0.0 usesrc clientip
server v6 ::: source ::: usesrc clientip
Note that HAProxy cannot drop privileges when using this feature and has to run as root (so remove any global.user
directive).
To route inbound messages from the outside Internet to Halon, add the following, customizing the bind
directive to have your own public IP and the server
directive to match your Halon listeners.
frontend smtp-inbound-frontend
bind 45.33.68.55:25 # Your public IP that is receiving
mode tcp
option tcplog
default_backend smtp-inbound-backend
backend smtp-inbound-backend
mode tcp
server halon1 192.168.1.3:25 check send-proxy # Your Halon listeners
server halon2 192.168.1.4:25 check send-proxy
The above example distributes messages to two Halon instances.
The check
directive ensures that HAProxy will not route traffic to a backend host that is out of service.
If you are routing via a separate internal load balancer, such as when hosting Halon in Kubernetes, then it's usual to have just a single IP address here.
Outbound testing
If you are confident in your configuration, you can of course go straight to testing end-to-end message flow.
If you prefer a step-by-step approach, log in to your HAProxy host. Check that your public IPs are available, for example with ip addr show
. Here, we find our public IPs; in this example, we will test 45.33.68.55
.
- Check that you can establish a connection from each IP to any public email server. You can see Gmail's IP addresses using
dig mx gmail.com
. Pick one, and use thenc
tool to connect. Here we use172.253.122.26
, one of Google's public IPs.
nc -s 45.33.68.55 172.253.122.26 25
220 mx.google.com ESMTP d75a77b69052e-4af1670f5f5si114815431cf.808 - gsmtp
The banner output shows you have connected to Gmail.
- Check that HAProxy is listening and that your Halon host can reach it. Log in to the Halon host, and use
swaks
to send an email to your address.
Adjust --server
and --proxy-source
to suit your own addresses. Note the --proxy-source-port
is an ephemeral port number, you can use anything there.
swaks --server 192.168.1.2:2525 --from "<>" --to [email protected] --proxy-version 1 \
--proxy-family tcp4 --proxy-source 45.33.68.55 --proxy-source-port 54321 \
--proxy-dest 172.253.122.26 --proxy-dest-port 25
You should see a connection established from swaks through HAProxy to Google. Swaks prints the PROXY header to the console and the rest of the SMTP conversation proceeds. The message will be rejected because it lacks a valid FROM
address.
- Alternative: test using Halon Scripting Language (
hsh
)
Halon scripts can be run stand-alone using the hsh
tool, which enables easy testing without swaks
.
This script uses the MIME()
string builder to construct an email, then sends it via the proxy (without queuing) with the send()
method.
The response is pretty-printed to the console.
$mail = MIME()
->addHeader("Subject", "Hello")
->setBody("Hello World")
->addHeader("From", "<>")
->addHeader("Message-ID", "<" . uuid() . "@example.com>");
$test = $mail->send(
"<>",
[
"[email protected]"
],
[
"server" => "172.253.122.26", // destination mailbox host (Gmail in this case)
"port" => 25, // destination port
"sourceip" => "45.33.68.55", // source IP for the outbound connection
"proxyprotocol" => [
"server" => "192.168.1.2", // route via proxy server
"port" => 2525
]
]
);
echo json_encode($test, ["pretty_print" => true]);
Expected output:
hsh.bin: send: Connecting from [45.33.68.55] to [172.253.122.26]:25 via [192.168.1.2]:2525
{
"result": {
"code": 550,
"enhanced": [
5,
7,
1
],
"reason": [
"[45.33.68.55] Messages missing a valid address in From: header, or",
"having no From: header, are not accepted. For more information, go to",
" https:\/\/support.google.com\/mail\/?p=RfcMessageNonCompliant and review",
"RFC 5322 specifications. d75a77b69052e-4b11debe8adsi67221561cf.716 - gsmtp"
],
"state": "EOD"
},
"connection": {
"localip": "45.33.68.55",
"remoteip": "172.253.122.26",
"remotemx": "",
"tls": {
"started": false
}
},
"duration": 0.26235413
}
Now you can test messages from your Halon transports/addresses via the proxy, knowing that you have proved your connectivity.
Inbound testing
Step-by-step testing of inbound message flow starts with the Halon listener.
- Inject a message into Halon with a PROXY header, like it has come via a proxy. For this test, the values of
proxy-source
,proxy-source-port
andproxy-dest
can be anything. On your Halon host, run:
swaks --server 127.0.0.1:25 --from "<>" --to [email protected] --proxy-version 1 \
--proxy-family tcp4 --proxy-source 45.33.68.55 --proxy-source-port 54321 \
--proxy-dest 1.1.1.1 --proxy-dest-port 25
-
Check that your HAProxy has connectivity to Halon. Log in to the HAProxy host and run the same test, but set
--server
to be the address of your Halon listener. -
Outside your network, check that messages reach your HAProxy frontend, and are routed to Halon:
swaks --server 45.33.68.55:25 --from "<>" --to [email protected]
You should see the SMTP conversation succeed. Note that we no longer need the --proxy..
arguments, as these are added by HAProxy in this test.
- Finally, set up your MX and A records to point to your HAProxy frontend, so that you can receive email on your domains.
swaks
should succeed with just--from
and--to
addresses.