Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

3 watching

Forks

Latest commit

 

History

195 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

userli-postfix-adapter

Integration Maintainability Rating Quality Gate Status Coverage

This is a postfix socketmap adapter for the userli project. It implements the socketmap protocol to provide dynamic lookups for aliases, domains, mailboxes, and senders. It also provides a Dovecot SASL authentication server, allowing Postfix to authenticate SMTP clients against the Userli API without requiring Dovecot.

Configuration

The adapter is configured via environment variables:

  • USERLI_TOKEN: The token to authenticate against the userli API.
  • USERLI_BASE_URL: The base URL of the userli API.
  • POSTFIX_RECIPIENT_DELIMITER: The recipient delimiter used in Postfix (e.g., +). Default: empty.
  • SOCKETMAP_LISTEN_ADDR: The address to listen on for socketmap requests. Default: :10001.
  • POLICY_LISTEN_ADDR: The address to listen on for policy requests (rate limiting). Default: :10003.
  • SASL_LISTEN_ADDR: The address to listen on for Dovecot SASL authentication. Supports TCP (e.g., :10006) and UNIX sockets (e.g., /var/spool/postfix/private/auth). Default: empty (disabled).
  • SASL_SOCKET_MODE: Octal permission mode of the SASL UNIX socket (e.g. 0660). Default: 0666.
  • SASL_REQUIRE_TLS: Reject SASL authentication in SMTP sessions without TLS (like Dovecot's disable_plaintext_auth). Default: false.
  • METRICS_LISTEN_ADDR: The address to listen on for metrics. Default: :10002.
  • RATE_LIMIT_MESSAGE: The rejection message returned when a sender exceeds their quota. Default: Rate limit exceeded, please try again later.
  • REDIS_URL: Connection URL for Redis (required). Format follows redis.ParseURL, e.g. redis://[user:password@]host:port/db. Rate-limit state is stored in Redis so it survives restarts.
  • LOOKUP_CACHE_TTL: TTL for cached successful (OK) lookup responses in Redis. Accepts any Go duration (e.g. 300s, 5m). Default: 300s. Set to 0 to disable the cache. NOTFOUND/TEMP/PERM responses are never cached.

In Postfix, you can configure the adapter using the socketmap protocol like this:

virtual_alias_maps = socketmap:inet:localhost:10001:alias
virtual_mailbox_domains = socketmap:inet:localhost:10001:domain
virtual_mailbox_maps = socketmap:inet:localhost:10001:mailbox
smtpd_sender_login_maps = socketmap:inet:localhost:10001:senders

Policy Server (Sending Permission and Rate Limiting)

The adapter also provides a Postfix SMTP Access Policy Delegation server for outgoing mail. It queries the Userli API for the quota and sending permission of the authenticated sender and enforces them.

Configure in Postfix main.cf:

smtpd_recipient_restrictions =
    check_policy_service inet:localhost:10003,
    permit_sasl_authenticated,
    reject_unauth_destination
smtpd_end_of_data_restrictions = check_policy_service inet:localhost:10003

The policy service must come before permit_sasl_authenticated, otherwise it is never asked for authenticated senders.

The Userli API endpoint /api/postfix/smtp_quota/{email} returns:

{
    "per_hour": 100,
    "per_day": 1000,
    "sending_allowed": true,
    "allowed_recipients": []
}

Sending permission

If sending_allowed is false, the sender may only send to the addresses in allowed_recipients. All other recipients are rejected. Userli decides when this applies, e.g. for accounts marked as compromised, so that these accounts can still reach support.

The permission is checked for every recipient (RCPT stage) of SASL-authenticated clients. Mail without sasl_username is not checked. Recipients are compared case-insensitively and without the recipient delimiter tag (POSTFIX_RECIPIENT_DELIMITER). Responses without sending_allowed (older Userli versions) allow sending.

Important: This check is fail-closed. If the API is unreachable or answers with an error, recipients are deferred with a temporary error (450), so that mail clients retry later.

Rate limiting

The limits are checked at the END-OF-MESSAGE stage. 0 means unlimited. If the API is unreachable, messages are allowed (fail-open).

Rate-limit state is persisted to Redis (REDIS_URL) so it survives restarts. If Redis is unreachable, messages are also allowed (fail-open) and the userli_postfix_adapter_ratelimit_backend_errors_total counter is incremented.

SASL Authentication (Dovecot SASL)

The adapter implements the Dovecot SASL authentication protocol, so Postfix can authenticate SMTP clients via smtpd_sasl_type=dovecot without requiring Dovecot itself. Authentication is performed against the Userli API (/api/postfix/auth). Supported mechanisms: PLAIN and LOGIN.

Important: SASL authentication is fail-closed. If the API is unreachable or slow (requests time out after 5 seconds), authentication is rejected with a temporary error (454), so mail clients retry instead of asking for a new password.

Rejected credentials are always reported as authentication failed, without the reason from the Userli API (e.g. a required password change). Postfix passes the reason to the SMTP client, and the API reports account states before checking the password. The reason is logged by the adapter.

Configure in Postfix main.cf (TCP):

smtpd_sasl_type = dovecot
smtpd_sasl_path = inet:localhost:10006
smtpd_sasl_auth_enable = yes

Or using a UNIX socket (set SASL_LISTEN_ADDR=/var/spool/postfix/private/auth):

smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_auth_enable = yes

Security considerations:

  • The SASL socket answers any client that can reach it, so it can be used to test passwords without going through Postfix (and its TLS and client restrictions). Bind TCP listeners to localhost or a private network only, and keep UNIX sockets inside Postfix' private/ directory or restrict them with SASL_SOCKET_MODE.
  • The adapter announces PLAIN and LOGIN as plaintext mechanisms. Set smtpd_tls_auth_only = yes so Postfix offers AUTH only after STARTTLS. As a second line of defense, set SASL_REQUIRE_TLS=true to reject AUTH requests that Postfix marks as coming from a session without TLS.

Brute-force protection:

The adapter does not delay or throttle failed authentications itself (unlike Dovecot's auth_failure_delay). Handle this in Postfix and at the firewall, which also covers other services and drops attackers before they reach the Userli API:

# master.cf: on the services with SASL enabled (submission, submissions)
submission inet n - n - - smtpd
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_tls_auth_only=yes
  -o smtpd_client_auth_rate_limit=10
  -o smtpd_error_sleep_time=1s
  -o smtpd_soft_error_limit=3
  -o smtpd_hard_error_limit=5
  • smtpd_client_auth_rate_limit: maximum AUTH commands per client IP per anvil_rate_time_unit (default 60s). Postfix default is 0 (unlimited).
  • smtpd_error_sleep_time / smtpd_soft_error_limit: failed AUTHs count as errors; past the soft limit every response is delayed.
  • smtpd_hard_error_limit: disconnect the client after this many errors.

Postfix logs every failed authentication with the client IP:

warning: unknown[192.0.2.1]: SASL LOGIN authentication failed: authentication failed

Ban offending clients based on this line, e.g. with the fail2ban postfix-sasl jail or the CrowdSec crowdsecurity/postfix collection (SASL failures feed the crowdsecurity/postfix-spam scenario). Monitor userli_postfix_adapter_sasl_auth_total{result="invalid_credentials"} to spot distributed attacks that stay below per-IP limits.

Docker

You can run the adapter using Docker. A docker-compose.yml file is provided for convenience.

docker compose up -d

# Create the database and load fixtures
docker compose exec userli bin/console doctrine:schema:create
docker compose exec userli bin/console doctrine:fixtures:load --no-debug

docker compose exec postfix postmap -q "example.org" socketmap:inet:adapter:10001:domain

The socketmap names supported are:

  • alias - For virtual alias lookups
  • domain - For virtual domain lookups
  • mailbox - For virtual mailbox lookups
  • senders - For sender login map lookups

Usage Example

You can test the socketmap adapter using postmap:

# Test alias lookup
postmap -q "test@example.com" socketmap:inet:localhost:10001:alias

# Test domain lookup
postmap -q "example.com" socketmap:inet:localhost:10001:domain

# Test mailbox lookup
postmap -q "user@example.com" socketmap:inet:localhost:10001:mailbox

# Test sender lookup
postmap -q "sender@example.com" socketmap:inet:localhost:10001:senders

Docker Usage

# Build and run with docker-compose
docker-compose up --build

# Or build manually
docker build -t userli-postfix-adapter .
docker run -e USERLI_TOKEN=your_token -e USERLI_BASE_URL=http://your-userli-instance -p 10001:10001 -p 10002:10002 userli-postfix-adapter

Protocol Details

The adapter implements the Postfix socketmap protocol using netstrings for encoding. Each request and response is formatted as:

[length]:[data],

Where:

  • length is the decimal length of the data
  • : is a delimiter
  • data is the actual request or response content
  • , is the terminating comma

Request Format

[length]:[mapname key],

Examples:

  • 22:alias test@example.com, - Look up alias for test@example.com
  • 18:domain example.com, - Check if domain example.com exists
  • 23:mailbox user@example.com, - Check if mailbox user@example.com exists
  • 24:senders user@example.com, - Get senders for user@example.com

Response Format

The adapter returns one of these response types:

  • OK [data] - Successful lookup with data
  • NOTFOUND - No data found for the key
  • TEMP [reason] - Temporary error (retry later)
  • PERM [reason] - Permanent error (don't retry)

Examples:

  • 19:OK dest@example.com, - Alias found, destination is dest@example.com
  • 4:OK 1, - Domain/mailbox exists
  • 8:NOTFOUND, - No data found
  • 20:TEMP Service error, - Temporary service error

Observability

The adapter exposes Prometheus metrics on /metrics (port 10002) and provides health check endpoints.

Metrics

Socketmap Metrics:

  • userli_postfix_adapter_request_duration_seconds - Request duration histogram
  • userli_postfix_adapter_requests_total - Total request counter
  • userli_postfix_adapter_active_connections - Active connections gauge
  • userli_postfix_adapter_connection_pool_usage - Connection pool usage (0-500)

HTTP Client Metrics:

  • userli_postfix_adapter_http_client_duration_seconds - Userli API request duration
  • userli_postfix_adapter_http_client_requests_total - Userli API request counter

Health:

  • userli_postfix_adapter_health_check_status - Userli API health (1=healthy, 0=unhealthy), checked in background every 15s, independent of /ready

Policy/Rate Limiting Metrics:

  • userli_postfix_adapter_policy_active_connections - Active policy connections gauge
  • userli_postfix_adapter_policy_requests_total - Total policy request counter
  • userli_postfix_adapter_policy_request_duration_seconds - Policy request duration histogram
  • userli_postfix_adapter_quota_exceeded_total - Total messages rejected due to quota
  • userli_postfix_adapter_sending_denied_total - Total recipients rejected because the sender may not send to them
  • userli_postfix_adapter_quota_checks_total - Total quota checks performed
  • userli_postfix_adapter_ratelimit_backend_errors_total - Total Redis errors hit by the rate limiter, labelled by operation

SASL Authentication Metrics:

  • userli_postfix_adapter_sasl_active_connections - Active SASL connections gauge
  • userli_postfix_adapter_sasl_auth_total - Total SASL authentication attempts (by mechanism and result)
  • userli_postfix_adapter_sasl_auth_duration_seconds - SASL authentication duration histogram
  • userli_postfix_adapter_sasl_connection_pool_full_total - SASL connection pool rejections

All metrics include relevant labels (handler, status, endpoint, etc.).

Health Endpoints

  • /health - Liveness probe (always returns 200 OK)
  • /ready - Readiness probe (checks Userli API connectivity)

Example Kubernetes configuration:

livenessProbe:
  httpGet:
    path: /health
    port: 10002
readinessProbe:
  httpGet:
    path: /ready
    port: 10002

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

3 watching

Forks

Releases

Packages

Used by

Contributors

Languages