Initial commit

This commit is contained in:
peio
2026-01-18 13:53:35 +00:00
commit 166db6cbf9
18 changed files with 1011 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
# Binaries
/bin/
/dist/
*.exe
# Go build cache (jamais versionné)
pkg/
vendor/ # sauf si tu utilises volontairement vendoring
# IDE
.idea/
.vscode/
# Local config files
config.yaml

22
Makefile Normal file
View File

@@ -0,0 +1,22 @@
BINARY := kc-policy
BIN_DIR := bin
.PHONY: build run test tidy clean install
build:
go build -o $(BIN_DIR)/$(BINARY) ./cmd/$(BINARY)
run:
go run ./cmd/$(BINARY)
test:
go test ./...
tidy:
go mod tidy
clean:
rm -f $(BIN_DIR)/$(BINARY)
install: build
sudo install -m 0755 $(BIN_DIR)/$(BINARY) /usr/local/sbin/$(BINARY)

85
README.md Normal file
View File

@@ -0,0 +1,85 @@
# kc-policy
Postfix policy + socketmap daemon that validates recipients/senders against Keycloak and serves a local aliases SQLite database.
## What it does
- **Policy service** (Postfix policy delegation):
- `RCPT` stage: accepts if the recipient exists in Keycloak (primary email) or as a local alias in SQLite.
- `MAIL` stage (authenticated submissions): accepts only if the sender is the users primary Keycloak email or one of their aliases.
- **Socketmap service**: exposes an `alias` map to Postfix, rewriting alias -> `username@domain`.
## Project layout
- `cmd/kc-policy/` main package entrypoint
- `internal/kcpolicy/` daemon sources
- `go.mod` / `go.sum` Go module files
- `configs/config.yaml.sample` sample config to copy to `/etc/kc-policy/config.yaml`
- `configs/openrc-kc-policy` OpenRC service file
- `db-init.sql` SQLite schema (also auto-created by the app)
- `aliasctl.py` CLI helper to manage aliases
## Build the binary
From the repository root:
```bash
make build
```
To install system-wide:
```bash
make install
```
To run locally:
```bash
make run
```
## Configuration
Copy the sample config and edit it:
```bash
install -d -m 0750 -o root -g postfix /etc/kc-policy
cp configs/config.yaml.sample /etc/kc-policy/config.yaml
```
Key settings:
- `keycloak.*` must point to your Keycloak realm and a client with permission to query users.
- `policy.domain` is the email domain enforced by the policy.
- `sqlite.path` is the aliases database path.
- `sockets.*` must be under the Postfix chroot (usually `/var/spool/postfix`).
## Alias database
You can manage aliases using the helper script:
```bash
./aliasctl.py --db /var/lib/kc-policy/aliases.db add alias@example.com username
./aliasctl.py --db /var/lib/kc-policy/aliases.db list
```
The script creates the schema automatically if missing.
## Postfix integration (example)
Policy service (smtpd_recipient_restrictions):
```
check_policy_service unix:private/kc-policy
```
Socketmap (virtual_alias_maps):
```
socketmap:unix:private/kc-socketmap:alias
```
## OpenRC
Use the provided service file:
```bash
cp configs/openrc-kc-policy /etc/init.d/kc-policy
rc-update add kc-policy default
rc-service kc-policy start
```
## Notes
- If Keycloak is unavailable, the policy returns `451` by default (configurable via `policy.keycloak_failure_mode`).
- The policy caches lookups for `policy.cache_ttl_seconds`.

