Initial commit
This commit is contained in:
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal 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
22
Makefile
Normal 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
85
README.md
Normal 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 user’s 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
96
aliasctl.py
Executable 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
60
cmd/kc-policy/main.go
Normal 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)
|
||||
}
|
||||
26
configs/config.yaml.sample
Normal file
26
configs/config.yaml.sample
Normal 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
23
configs/openrc-kc-policy
Normal 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
21
configs/postfix-main.cf
Normal 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
|
||||
8
configs/postfix-master.cf
Normal file
8
configs/postfix-master.cf
Normal 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
8
db-init.sql
Normal 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
24
go.mod
Normal 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
32
go.sum
Normal 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=
|
||||
62
internal/kcpolicy/config.go
Normal file
62
internal/kcpolicy/config.go
Normal 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
|
||||
}
|
||||
153
internal/kcpolicy/keycloak.go
Normal file
153
internal/kcpolicy/keycloak.go
Normal 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
198
internal/kcpolicy/policy.go
Normal 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"
|
||||
}
|
||||
31
internal/kcpolicy/socket_perms.go
Normal file
31
internal/kcpolicy/socket_perms.go
Normal 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))
|
||||
}
|
||||
85
internal/kcpolicy/socketmap.go
Normal file
85
internal/kcpolicy/socketmap.go
Normal 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))
|
||||
}
|
||||
}
|
||||
62
internal/kcpolicy/sqlite.go
Normal file
62
internal/kcpolicy/sqlite.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user