Skip to content

Commit 5f4daf5

Browse files
authored
Merge pull request #76 from systemli/feature/dovecot-sasl-auth
✨ Add Dovecot SASL authentication server
2 parents da49312 + e92e35f commit 5f4daf5

13 files changed

Lines changed: 1184 additions & 26 deletions

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ ALIAS_LISTEN_ADDR=":10001"
55
DOMAIN_LISTEN_ADDR=":10002"
66
MAILBOX_LISTEN_ADDR=":10003"
77
SENDERS_LISTEN_ADDR=":10004"
8+
SASL_LISTEN_ADDR=":10006"
89
METRICS_LISTEN_ADDR=":10005"
910
RATE_LIMIT_MESSAGE="Rate limit exceeded, please try again later"
1011
REDIS_URL="redis://redis:6379/0"

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
This is a postfix socketmap adapter for the [userli](https://github.com/systemli/userli) project.
66
It implements the [socketmap protocol](https://www.postfix.org/socketmap_table.5.html) to provide dynamic lookups for aliases, domains, mailboxes, and senders.
7+
It also provides a Dovecot SASL authentication server, allowing Postfix to authenticate SMTP clients against the Userli API without requiring Dovecot.
78

89
## Configuration
910

@@ -14,6 +15,7 @@ The adapter is configured via environment variables:
1415
- `POSTFIX_RECIPIENT_DELIMITER`: The recipient delimiter used in Postfix (e.g., `+`). Default: empty.
1516
- `SOCKETMAP_LISTEN_ADDR`: The address to listen on for socketmap requests. Default: `:10001`.
1617
- `POLICY_LISTEN_ADDR`: The address to listen on for policy requests (rate limiting). Default: `:10003`.
18+
- `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).
1719
- `METRICS_LISTEN_ADDR`: The address to listen on for metrics. Default: `:10002`.
1820
- `RATE_LIMIT_MESSAGE`: The rejection message returned when a sender exceeds their quota. Default: `Rate limit exceeded, please try again later`.
1921
- `REDIS_URL`: Connection URL for Redis (required). Format follows [`redis.ParseURL`](https://pkg.go.dev/github.com/redis/go-redis/v9#ParseURL), e.g. `redis://[user:password@]host:port/db`. Rate-limit state is stored in Redis so it survives restarts.
@@ -52,6 +54,28 @@ Where `0` means unlimited. If the API is unreachable, messages are allowed (fail
5254

5355
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.
5456

57+
### SASL Authentication (Dovecot SASL)
58+
59+
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.
60+
61+
**Important:** SASL authentication is fail-closed. If the API is unreachable, authentication is rejected.
62+
63+
Configure in Postfix `main.cf` (TCP):
64+
65+
```text
66+
smtpd_sasl_type = dovecot
67+
smtpd_sasl_path = inet:localhost:10006
68+
smtpd_sasl_auth_enable = yes
69+
```
70+
71+
Or using a UNIX socket (set `SASL_LISTEN_ADDR=/var/spool/postfix/private/auth`):
72+
73+
```text
74+
smtpd_sasl_type = dovecot
75+
smtpd_sasl_path = private/auth
76+
smtpd_sasl_auth_enable = yes
77+
```
78+
5579
## Docker
5680

5781
You can run the adapter using Docker.
@@ -178,6 +202,13 @@ The adapter exposes Prometheus metrics on `/metrics` (port 10002) and provides h
178202
- `userli_postfix_adapter_quota_checks_total` - Total quota checks performed
179203
- `userli_postfix_adapter_ratelimit_backend_errors_total` - Total Redis errors hit by the rate limiter, labelled by `operation`
180204

205+
**SASL Authentication Metrics:**
206+
207+
- `userli_postfix_adapter_sasl_active_connections` - Active SASL connections gauge
208+
- `userli_postfix_adapter_sasl_auth_total` - Total SASL authentication attempts (by mechanism and result)
209+
- `userli_postfix_adapter_sasl_auth_duration_seconds` - SASL authentication duration histogram
210+
- `userli_postfix_adapter_sasl_connection_pool_full_total` - SASL connection pool rejections
211+
181212
All metrics include relevant labels (handler, status, endpoint, etc.).
182213

183214
### Health Endpoints

config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ type Config struct {
4040
// LookupCacheTTL is the TTL for cached successful lookup responses.
4141
// Zero disables caching entirely.
4242
LookupCacheTTL time.Duration
43+
44+
// SASLListenAddr is the address to listen for Dovecot SASL auth requests.
45+
// Supports TCP (e.g. ":10004") or UNIX socket (e.g. "/var/spool/postfix/private/auth").
46+
// Leave empty to disable the SASL auth server.
47+
SASLListenAddr string
4348
}
4449

4550
// NewConfig creates a new Config with default values.
@@ -93,6 +98,8 @@ func NewConfig() (*Config, error) {
9398
lookupCacheTTL = parsed
9499
}
95100

101+
saslListenAddr := os.Getenv("SASL_LISTEN_ADDR")
102+
96103
return &Config{
97104
UserliBaseURL: userliBaseURL,
98105
UserliToken: userliToken,
@@ -103,5 +110,6 @@ func NewConfig() (*Config, error) {
103110
RateLimitMessage: rateLimitMessage,
104111
RedisURL: redisURL,
105112
LookupCacheTTL: lookupCacheTTL,
113+
SASLListenAddr: saslListenAddr,
106114
}, nil
107115
}

docker-compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ services:
88
- "10001:10001"
99
- "10002:10002"
1010
- "10003:10003"
11+
- "10006:10006"
1112
env_file:
1213
- .env
1314
depends_on:
@@ -38,6 +39,9 @@ services:
3839
POSTFIX_virtual_mailbox_maps: "socketmap:inet:adapter:10001:mailbox"
3940
POSTFIX_smtpd_sender_login_maps: "socketmap:inet:adapter:10001:senders"
4041
POSTFIX_smtpd_end_of_data_restrictions: "check_policy_service inet:adapter:10003"
42+
POSTFIX_smtpd_sasl_type: "dovecot"
43+
POSTFIX_smtpd_sasl_path: "inet:adapter:10006"
44+
POSTFIX_smtpd_sasl_auth_enable: "yes"
4145
networks:
4246
- userli
4347

main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ func main() {
7676
wg.Add(1)
7777
go StartPolicyServer(ctx, &wg, config.PolicyListenAddr, policyServer)
7878

79+
if config.SASLListenAddr != "" {
80+
saslServer := NewSASLServer(userli, logger.Named("sasl"))
81+
wg.Add(1)
82+
go StartSASLServer(ctx, &wg, config.SASLListenAddr, saslServer)
83+
}
84+
7985
wg.Wait()
8086
logger.Info("All servers stopped")
8187
}

mock_UserliService.go

Lines changed: 152 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

policy_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ func (m *MockUserliServiceForPolicy) GetQuota(_ context.Context, _ string) (*Quo
4242
return m.quota, nil
4343
}
4444

45+
func (m *MockUserliServiceForPolicy) Authenticate(_ context.Context, _ string, _ string) (bool, string, error) {
46+
return false, "", nil
47+
}
48+
4549
func TestPolicyServer_ReadRequest(t *testing.T) {
4650
input := `request=smtpd_access_policy
4751
protocol_state=END-OF-MESSAGE

prometheus.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,28 @@ var (
9999
Name: "userli_postfix_adapter_lookup_cache_total",
100100
Help: "Total number of lookup cache events, by handler and result (hit, miss, error)",
101101
}, []string{"handler", "result"})
102+
103+
// SASL server metrics
104+
saslActiveConnections = prometheus.NewGauge(prometheus.GaugeOpts{
105+
Name: "userli_postfix_adapter_sasl_active_connections",
106+
Help: "Number of currently active SASL auth connections",
107+
})
108+
109+
saslAuthTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
110+
Name: "userli_postfix_adapter_sasl_auth_total",
111+
Help: "Total number of SASL authentication attempts",
112+
}, []string{"mechanism", "result"})
113+
114+
saslAuthDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
115+
Name: "userli_postfix_adapter_sasl_auth_duration_seconds",
116+
Help: "Duration of SASL authentication requests",
117+
Buckets: prometheus.ExponentialBuckets(0.001, 2, 10),
118+
}, []string{"mechanism", "result"})
119+
120+
saslConnectionPoolFullTotal = prometheus.NewCounter(prometheus.CounterOpts{
121+
Name: "userli_postfix_adapter_sasl_connection_pool_full_total",
122+
Help: "Total number of SASL connections rejected because the connection pool is full",
123+
})
102124
)
103125

104126
// StartMetricsServer starts a new HTTP server for prometheus metrics and health checks.
@@ -123,6 +145,10 @@ func StartMetricsServer(ctx context.Context, listenAddr string, userliClient Use
123145
policyConnectionPoolFullTotal,
124146
rateLimitBackendErrors,
125147
lookupCacheTotal,
148+
saslActiveConnections,
149+
saslAuthTotal,
150+
saslAuthDuration,
151+
saslConnectionPoolFullTotal,
126152
)
127153

128154
mux := http.NewServeMux()

0 commit comments

Comments
 (0)