96
aliasctl.py Executable file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
import argparse
import sqlite3
import time
import sys
DEFAULT_DB = "/var/lib/kc-policy/aliases.db"
SCHEMA = """
CREATE TABLE IF NOT EXISTS aliases (
alias_email TEXT PRIMARY KEY,
username TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE INDEX IF NOT EXISTS idx_aliases_username ON aliases(username);
"""
def connect(db_path: str):
con = sqlite3.connect(db_path)
con.execute("PRAGMA journal_mode=WAL;")
con.execute("PRAGMA synchronous=NORMAL;")
con.executescript(SCHEMA)
return con
def norm_email(s: str) -> str:
return s.strip().lower()
def cmd_add(con, alias_email, username):
alias_email = norm_email(alias_email)
username = username.strip()
now = int(time.time())
con.execute(
"INSERT INTO aliases(alias_email, username, enabled, updated_at) VALUES(?,?,1,?) "
"ON CONFLICT(alias_email) DO UPDATE SET username=excluded.username, enabled=1, updated_at=excluded.updated_at",
(alias_email, username, now),
)
con.commit()
def cmd_del(con, alias_email):
alias_email = norm_email(alias_email)
con.execute("DELETE FROM aliases WHERE alias_email=?", (alias_email,))
con.commit()
def cmd_disable(con, alias_email):
alias_email = norm_email(alias_email)
con.execute("UPDATE aliases SET enabled=0, updated_at=? WHERE alias_email=?", (int(time.time()), alias_email))
con.commit()
def cmd_list(con, username=None):
if username:
rows = con.execute(
"SELECT alias_email, username, enabled, updated_at FROM aliases WHERE username=? ORDER BY alias_email",
(username,),
).fetchall()
else:
rows = con.execute(
"SELECT alias_email, username, enabled, updated_at FROM aliases ORDER BY username, alias_email"
).fetchall()
for a,u,en,ts in rows:
print(f"{a}\t{u}\t{'enabled' if en else 'disabled'}\t{ts}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--db", default=DEFAULT_DB)
sub = ap.add_subparsers(dest="cmd", required=True)
p_add = sub.add_parser("add")
p_add.add_argument("alias_email")
p_add.add_argument("username")
p_del = sub.add_parser("del")
p_del.add_argument("alias_email")
p_dis = sub.add_parser("disable")
p_dis.add_argument("alias_email")
p_ls = sub.add_parser("list")
p_ls.add_argument("--user", default=None)
args = ap.parse_args()
con = connect(args.db)
try:
if args.cmd == "add":
cmd_add(con, args.alias_email, args.username)
elif args.cmd == "del":
cmd_del(con, args.alias_email)
elif args.cmd == "disable":
cmd_disable(con, args.alias_email)
elif args.cmd == "list":
cmd_list(con, args.user)
finally:
con.close()
if __name__ == "__main__":
sys.exit(main())

60
cmd/kc-policy/main.go Normal file
View File

@@ -0,0 +1,60 @@
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"time"
"kc-policy/internal/kcpolicy"
)
func main() {
cfgPath := "/etc/kc-policy/config.yaml"
if len(os.Args) >= 2 {
cfgPath = os.Args[1]
}
cfg, err := kcpolicy.LoadConfig(cfgPath)
if err != nil {
log.Fatalf("config: %v", err)
}
db, err := kcpolicy.OpenAliasDB(cfg.SQLite.Path)
if err != nil {
log.Fatalf("sqlite: %v", err)
}
defer db.Close()
kc := kcpolicy.NewKeycloak(cfg)
cache := kcpolicy.NewCache(time.Duration(cfg.Policy.CacheTTLSeconds) * time.Second)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Start socketmap server
go func() {
if err := kcpolicy.RunSocketmap(ctx, cfg, db); err != nil {
log.Fatalf("socketmap: %v", err)
}
}()
// Start policy server
go func() {
if err := kcpolicy.RunPolicy(ctx, cfg, db, kc, cache); err != nil {
log.Fatalf("policy: %v", err)
}
}()
log.Printf("kc-policy started")
// Handle signals
ch := make(chan os.Signal, 2)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
<-ch
log.Printf("shutdown")
cancel()
time.Sleep(300 * time.Millisecond)
}

View File

@@ -0,0 +1,26 @@
keycloak:
base_url: "<Keycloak URL>"
realm: "<Keycloak Realm>"
client_id: "<Client ID>"
client_secret: "<Client Secret>"
# admin API is derived: {base_url}/admin/realms/{realm}
sqlite:
path: "/var/lib/kc-policy/aliases.db"
policy:
domain: "<EMail domain-name>"
# cache for keycloak lookups (username->email, email->exists)
cache_ttl_seconds: 120
# if keycloak is down:
# - "tempfail": return 451 (recommended)
# - "dunno": fail-open
keycloak_failure_mode: "tempfail"
sockets:
# These paths must be inside postfix chroot (/var/spool/postfix)
policy_socket: "/var/spool/postfix/private/kc-policy"
socketmap_socket: "/var/spool/postfix/private/kc-socketmap"
socket_owner_user: "postfix"
socket_owner_group: "postfix"
socket_mode: "0660"

