commit 70eadb2eb06103189486d66591a854c0e887d263 Author: peio Date: Sun Jan 18 13:53:35 2026 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a539470 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +config.yaml \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a6e1b30 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# 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 user’s primary Keycloak email or one of their aliases. +- **Socketmap service**: exposes an `alias` map to Postfix, rewriting alias -> `username@domain`. + +## Project layout +- `src/` – Go module and daemon sources +- `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 +cd src +go mod download +go build -o ../kc-policy . +``` + +To install system-wide: + +```bash +install -m 0755 ../kc-policy /usr/local/sbin/kc-policy +``` + +## 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`. diff --git a/aliasctl.py b/aliasctl.py new file mode 100755 index 0000000..8ee8f02 --- /dev/null +++ b/aliasctl.py @@ -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()) diff --git a/configs/config.yaml.sample b/configs/config.yaml.sample new file mode 100644 index 0000000..71d2e2d --- /dev/null +++ b/configs/config.yaml.sample @@ -0,0 +1,26 @@ +keycloak: + base_url: "" + realm: "" + client_id: "" + client_secret: "" + # admin API is derived: {base_url}/admin/realms/{realm} + +sqlite: + path: "/var/lib/kc-policy/aliases.db" + +policy: + domain: "" + # 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" diff --git a/configs/openrc-kc-policy b/configs/openrc-kc-policy new file mode 100644 index 0000000..19280ce --- /dev/null +++ b/configs/openrc-kc-policy @@ -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 +} \ No newline at end of file diff --git a/configs/postfix-main.cf b/configs/postfix-main.cf new file mode 100644 index 0000000..fd1c727 --- /dev/null +++ b/configs/postfix-main.cf @@ -0,0 +1,21 @@ +# +# Configuration to add to /etc/postfix/main.cf +# + +# Domaine local “virtuel” +virtual_mailbox_domains = static: + +# 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 diff --git a/configs/postfix-master.cf b/configs/postfix-master.cf new file mode 100644 index 0000000..26b5c2b --- /dev/null +++ b/configs/postfix-master.cf @@ -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. \ No newline at end of file diff --git a/db-init.sql b/db-init.sql new file mode 100644 index 0000000..c952c6b --- /dev/null +++ b/db-init.sql @@ -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); diff --git a/src/config.go b/src/config.go new file mode 100644 index 0000000..0a879c2 --- /dev/null +++ b/src/config.go @@ -0,0 +1,62 @@ +package main + +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 +} diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..894231f --- /dev/null +++ b/src/go.mod @@ -0,0 +1,8 @@ +module kc-policy + +go 1.22 + +require ( + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.32.0 +) diff --git a/src/keycloak.go b/src/keycloak.go new file mode 100644 index 0000000..3c2a712 --- /dev/null +++ b/src/keycloak.go @@ -0,0 +1,153 @@ +package main + +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 +} diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..0b1c884 --- /dev/null +++ b/src/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + "time" +) + +func main() { + cfgPath := "/etc/kc-policy/config.yaml" + if len(os.Args) >= 2 { + cfgPath = os.Args[1] + } + + cfg, err := LoadConfig(cfgPath) + if err != nil { + log.Fatalf("config: %v", err) + } + + db, err := OpenAliasDB(cfg.SQLite.Path) + if err != nil { + log.Fatalf("sqlite: %v", err) + } + defer db.Close() + + kc := NewKeycloak(cfg) + cache := NewCache(time.Duration(cfg.Policy.CacheTTLSeconds) * time.Second) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start socketmap server + go func() { + if err := RunSocketmap(ctx, cfg, db); err != nil { + log.Fatalf("socketmap: %v", err) + } + }() + + // Start policy server + go func() { + if err := 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) +} diff --git a/src/policy.go b/src/policy.go new file mode 100644 index 0000000..0debf01 --- /dev/null +++ b/src/policy.go @@ -0,0 +1,198 @@ +package main + +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" +} diff --git a/src/socket_perms.go b/src/socket_perms.go new file mode 100644 index 0000000..320c4cb --- /dev/null +++ b/src/socket_perms.go @@ -0,0 +1,31 @@ +package main + +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)) +} diff --git a/src/socketmap.go b/src/socketmap.go new file mode 100644 index 0000000..b8841cb --- /dev/null +++ b/src/socketmap.go @@ -0,0 +1,85 @@ +package main + +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)) + } +} diff --git a/src/sqlite.go b/src/sqlite.go new file mode 100644 index 0000000..aeb3522 --- /dev/null +++ b/src/sqlite.go @@ -0,0 +1,62 @@ +package main + +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 +}