Pfxhttp is a lightweight HTTP proxy designed to integrate Postfix with external HTTP APIs for socket maps and policy services. This enables dynamic and flexible email workflows by connecting Postfix to modern APIs.
- Pfxhttp – HTTP Proxy for Postfix
- Table of contents
- Overview
- Getting Started
- Configuration
- Logging and Troubleshooting
- Contributing
- References
Pfxhttp allows you to:
- Perform dynamic lookups via socket maps, such as resolving virtual mailboxes or domains.
- Implement custom mail policy checks through HTTP-based policy services.
- Export optional observability through Prometheus metrics and OpenTelemetry OTLP HTTP traces/metrics.
The application is configured using a YAML file, specifying HTTP endpoints, the format of requests, and field mappings. It supports key Postfix features like query lookups and policy service hooks.
Pfxhttp is written in Go. It can be compiled with the following commands:
make
make installRelease builds also publish Linux .deb and .rpm packages. The packages use
distribution-owned paths and include:
/usr/sbin/pfxhttp/etc/pfxhttp/pfxhttp.yml/etc/pfxhttp/pfxhttp.env/usr/lib/systemd/system/pfxhttp.service/usr/lib/systemd/system/pfxhttp-policy.socket/usr/lib/systemd/system/[email protected]/usr/share/man/man5/pfxhttp.yml.5.gz/usr/share/man/man8/pfxhttp.8.gz/usr/share/doc/pfxhttp/README.md/usr/share/doc/pfxhttp/LICENSE/usr/share/doc/pfxhttp/pfxhttp.yml.demo
Package installation creates the pfxhttp system user and group when missing,
reloads the systemd manager, and leaves service enablement to the operator. The
packaged service reads optional overrides from /etc/pfxhttp/pfxhttp.env. The
packaged default configuration matches pfxhttp-policy.socket: it consumes the
systemd descriptor named policy for
/var/spool/postfix/private/pfxhttp-policy, avoids demo credentials, and stores
/etc/pfxhttp/pfxhttp.yml as a conffile with root:pfxhttp ownership and
0640 permissions so SIGHUP reloads remain readable after privilege drop. To
run pfxhttp.service without socket activation, edit the listener to remove
systemd_socket_name and configure a native TCP or Unix endpoint first.
- Go 1.26 or later
Pfxhttp is typically run as a systemd service. Below is an example unit file:
[Unit]
Description=PfxHTTP Postfix-to-HTTP server
After=network.target
[Service]
Type=simple
Restart=always
User=pfxhttp
Group=pfxhttp
EnvironmentFile=-/etc/pfxhttp/pfxhttp.env
ExecStart=/usr/local/sbin/pfxhttp
StandardOutput=journal
StandardError=journal
SyslogIdentifier=pfxhttp
MemoryMax=50M
CPUQuota=10%
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_CHOWN CAP_SYS_CHROOT
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
NoNewPrivileges=true
ReadOnlyPaths=/etc
ProtectKernelModules=true
MemoryDenyWriteExecute=true
ProtectControlGroups=true
ProtectKernelLogs=true
ProtectClock=true
RestrictSUIDSGID=true
ProtectProc=invisible
LimitNOFILE=1024
#RestrictAddressFamilies=AF_INET AF_INET6
[Install]
WantedBy=multi-user.targetYou must create a user pfxhttp and a group pfxhttp before using this unit file!
To install and start the service:
sudo systemctl daemon-reload
sudo systemctl enable pfxhttp
sudo systemctl start pfxhttpPfxhttp can also consume sockets created by systemd. This mode is optional and
per listener: if systemd_socket_name is omitted, the listener uses the native
net.Listen path exactly as before. If systemd_socket_name is set, pfxhttp
expects systemd to provide a matching FileDescriptorName via LISTEN_FDS.
Use Accept=no in the .socket unit. Systemd owns the listen socket, but
pfxhttp still accepts connections itself and continues to use the configured
worker pools.
Example socket unit for one Postfix policy-service socket:
[Unit]
Description=PfxHTTP policy service socket
[Socket]
ListenStream=/var/spool/postfix/private/pfxhttp-policy
FileDescriptorName=policy
Accept=no
SocketMode=0660
SocketUser=postfix
SocketGroup=postfix
Service=pfxhttp.service
[Install]
WantedBy=sockets.targetFor several listeners that follow the same naming scheme, the repository also
ships a template unit as contrib/systemd/[email protected]. The instance name is
used both as the systemd FileDescriptorName and as the socket path suffix:
[Socket]
ListenStream=/var/spool/postfix/private/pfxhttp-%i
FileDescriptorName=%i
Accept=no
Service=pfxhttp.serviceFor example, enabling [email protected] creates
/var/spool/postfix/private/pfxhttp-policy and passes it to pfxhttp.service
as FileDescriptorName=policy. Multiple template instances can point to the
same pfxhttp.service; systemd passes all sockets for that service to the
process when it starts. Use explicit .socket units instead if a listener needs
a path or descriptor name that does not fit the template. Enable either the
template instance or a concrete .socket unit for a listener, not both.
The packaged default configuration matches the concrete
pfxhttp-policy.socket unit.
sudo systemctl daemon-reload
sudo systemctl enable --now pfxhttp-policy.socket
sudo systemctl restart pfxhttp.serviceMatching listener configuration:
server:
listen:
- kind: "policy_service"
name: "policy"
type: "unix"
address: "/var/spool/postfix/private/pfxhttp-policy"
systemd_socket_name: "policy"For backend-only configuration changes, SIGHUP reloads continue to work. If a
reload would add, remove, or change a systemd-activated listener, pfxhttp keeps
the current configuration and logs that a service restart is required. With the
.socket unit active, systemctl restart pfxhttp.service keeps the listen
socket available while the process is replaced.
Pfxhttp provides the following command-line flags:
-
--config: Specifies the path to the configuration file. Overrides the default configuration file location.
./pfxhttp --config=/path/to/config.yml
-
--format: Sets the logging format. Available options are
yaml,tomlorjson../pfxhttp --format=json
Use these flags as needed to customize the behavior of the application during runtime.
All outbound HTTP requests use a default User-Agent header of PostfixToHTTP/ followed by the build version (Git tag). If the User-Agent header is already set via custom headers in a request configuration, it will not be overridden.
Pfxhttp is configured through a YAML file named pfxhttp.yml (or a custom file specified with the --config and --format flags). The following are the main sections:
The server section contains global options, including:
- Listeners: Define socket map and policy service listeners for Postfix integration.
- Privilege Dropping: Run as a specific user/group after binding listeners (
run_as_user,run_as_group). - Chroot: Optionally isolate the process in a chroot jail (
chroot). - Logging: Enable JSON-formatted logs and set verbosity (
debug,info, orerror). - HTTP Client Options: Configure connection limits, timeouts, and optional TLS settings.
- OIDC Authentication: Configure OIDC authentication (Client Credentials Flow) for HTTP requests with automatic token management.
- Response Cache: Optional in-memory cache to serve responses when the backend is unavailable.
- Worker Pool: Controlled performance by limiting the number of concurrent connections and providing back-pressure via a job queue.
- Systemd Socket Activation: Optionally bind a listener to a named systemd
FileDescriptorNameviasystemd_socket_name. - Observability: Optional Prometheus endpoint and OpenTelemetry OTLP HTTP export for listener, request, HTTP backend, gRPC backend, and OIDC activity.
Below is a detailed example configuration for pfxhttp.yml:
server:
run_as_user: "pfxhttp"
run_as_group: "pfxhttp"
# chroot: "/var/lib/pfxhttp"
listen:
- kind: "socket_map"
name: "demo_map"
type: "tcp"
address: "[::]"
port: 23450
- kind: "policy_service"
name: "example_policy"
type: "tcp"
address: "[::]"
port: 23451
# systemd_socket_name: "example_policy"
- kind: "dovecot_sasl"
name: "dovecot_sasl"
type: "tcp"
address: "0.0.0.0"
port: 23453
logging:
json: true
level: info
use_systemd: false
tls:
enabled: true
skip_verify: true
root_ca: "/etc/ssl/certs/ca-certificates.crt"
http_client:
timeout: 30s
max_connections_per_host: 100
max_idle_connections: 20
max_idle_connections_per_host: 20
idle_connection_timeout: 90s
proxy: "http://proxy.example.com:8080"
# Optional response cache to serve data during backend outages
response_cache:
enabled: true
ttl: 5m # cache lifetime per entry
observability:
prometheus_enabled: false
prometheus_address: "127.0.0.1"
prometheus_port: 9464
prometheus_path: "/metrics"
prometheus_runtime_metrics: false
# prometheus_http_auth_basic: "metrics:change-me"
prometheus_tls:
enabled: false
# cert: "/etc/pfxhttp/metrics.crt"
# key: "/etc/pfxhttp/metrics.key"
min_tls_version: "1.2"
otel_enabled: false
otel_traces_enabled: false
otel_metrics_enabled: false
otel_service_name: "pfxhttp"
# otel_service_version defaults to the binary version.
# otel_exporter_otlp_endpoint: "http://127.0.0.1:4318"
# otel_exporter_otlp_headers:
# authorization: "Bearer token"
otel_exporter_otlp_insecure: false
otel_sample_ratio: 1.0
socket_maps:
demo_map:
target: "https://127.0.0.1:9443/api/v1/custom/map"
custom_headers:
- "Authorization: Bearer <token>"
payload: >
{
"key": "{{ .Key }}"
}
status_code: 200
value_field: "data.result"
error_field: "error"
no_error_value: "not-found"
oidc_demo_map:
target: "https://127.0.0.1:9443/api/v1/custom/map"
backend_oidc_auth:
enabled: true
configuration_uri: "https://example.com/.well-known/openid-configuration"
client_id: "foobar"
client_secret: "secret"
payload: >
{
"key": "{{ .Key }}"
}
status_code: 200
value_field: "data.result"
error_field: "error"
no_error_value: "not-found"
policy_services:
example_policy:
target: "https://127.0.0.1:9443/api/v1/custom/policy"
custom_headers:
- "Authorization: Bearer <token>"
payload: "{{ .Key }}"
status_code: 200
value_field: "policy.result"
error_field: "policy.error"
no_error_value: "OK"
oidc_example_policy:
target: "https://127.0.0.1:9443/api/v1/custom/policy"
backend_oidc_auth:
enabled: true
configuration_uri: "https://example.com/.well-known/openid-configuration"
client_id: "foobar"
client_secret: "secret"
payload: "{{ .Key }}"
status_code: 200
value_field: "policy.result"
error_field: "policy.error"
no_error_value: "OK"
dovecot_sasl:
dovecot_sasl:
target: https://127.0.0.1:9443/api/v1/auth/json
# Bearer to backend (Nauthilus) via Client-Credentials-Flow (optional)
backend_oidc_auth:
enabled: true
configuration_uri: https://127.0.0.1:9443/.well-known/openid-configuration
client_id: pfxhttp
client_secret: backend-secret
# Validation of incoming XOAUTH2/OAUTHBEARER tokens
sasl_oidc_auth:
enabled: true
configuration_uri: https://127.0.0.1:9443/.well-known/openid-configuration
client_id: roundcube
client_secret: introspection-secret
scopes:
- "introspect"
validation: introspection
# Note: For dovecot_sasl, the payload is generated internally according to Nauthilus
# /api/v1/auth/json; a payload entry has no effect here.
status_code: 200
# Username is returned via HTTP response header "Auth-User" by the backend.
# Errors are signaled via HTTP status and the "Auth-Status" header.
Important: Postfix has a hardcoded socket map reply size limit of 100,000 bytes (Postfix 3.9.1 or older).
Each section (socket_maps, policy_services, dovecot_sasl) supports an optional defaults block. Values defined in defaults are automatically inherited by all entries in that section. Explicit values in an entry override the inherited defaults.
- Additive merge for
custom_headers: Headers fromdefaultsare prepended to entry-specific headers (not replaced).custom_headersare HTTP-only and are rejected fortransport: grpcentries. - Keyed merge for
grpc.metadata: Metadata fromdefaultsis inherited by gRPC entries; entry-specific keys override default keys. - The
defaultskey is reserved and cannot be used as a listener name. defaultsis optional — existing configurations without it continue to work unchanged.
Example with defaults:
socket_maps:
defaults:
target: "https://api.example.com/postfix/map"
http_auth_basic: "user:secret"
http_response_compression: true
payload: >
{
"key": "{{ .Key }}"
}
status_code: 200
value_field: "result"
error_field: "error"
relay_domains:
custom_headers:
- "X-Pfx-Name: relay_domains"
relay_recipient_maps:
custom_headers:
- "X-Pfx-Name: relay_recipient_maps"
transport_maps:
custom_headers:
- "X-Pfx-Name: transport_maps"Each entry inherits target, http_auth_basic, payload, status_code, etc. from defaults and only needs to specify what differs.
Instead of manually adding an Authorization: Basic ... header, you can use the http_auth_basic field:
http_auth_basic: "user:password"The value is automatically Base64-encoded and set as an Authorization: Basic <base64> header for HTTP transports. For dovecot_sasl gRPC entries, the same value is sent as authorization: Basic <base64> gRPC metadata. This field can be used both in defaults and in individual entries.
Pfxhttp includes an optional in-memory response cache. It always forwards responses from your backend, but if the backend becomes unavailable, it can serve a previously cached response for a configurable time (TTL).
Behavior:
- On backend failure: if a valid cache entry exists for the same map/policy name and key, it is returned.
- Cache population:
- Socket maps: cache only definitive successes (status "OK").
- Policy services: cache only definitive actions (anything other than empty).
- Expiration: entries expire after the TTL.
Configuration (server section):
server:
response_cache:
enabled: true
ttl: 5mNotes:
- TTL must be between 1s and 168h (7 days).
- Cache is in-memory and per-process; it is cleared on restart.
- Keys are derived from the tuple (name, key). For policy services, the key is the JSON of the policy payload.
Pfxhttp uses a worker pool to manage concurrent connections efficiently. This prevents the server from spawning an unlimited number of goroutines, which could lead to resource exhaustion under high load. You can configure a global worker pool for all listeners or a dedicated pool per listener.
Configuration (server section for global pool):
server:
worker_pool:
max_workers: 10 # Number of concurrent workers
max_queue: 100 # Maximum number of connections waiting in the queueConfiguration (listen section for per-listener pool):
server:
listen:
- kind: "socket_map"
name: "demo_map"
type: "tcp"
address: "[::]"
port: 23450
worker_pool:
max_workers: 5
max_queue: 20Behavior:
- Max Workers: Defines how many connections are processed simultaneously.
- Max Queue: Defines how many connections can be queued before the server starts applying back-pressure (blocking the
acceptcall). - Back-Pressure: When the queue is full, the server will wait until a worker becomes available before accepting more connections. This naturally slows down the sender (Postfix).
- Precedence: A worker pool defined in the
listensection takes precedence over the globalworker_poolin theserversection. - Defaults: If no
worker_poolis configured in theserversection, Pfxhttp automatically initializes a global worker pool withmax_workersset to2 * GOMAXPROCSandmax_queueset to10 * max_workers.
Observability is disabled by default. When enabled, Pfxhttp records listener connection events, protocol-level requests, outgoing HTTP backend requests, outgoing gRPC Authenticate calls, and OIDC discovery/token/JWKS/introspection traffic. Request contexts are delegated through these paths so outbound HTTP headers and gRPC metadata receive the active W3C trace context.
Ingress protocol traces include child spans for the listener protocol steps. Socket-map requests expose request read, decode, backend exchange, response encoding, and response write spans. Policy-service requests expose the same read/decode/backend/encode/write shape for policy payloads. Dovecot SASL requests expose mechanism-step, backend-auth, response-write, and continuation-wait spans so multi-step SASL exchanges show the time spent waiting for the next Postfix/client continuation instead of hiding it in the root auth span.
gRPC-backed SASL traces also split the pre-call preparation into gRPC connection, gRPC metadata, and gRPC request build spans. When OIDC caller authentication is enabled, token cache lookups, cache-lock waits, discovery, token fetches, client assertions, and JWKS validation are traced as child spans without recording secrets or token values.
Application request metrics keep the request-side name in the name label and add the configured listener name in the listener label. This lets dashboards show ingress latency per listener while still preserving map/backend-specific views for the outbound side.
Prometheus uses a dedicated HTTP endpoint:
server:
observability:
prometheus_enabled: true
prometheus_address: "127.0.0.1"
prometheus_port: 9464
prometheus_path: "/metrics"
prometheus_runtime_metrics: false
prometheus_http_auth_basic: "metrics:secret"
prometheus_tls:
enabled: true
cert: "/etc/pfxhttp/metrics.crt"
key: "/etc/pfxhttp/metrics.key"
min_tls_version: "1.2"prometheus_http_auth_basic protects the scrape endpoint with incoming HTTP Basic auth and must use user:password form. prometheus_tls enables HTTPS for the dedicated Prometheus endpoint; it requires a certificate and key when enabled and accepts min_tls_version values 1.2 or 1.3. These settings apply only to the metrics HTTP server. The top-level server.tls section remains the outbound backend HTTP client TLS configuration.
Scrape example:
curl --cacert /etc/pfxhttp/metrics.crt -u metrics:secret https://127.0.0.1:9464/metricsOpenTelemetry uses OTLP over HTTP. otel_enabled is the master switch; traces and metrics remain separate opt-in controls:
server:
observability:
otel_enabled: true
otel_traces_enabled: true
otel_metrics_enabled: true
otel_service_name: "pfxhttp"
otel_exporter_otlp_endpoint: "http://127.0.0.1:4318"
otel_exporter_otlp_insecure: true
otel_sample_ratio: 1.0Configuration keys:
| Key | Default | Description |
|---|---|---|
prometheus_enabled |
false |
Starts the Prometheus HTTP endpoint. |
prometheus_address |
127.0.0.1 |
Bind address for the metrics endpoint. |
prometheus_port |
9464 |
Bind port for the metrics endpoint. |
prometheus_path |
/metrics |
HTTP path for Prometheus scraping. |
prometheus_runtime_metrics |
false |
Adds Go runtime and process collectors. |
prometheus_http_auth_basic |
empty | Protects the scrape endpoint with Basic auth user:password. |
prometheus_tls.enabled |
false |
Serves the Prometheus endpoint over HTTPS. |
prometheus_tls.cert |
empty | PEM certificate file for the HTTPS scrape endpoint. |
prometheus_tls.key |
empty | PEM private key file for the HTTPS scrape endpoint. |
prometheus_tls.min_tls_version |
1.2 |
Lowest accepted TLS version: 1.2 or 1.3. |
otel_enabled |
false |
Enables OpenTelemetry export. |
otel_traces_enabled |
false |
Exports traces when OTel is enabled. |
otel_metrics_enabled |
false |
Exports OTel metrics when OTel is enabled. |
otel_service_name |
pfxhttp |
Sets the service.name resource attribute. |
otel_service_version |
build version | Sets the service.version resource attribute. |
otel_exporter_otlp_endpoint |
empty | OTLP HTTP collector endpoint; required when OTel is enabled. |
otel_exporter_otlp_headers |
empty | Map of headers for the OTLP HTTP exporter. |
otel_exporter_otlp_insecure |
false |
Uses insecure transport for OTLP HTTP. |
otel_sample_ratio |
1.0 |
Parent-based trace sampling ratio from 0.0 to 1.0. |
If otel_enabled is true, at least one of otel_traces_enabled or otel_metrics_enabled must also be true, and otel_exporter_otlp_endpoint must be set. Observability is initialized at process startup; change Prometheus bind settings or OTLP exporter settings with a service restart, not SIGHUP reload.
Pfxhttp can drop root privileges after startup and optionally run inside a chroot jail for additional isolation.
When started as root, Pfxhttp can drop to a non-root user and group after binding all listeners:
server:
run_as_user: "pfxhttp"
run_as_group: "pfxhttp"The user and group must exist on the system. Supplementary groups are cleared before switching.
Pfxhttp supports an optional chroot setting that changes the process root directory after binding listeners but before dropping privileges:
server:
run_as_user: "pfxhttp"
run_as_group: "pfxhttp"
chroot: "/var/lib/pfxhttp"The startup order is:
- Resolve credentials — look up user/group in
/etc/passwdand/etc/group - Bind listeners — bind to privileged ports or Unix sockets
- Chroot — change root directory
- Drop privileges — switch to the resolved UID/GID
Running inside a chroot jail imposes several constraints that must be understood before enabling this feature:
Configuration file
The configuration file (pfxhttp.yml) is read before the chroot takes effect. The path given via --config (or the default search paths) must refer to the real filesystem, not to a path inside the chroot. After chroot, the configuration file does not need to be present inside the jail. However, a SIGHUP reload will re-read the configuration file — since the process is now inside the chroot, the file must also be accessible at its configured path relative to the new root (e.g., /var/lib/pfxhttp/etc/pfxhttp/pfxhttp.yml).
Port binding and TCP listeners All TCP listeners are bound before the chroot and privilege drop. Privileged ports (< 1024) therefore work at initial startup, because the process still has root privileges at that point. No special preparation inside the chroot is needed for TCP listeners. After a SIGHUP reload, however, the process runs as an unprivileged user. Binding new or changed TCP listeners to privileged ports (< 1024) will fail. Only unprivileged ports (≥ 1024) can be used for listeners added or changed via reload.
Unix sockets Unix socket listeners are created before the chroot. The socket paths in the configuration refer to the real filesystem. After chroot, the process can still accept connections on these existing sockets because the file descriptors remain open. Postfix (or any other client) connects to the socket path on the real filesystem — no change is needed on the client side.
After a SIGHUP reload, any new or changed Unix socket listener paths are resolved relative to the chroot root. For example, a configured path /run/pfxhttp/new.sock becomes /var/lib/pfxhttp/run/pfxhttp/new.sock on the real filesystem. The corresponding directories must exist inside the chroot and be writable by the unprivileged user.
When systemd_socket_name is configured, systemd creates the socket before
pfxhttp starts. The socket path, mode, user, and group should be managed in the
.socket unit; pfxhttp only consumes the already-open file descriptor.
DNS resolution The Go runtime requires certain files for name resolution. The chroot directory must contain:
etc/resolv.confetc/hostsetc/nsswitch.conf
If any of these files are missing, Pfxhttp will refuse to start and log an error listing the missing files. Without these files, HTTP requests to hostnames (e.g., OIDC discovery endpoints) will fail silently or return errors.
TLS certificates
When using TLS with a custom root_ca, the CA certificate file is loaded before the chroot. The path in the configuration refers to the real filesystem. However, if the HTTP client is re-initialized during a SIGHUP reload, the root_ca path must be accessible inside the chroot (e.g., /var/lib/pfxhttp/etc/ssl/certs/ca-bundle.crt). It is therefore recommended to always place the CA file inside the chroot.
Similarly, if cert and key are configured for client TLS certificates, these files should also be present inside the chroot for reload compatibility.
OIDC private key files
If private_key_file is configured for private_key_jwt authentication, the key file is read at token fetch time (not at startup). After chroot, the path must be accessible inside the jail.
Example chroot directory layout
/var/lib/pfxhttp/
├── etc/
│ ├── hosts
│ ├── nsswitch.conf
│ ├── resolv.conf
│ ├── ssl/
│ │ └── certs/
│ │ └── ca-bundle.crt
│ └── pfxhttp/
│ └── pfxhttp.yml # only needed if SIGHUP reload is used
Note: When using chroot, the systemd unit file must run the service as root (remove
User=andGroup=directives) and includeCAP_SYS_CHROOTinCapabilityBoundingSet. Userun_as_userandrun_as_groupin the configuration file instead.
Pfxhttp supports OIDC Client Credentials Flow for HTTP requests to the target endpoints. This allows you to securely authenticate with APIs that require OIDC tokens.
The OIDC authentication feature includes:
- Automatic discovery of OIDC endpoints via the OpenID configuration URI
- Automatic token fetching using the
client_credentialsgrant type - Support for
client_secret(Basic Authentication) - Support for
private_key_jwt(RSA, ECDSA, or Ed25519) - Automatic token caching and refresh before expiration
To configure OIDC authentication for a socket map or policy service:
socket_maps:
example:
target: "https://api.example.com/endpoint"
backend_oidc_auth:
enabled: true
configuration_uri: "https://auth.example.com/.well-known/openid-configuration"
client_id: "your-client-id"
# Use either client_secret:
client_secret: "your-client-secret"
# OR private_key_file for private_key_jwt:
# private_key_file: "/path/to/private-key.pem"
# Optional: list of scopes
scopes:
- "api.read"
- "api.write"The OIDC access token will be automatically fetched and included in the Authorization header as a Bearer token for all requests to the target endpoint.
Pfxhttp allows you to control HTTP compression per target (socket map or policy service). This is useful when your backend supports gzip and you want to reduce bandwidth or comply with specific API requirements. Nauthilus backends support gzip exclusively.
- http_request_compression: When true, Pfxhttp gzips the request body and sets Content-Encoding: gzip. Disabled by default.
- http_response_compression: When true, Pfxhttp advertises Accept-Encoding: gzip and will transparently decompress gzip responses if the server replies with Content-Encoding: gzip. Disabled by default.
Notes:
- Compression settings are defined per target, not globally.
- The HTTP client's automatic gzip handling is disabled to ensure per-target control.
- Only gzip is supported currently.
Example:
socket_maps: demo: target: https://127.0.0.1:9443/api/v1/custom/postfix/socket_map http_request_compression: true http_response_compression: true payload: > { "key": "{{ .Key }}" } status_code: 200 value_field: "demo_value"
policy_services: policy: target: https://127.0.0.1:9443/api/v1/custom/postfix/policy_service http_request_compression: false http_response_compression: true payload: "{{ .Key }}" status_code: 200 value_field: "result"
To configure Postfix to use a socket map, simply add it to your main.cf:
# main.cf
virtual_mailbox_domains = socketmap:tcp:127.0.0.1:23450:demo_map
Here, Postfix connects to the TCP socket map listener defined in pfxhttp.yml for demo_map.
To use a policy service, include it in your recipient restrictions list in main.cf:
# main.cf
smtpd_recipient_restrictions =
permit_mynetworks,
reject_unauth_destination,
check_policy_service inet:127.0.0.1:23451
This setup enables Postfix to query the policy service defined in pfxhttp.yml for example_policy.
Pfxhttp can act as a Dovecot-compatible SASL server for Postfix. When Postfix does not provide a local_port, administrators may configure a fallback in the corresponding dovecot_sasl target via default_local_port.
Example configuration:
dovecot_sasl:
login_smtp:
target: "https://nauthilus.example.org/api/v1/sasl/auth"
# Optional fallback when Postfix does not provide the local port
default_local_port: "587"
# Optional: enable OIDC-based token validation for XOAUTH2/OAUTHBEARER
sasl_oidc_auth:
enabled: true
configuration_uri: "https://auth.example.org/.well-known/openid-configuration"Behavior:
- If Dovecot provides
local_port, it is forwarded. - Else, if
default_local_portis set, it is sent aslocal_portto the backend. - Applies to both password-based and OAuth-based SASL flows.
For password-based SASL mechanisms (PLAIN/LOGIN), dovecot_sasl entries can
optionally call the Nauthilus gRPC AuthService instead of posting JSON to
the HTTP endpoint. The gRPC service shares the same authentication contract
(Authenticate RPC in nauthilus.auth.v1), so caller credentials, OIDC
backend authentication and TLS settings keep their semantics across both
transports.
Token-based mechanisms (XOAUTH2 / OAUTHBEARER) always validate via the OIDC
introspection / JWKS endpoints over HTTP, regardless of transport.
dovecot_sasl:
smtp_auth:
transport: grpc
# backend_oidc_auth applies to gRPC just like HTTP and produces the
# `authorization: Bearer <token>` metadata for outgoing RPCs.
backend_oidc_auth:
enabled: true
configuration_uri: "https://idp.example.org/.well-known/openid-configuration"
client_id: "pfxhttp"
private_key_file: "/etc/pfxhttp/private.pem"
auth_method: "private_key_jwt"
grpc:
address: "nauthilus.example.org:9444"
timeout: 5s
metadata:
accept-language:
- "de"
tls:
enabled: true
root_ca: "/etc/pfxhttp/nauthilus-ca.pem"
# Optional mTLS:
client_cert: "/etc/pfxhttp/client.pem"
client_key: "/etc/pfxhttp/client.key"
# server_name overrides the SNI/SAN used during the TLS handshake.
server_name: "nauthilus.example.org"
# min_tls_version is "1.2" (default) or "1.3". Anything below 1.2
# is rejected at config-load time.
min_tls_version: "1.3"
# skip_verify disables certificate verification — only for testing.
skip_verify: falseNotes:
transportdefaults tojson. When set togrpc, thetargetHTTP URL is no longer required; insteadgrpc.address(host:port) becomes mandatory.- The gRPC
Authenticaterequest forwards the same core SASL context as the JSON transport and additionally maps every parsed field that exists in the AuthService contract when Postfix/Dovecot supplies it:external_session_id(orsession),user_agent,ssl_session_id,ssl_client_verify,ssl_client_dn,ssl_client_cn,ssl_issuer,ssl_client_notbefore,ssl_client_notafter,ssl_subject_dn,ssl_issuer_dn,ssl_client_subject_dn,ssl_client_issuer_dn,ssl_serial,ssl_fingerprint,oidc_cid, andauth_login_attempt. Direct contract names such asclient_ip,local_ip,client_port,local_port,client_hostname,protocol,method,ssl, andssl_protocolare also accepted; classic Dovecot names such asrip,lip, andlocal_nameremain supported. Fields without an AuthService equivalent, such asssl_cipher_bits,ssl_pxt_id,nologin, andno_penalty, are not sent over gRPC. - If Postfix/Dovecot does not provide
external_session_idorsession, pfxhttp sends its own Dovecot-SASL connection session ID asexternal_session_idso backend logs still have a stable correlation value. - Caller authorization is derived from existing fields:
backend_oidc_auth.enabled: true→ the OIDC manager fetches a token via Client Credentials /private_key_jwtand the result is sent asauthorization: Bearer <token>metadata.- Otherwise
http_auth_basic: "user:secret"is encoded and sent asauthorization: Basic <base64>metadata.
- Additional gRPC request metadata is configured explicitly under
grpc.metadata. Keys are normalized to lowercase. Theauthorizationkey,grpc-prefix and binary-binmetadata are reserved; usehttp_auth_basicorbackend_oidc_authfor caller authorization. custom_headersare HTTP-only. Entries usingtransport: grpcmust usegrpc.metadatainstead.- The shared connection pool maintains one long-lived
*grpc.ClientConnperdovecot_saslentry. Multiple SASL sessions multiplex over the same HTTP/2 connection. Existing Dovecot connections use the reloaded backend settings for the next authentication request. SIGHUP-based reloads automatically:- rebuild a connection when the gRPC settings of an entry change,
- close connections for entries that were removed or switched back to the JSON transport,
- leave unchanged entries' connections in place to avoid disruption.
- gRPC errors are mapped conservatively: caller-credential rejection, backend unavailability and timeouts surface as temporary failures so Postfix retries instead of immediately blacklisting the user.
Logs are output to the console by default and should be captured by the service manager (e.g., systemd). Log verbosity is configurable in the pfxhttp.yml file.
Available logging options:
- json: If
true, log output is formatted as JSON. Default:false. - level: Log verbosity. Options:
none,debug,info,error. Default:info. - use_systemd: If
true, timestamps are omitted from log lines. This is useful when running under systemd, which adds its own timestamps. Default:false.
server:
logging:
json: false
level: info
use_systemd: truePfxhttp assigns a unique session ID to every incoming connection. This session ID is logged with every message related to that connection, making it easy to trace all activity belonging to a single client connection.
Within a connection, each individual request (e.g., a socket map lookup, a policy check, or a SASL AUTH/CONT command) receives a unique sub_session ID. This allows fine-grained tracing of individual requests within a long-lived connection.
The session IDs are 16-character random hex strings generated using crypto/rand.
Log fields:
session: Identifies the connection. Stays the same for all log lines of a given connection.sub_session: Identifies a single request within a connection.
Example log output:
level=INFO msg="New connection established" session=a1b2c3d4e5f67890 client=127.0.0.1:12345
level=DEBUG msg="Received request" session=a1b2c3d4e5f67890 sub_session=9f8e7d6c5b4a3210 client=127.0.0.1:12345
level=DEBUG msg="Response sent" session=a1b2c3d4e5f67890 sub_session=9f8e7d6c5b4a3210 client=127.0.0.1:12345
level=INFO msg="Connection closed" session=a1b2c3d4e5f67890 client=127.0.0.1:12345
When running under systemd, the journal already adds timestamps to every log line. To avoid duplicate timestamps, set use_systemd: true in the logging configuration:
server:
logging:
level: info
use_systemd: trueThis removes the time field from log output. All other fields (level, message, session, sub_session, etc.) remain unchanged.
If Pfxhttp fails to start, verify the following:
- Ensure the configuration file (
/etc/pfxhttp/pfxhttp.yml) is valid and complete. - Ensure the service is running with the appropriate permissions for the configured resources.
Contributions are welcome! Feel free to submit pull requests or issues to improve the project. The project is distributed under the MIT License.
Before committing code changes, run the local quality gate:
make guardrailsThis requires golangci-lint v2.12.1 or a compatible v2 release on PATH.
Dependency updates should be followed by go mod tidy and go mod vendor so the vendored build remains reproducible.
Release tags must use vMAJOR.MINOR.PATCH. Prerelease tags such as
v1.2.3-alpha.2 or v1.2.3-rc.4 are supported and are published as GitHub
prereleases. Docker prereleases only receive the exact version tag; stable
release tags also update latest, vMAJOR, and vMAJOR.MINOR.
Linux package prereleases use Debian/RPM-compatible ~ package versions.
Release package jobs build both formats through scripts/build-linux-package.sh
and validate the final package contents with scripts/verify-linux-package.sh
before upload.
- Postfix Documentation
- Nauthilus
- Manpages:
pfxhttp(8): Overview and service managementpfxhttp.yml(5): Detailed configuration guide
The following optional fields fine-tune OIDC behavior. Defaults are chosen for maximum interoperability and security.
-
auth_method: How the client authenticates to the token and introspection endpoints. Values:auto(defaulting is resolved during config load)client_secret_basic(default)client_secret_postprivate_key_jwtnone
If
auth_methodis omitted or set toauto, the following preference is applied:- Use
private_key_jwtwhenprivate_key_fileis set - Else use
client_secret_basicwhenclient_secretis set - Else fall back to
none(send onlyclient_id)
-
sasl_oidc_auth.scopes: Optional list of scopes to send as a space-separatedscopeparameter to the introspection endpoint. Only needed if your provider requires it for introspection access. -
sasl_oidc_auth.validation: How SASL OAuth tokens are validated.introspection(default): Always call the provider’s introspection endpoint (RFC 7662). Supports opaque tokens and immediate revocation checks.jwks: Validate tokens locally using the provider’sjwks_uri. Lowest latency for JWTs, but revocations may take effect only after key/claim changes.auto: Try JWKS first for JWTs; fall back to introspection for opaque tokens or transient JWKS issues.
-
sasl_oidc_auth.jwks_cache_ttl: Duration for caching the JWKS document. Default:5m. -
sasl_oidc_auth.account_claim: Specifies which claim (for JWT/JWKS validation) or which field in the introspection response should be used as the account/username. If omitted, the default resolution chain is used:- JWKS:
sub→preferred_username→username - Introspection:
sub→username
- JWKS:
Example with advanced settings:
socket_maps:
example:
target: "https://api.example.com/endpoint"
backend_oidc_auth:
enabled: true
configuration_uri: "https://auth.example.com/.well-known/openid-configuration"
client_id: "your-client-id"
client_secret: "your-client-secret"
# Use POST body instead of Basic Auth:
auth_method: client_secret_post
# SASL token validation strategy (for dovecot_sasl only):
sasl_oidc_auth:
enabled: true
configuration_uri: "https://auth.example.com/.well-known/openid-configuration"
client_id: "roundcube"
client_secret: "introspection-secret"
validation: auto # or: introspection | jwks
jwks_cache_ttl: 5m
# Use a custom claim/field as the account name:
account_claim: "dovecot_account"Notes:
- HTTP requests to the token and introspection endpoints now include
Accept: application/json. - Request bodies are built once and never rewritten after
http.NewRequest, ensuring correctContent-Lengthhandling. - For target requests with
backend_oidc_auth, any pre-existingAuthorizationheader (e.g., set viacustom_headers) is explicitly removed and replaced withAuthorization: Bearer <token>. - For introspection requests with
sasl_oidc_auth, anyAuthorizationheader is explicitly cleared before applying the selected client authentication (client_secret_basicorclient_secret_post). - JWKS-based validation supports RSA, EC (P-256/384/521), and Ed25519 keys from the provider’s
jwks_uri.