23
configs/openrc-kc-policy Normal file
View File

@@ -0,0 +1,23 @@
#!/sbin/openrc-run
name="kc-policy"
command="/usr/local/sbin/kc-policy"
command_args="/etc/kc-policy/config.yaml"
command_background="yes"
pidfile="/run/kc-policy.pid"
depend() {
need net
after postfix
}
start_pre() {
checkpath -d -m 0750 -o root:postfix /etc/kc-policy
checkpath -d -m 0750 -o root:postfix /var/lib/kc-policy
checkpath -d -m 0755 -o root:root /usr/local/sbin
# sockets dir already exists
}
stop_post() {
rm -f /var/spool/postfix/private/kc-policy /var/spool/postfix/private/kc-socketmap
}

21
configs/postfix-main.cf Normal file
View File

@@ -0,0 +1,21 @@
#
# Configuration to add to /etc/postfix/main.cf
#
# Domaine local “virtuel”
virtual_mailbox_domains = static:<EMail domain-name>
# Delivery to dovecot LMTP
virtual_transport = lmtp:unix:private/dovecot-lmtp
# Dynamic aliases via socketmap
virtual_alias_maps = socketmap:unix:private/kc-socketmap:alias
# Policy (RCPT existence + sender policy on 587 via master.cf)
smtpd_recipient_restrictions =
reject_non_fqdn_recipient,
reject_unknown_recipient_domain,
permit_sasl_authenticated,
reject_unauth_destination,
check_policy_service unix:private/kc-policy,
permit

View File

@@ -0,0 +1,8 @@
#
# Configuration to add to /etc/postfix/master.cf
#
-o smtpd_sender_restrictions=check_policy_service unix:private/kc-policy
# You can remove `reject_senders_login_mismaych` + `sender_login_maps`
# as this kc-policy will handle it.

8
db-init.sql Normal file
View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS aliases (
alias_email TEXT PRIMARY KEY,
username TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE INDEX IF NOT EXISTS idx_aliases_username ON aliases(username);

24
go.mod Normal file
View File

@@ -0,0 +1,24 @@
module kc-policy
go 1.22
require (
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.32.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.22.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

32
go.sum Normal file
View File

@@ -0,0 +1,32 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/sqlite v1.32.0 h1:6BM4uGza7bWypsw4fdLRsLxut6bHe4c58VeqjRgST8s=
modernc.org/sqlite v1.32.0/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -0,0 +1,62 @@
package kcpolicy
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Keycloak struct {
BaseURL string `yaml:"base_url"`
Realm string `yaml:"realm"`
ClientID string `yaml:"client_id"`
ClientSecret string `yaml:"client_secret"`
} `yaml:"keycloak"`
SQLite struct {
Path string `yaml:"path"`
} `yaml:"sqlite"`
Policy struct {
Domain string `yaml:"domain"`
CacheTTLSeconds int `yaml:"cache_ttl_seconds"`
KeycloakFailureMode string `yaml:"keycloak_failure_mode"` // "tempfail" or "dunno"
} `yaml:"policy"`
Sockets struct {
PolicySocket string `yaml:"policy_socket"`
SocketmapSocket string `yaml:"socketmap_socket"`
SocketOwnerUser string `yaml:"socket_owner_user"`
SocketOwnerGroup string `yaml:"socket_owner_group"`
SocketMode string `yaml:"socket_mode"`
} `yaml:"sockets"`
}
func LoadConfig(path string) (*Config, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(b, &cfg); err != nil {
return nil, err
}
if cfg.Keycloak.BaseURL == "" || cfg.Keycloak.Realm == "" {
return nil, fmt.Errorf("missing keycloak.base_url or keycloak.realm")
}
if cfg.SQLite.Path == "" {
return nil, fmt.Errorf("missing sqlite.path")
}
if cfg.Policy.Domain == "" {
return nil, fmt.Errorf("missing policy.domain")
}
if cfg.Policy.CacheTTLSeconds <= 0 {
cfg.Policy.CacheTTLSeconds = 120
}
if cfg.Policy.KeycloakFailureMode == "" {
cfg.Policy.KeycloakFailureMode = "tempfail"
}
return &cfg, nil
}

View File

@@ -0,0 +1,153 @@
package kcpolicy
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Keycloak struct {
cfg *Config
hc *http.Client
}
func NewKeycloak(cfg *Config) *Keycloak {
return &Keycloak{
cfg: cfg,
hc: &http.Client{Timeout: 5 * time.Second},
}
}
type tokenResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
func (k *Keycloak) token(ctx context.Context) (string, error) {
form := url.Values{}
form.Set("grant_type", "client_credentials")
form.Set("client_id", k.cfg.Keycloak.ClientID)
form.Set("client_secret", k.cfg.Keycloak.ClientSecret)
u := strings.TrimRight(k.cfg.Keycloak.BaseURL, "/") +
"/realms/" + url.PathEscape(k.cfg.Keycloak.Realm) +
"/protocol/openid-connect/token"
req, _ := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := k.hc.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", fmt.Errorf("token http %d: %s", resp.StatusCode, string(b))
}
var tr tokenResp
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return "", err
}
if tr.AccessToken == "" {
return "", fmt.Errorf("empty access_token")
}
return tr.AccessToken, nil
}
type kcUser struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Enabled bool `json:"enabled"`
Attrs map[string][]string `json:"attributes"`
}
func (k *Keycloak) adminGet(ctx context.Context, bearer, path string, q url.Values) ([]kcUser, error) {
base := strings.TrimRight(k.cfg.Keycloak.BaseURL, "/") +
"/admin/realms/" + url.PathEscape(k.cfg.Keycloak.Realm) + path
u := base
if q != nil {
u += "?" + q.Encode()
}
req, _ := http.NewRequestWithContext(ctx, "GET", u, nil)
req.Header.Set("Authorization", "Bearer "+bearer)
resp, err := k.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("admin http %d: %s", resp.StatusCode, string(b))
}
var users []kcUser
if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
return nil, err
}
return users, nil
}
// Find primary email by username (exact if supported)
func (k *Keycloak) EmailByUsername(ctx context.Context, username string) (string, bool, error) {
bearer, err := k.token(ctx)
if err != nil {
return "", false, err
}
q := url.Values{}
q.Set("username", username)
q.Set("exact", "true")
users, err := k.adminGet(ctx, bearer, "/users", q)
if err != nil {
// fallback: search
q2 := url.Values{}
q2.Set("search", username)
users, err = k.adminGet(ctx, bearer, "/users", q2)
if err != nil {
return "", false, err
}
}
for _, u := range users {
if strings.EqualFold(u.Username, username) && u.Enabled && u.Email != "" {
return strings.ToLower(u.Email), true, nil
}
}
return "", false, nil
}
// Check if an email exists as primary user email
func (k *Keycloak) EmailExists(ctx context.Context, email string) (bool, error) {
bearer, err := k.token(ctx)
if err != nil {
return false, err
}
q := url.Values{}
q.Set("email", email)
q.Set("exact", "true")
users, err := k.adminGet(ctx, bearer, "/users", q)
if err != nil {
// fallback: search
q2 := url.Values{}
q2.Set("search", email)
users, err = k.adminGet(ctx, bearer, "/users", q2)
if err != nil {
return false, err
}
}
for _, u := range users {
if u.Enabled && strings.EqualFold(u.Email, email) {
return true, nil
}
}
return false, nil
}

198
internal/kcpolicy/policy.go Normal file
View File

@@ -0,0 +1,198 @@
package kcpolicy
import (
"bufio"
"context"
"fmt"
"log"
"net"
"os"
"strings"
"time"
)
type Cache struct {
ttl time.Duration
m map[string]cacheItem
}
type cacheItem struct {
val string
expires time.Time
ok bool
}
func NewCache(ttl time.Duration) *Cache {
return &Cache{ttl: ttl, m: make(map[string]cacheItem)}
}
func (c *Cache) Get(key string) (string, bool, bool) {
it, ok := c.m[key]
if !ok || time.Now().After(it.expires) {
return "", false, false
}
return it.val, it.ok, true
}
func (c *Cache) Put(key, val string, ok bool) {
c.m[key] = cacheItem{val: val, ok: ok, expires: time.Now().Add(c.ttl)}
}
func RunPolicy(ctx context.Context, cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache) error {
sock := cfg.Sockets.PolicySocket
_ = os.Remove(sock)
l, err := net.Listen("unix", sock)
if err != nil {
return err
}
defer l.Close()
if err := ChownChmodSocket(sock, cfg); err != nil {
return err
}
for {
conn, err := l.Accept()
if err != nil {
select {
case <-ctx.Done():
return nil
default:
return err
}
}
go handlePolicyConn(conn, cfg, db, kc, cache)
}
}
func handlePolicyConn(conn net.Conn, cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache) {
defer conn.Close()
r := bufio.NewReader(conn)
req := map[string]string{}
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
if i := strings.IndexByte(line, '='); i > 0 {
req[line[:i]] = line[i+1:]
}
}
// Decide based on protocol_state
state := req["protocol_state"] // e.g. RCPT, MAIL
saslUser := req["sasl_username"]
sender := strings.ToLower(req["sender"])
rcpt := strings.ToLower(req["recipient"])
action := "DUNNO"
switch state {
case "RCPT":
action = policyRCPT(cfg, db, kc, cache, rcpt)
case "MAIL":
// On MAIL stage we can validate sender if authenticated (submission)
if saslUser != "" && sender != "" {
action = policyMAIL(cfg, db, kc, cache, saslUser, sender)
}
default:
action = "DUNNO"
}
fmt.Fprintf(conn, "action=%s\n\n", action)
}
func policyRCPT(cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache, rcpt string) string {
if rcpt == "" {
return "DUNNO"
}
// Only enforce for our domain
if !strings.HasSuffix(rcpt, "@"+strings.ToLower(cfg.Policy.Domain)) {
return "DUNNO"
}
// 1) exists in keycloak primary email?
key := "email_exists:" + rcpt
if _, ok, hit := cache.Get(key); hit {
if ok {
return "DUNNO"
}
} else {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
exists, err := kc.EmailExists(ctx, rcpt)
if err != nil {
if cfg.Policy.KeycloakFailureMode == "dunno" {
return "DUNNO"
}
return "451 4.3.0 Temporary authentication/lookup failure"
}
cache.Put(key, "", exists)
if exists {
return "DUNNO"
}
}
// 2) exists as sqlite alias?
_, ok, err := db.AliasOwner(rcpt)
if err != nil {
log.Printf("sqlite rcpt lookup error: %v", err)
return "451 4.3.0 Temporary internal error"
}
if ok {
return "DUNNO"
}
return "550 5.1.1 No such user"
}
func policyMAIL(cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache, saslUser, sender string) string {
// Allow empty sender (bounce)
if sender == "" || sender == "<>" {
return "DUNNO"
}
// Only enforce our domain senders (optional)
if !strings.HasSuffix(sender, "@"+strings.ToLower(cfg.Policy.Domain)) {
return "DUNNO"
}
// primary email from keycloak (cached)
key := "email_by_username:" + strings.ToLower(saslUser)
email, ok, hit := cache.Get(key)
if !hit {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
e, exists, err := kc.EmailByUsername(ctx, saslUser)
if err != nil {
if cfg.Policy.KeycloakFailureMode == "dunno" {
return "DUNNO"
}
return "451 4.3.0 Temporary authentication/lookup failure"
}
cache.Put(key, e, exists)
email, ok = e, exists
}
// 1) sender == primary email
if ok && strings.EqualFold(sender, email) {
return "DUNNO"
}
// 2) sender is sqlite alias belonging to this user
belongs, err := db.AliasBelongsTo(sender, saslUser)
if err != nil {
log.Printf("sqlite sender lookup error: %v", err)
return "451 4.3.0 Temporary internal error"
}
if belongs {
return "DUNNO"
}
return "553 5.7.1 Sender not owned by authenticated user"
}

View File

@@ -0,0 +1,31 @@
package kcpolicy
import (
"fmt"
"os"
"os/user"
"strconv"
)
func ChownChmodSocket(path string, cfg *Config) error {
u, err := user.Lookup(cfg.Sockets.SocketOwnerUser)
if err != nil {
return err
}
g, err := user.LookupGroup(cfg.Sockets.SocketOwnerGroup)
if err != nil {
return err
}
uid, _ := strconv.Atoi(u.Uid)
gid, _ := strconv.Atoi(g.Gid)
if err := os.Chown(path, uid, gid); err != nil {
return err
}
mode, err := strconv.ParseUint(cfg.Sockets.SocketMode, 8, 32)
if err != nil {
return fmt.Errorf("bad socket_mode: %w", err)
}
return os.Chmod(path, os.FileMode(mode))
}

View File

@@ -0,0 +1,85 @@
package kcpolicy
import (
"bufio"
"context"
"fmt"
"net"
"os"
"strings"
)
func RunSocketmap(ctx context.Context, cfg *Config, db *AliasDB) error {
sock := cfg.Sockets.SocketmapSocket
_ = os.Remove(sock)
l, err := net.Listen("unix", sock)
if err != nil {
return err
}
defer l.Close()
if err := ChownChmodSocket(sock, cfg); err != nil {
return err
}
for {
conn, err := l.Accept()
if err != nil {
select {
case <-ctx.Done():
return nil
default:
return err
}
}
go handleSocketmapConn(conn, cfg, db)
}
}
// Socketmap protocol: "mapname key\n" -> "OK value\n" or "NOTFOUND\n" or "TEMP\n"
func handleSocketmapConn(conn net.Conn, cfg *Config, db *AliasDB) {
defer conn.Close()
r := bufio.NewReader(conn)
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, " ", 2)
if len(parts) != 2 {
fmt.Fprint(conn, "TEMP\n")
continue
}
mapName := parts[0]
key := strings.ToLower(strings.TrimSpace(parts[1]))
if mapName != "alias" {
fmt.Fprint(conn, "NOTFOUND\n")
continue
}
// Only handle our domain
if !strings.HasSuffix(key, "@"+strings.ToLower(cfg.Policy.Domain)) {
fmt.Fprint(conn, "NOTFOUND\n")
continue
}
username, ok, err := db.AliasOwner(key)
if err != nil {
fmt.Fprint(conn, "TEMP\n")
continue
}
if !ok {
fmt.Fprint(conn, "NOTFOUND\n")
continue
}
// rewrite alias -> primary rcpt (username@domain)
fmt.Fprintf(conn, "OK %s@%s\n", username, strings.ToLower(cfg.Policy.Domain))
}
}

View File

@@ -0,0 +1,62 @@
package kcpolicy
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
type AliasDB struct{ DB *sql.DB }
func OpenAliasDB(path string) (*AliasDB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS aliases (
alias_email TEXT PRIMARY KEY,
username TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE INDEX IF NOT EXISTS idx_aliases_username ON aliases(username);
`); err != nil {
_ = db.Close()
return nil, fmt.Errorf("init schema: %w", err)
}
return &AliasDB{DB: db}, nil
}
func (a *AliasDB) Close() error { return a.DB.Close() }
// Returns username owning alias, ok
func (a *AliasDB) AliasOwner(aliasEmail string) (string, bool, error) {
var username string
var enabled int
err := a.DB.QueryRow(`SELECT username, enabled FROM aliases WHERE alias_email=?`, aliasEmail).Scan(&username, &enabled)
if err == sql.ErrNoRows {
return "", false, nil
}
if err != nil {
return "", false, err
}
if enabled != 1 {
return "", false, nil
}
return username, true, nil
}
// Returns true if alias belongs to username
func (a *AliasDB) AliasBelongsTo(aliasEmail, username string) (bool, error) {
var enabled int
err := a.DB.QueryRow(`SELECT enabled FROM aliases WHERE alias_email=? AND username=?`, aliasEmail, username).Scan(&enabled)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return enabled == 1, nil
}