Compare commits
6 Commits
v0.9.1
...
b067a23bba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b067a23bba | ||
|
|
7fdcc9fb10 | ||
|
|
9447523c6b | ||
|
|
6845060d00 | ||
|
|
762cb11389 | ||
|
|
a7e6b5420e |
@@ -25,7 +25,7 @@ jobs:
|
||||
go-version: "1.22.x"
|
||||
cache: true
|
||||
|
||||
- name: Build (linux/musl)
|
||||
- name: Build
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.arch }}
|
||||
@@ -34,20 +34,23 @@ jobs:
|
||||
run: |
|
||||
set -eux
|
||||
mkdir -p dist
|
||||
BIN="kc-policy-${GOOS}-${GOARCH}"
|
||||
BIN="mailcloak-${GOOS}-${GOARCH}"
|
||||
go build \
|
||||
-trimpath \
|
||||
-ldflags="-s -w -X main.version=${VERSION}" \
|
||||
-o "dist/${BIN}" ./cmd/kc-policy
|
||||
-o "dist/${BIN}" ./cmd/mailcloak
|
||||
tar -C dist -czf "dist/${BIN}.tar.gz" "${BIN}"
|
||||
cp mailcloakctl dist/
|
||||
sha256sum "dist/${BIN}.tar.gz" >> dist/checksums.txt
|
||||
sha256sum "dist/mailcloakctl" >> dist/checksums.txt
|
||||
|
||||
- name: Upload assets to Gitea release
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
files: |
|
||||
dist/*.tar.gz
|
||||
dist/*.sha256
|
||||
dist/mailcloakctl
|
||||
dist/checksums.txt
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITEA_TOKEN: ${{ github.token }}
|
||||
|
||||
8
Makefile
8
Makefile
@@ -1,10 +1,14 @@
|
||||
BINARY := kc-policy
|
||||
BINARY := mailcloak
|
||||
BIN_DIR := bin
|
||||
|
||||
.PHONY: build run test tidy clean install
|
||||
|
||||
build:
|
||||
go build -o $(BIN_DIR)/$(BINARY) ./cmd/$(BINARY)
|
||||
export CGO_ENABLED=0
|
||||
go build \
|
||||
-trimpath \
|
||||
-ldflags="-s -w" \
|
||||
-o "$(BIN_DIR)/$(BINARY)" ./cmd/$(BINARY)
|
||||
|
||||
run:
|
||||
go run ./cmd/$(BINARY)
|
||||
|
||||
32
README.md
32
README.md
@@ -1,4 +1,4 @@
|
||||
# kc-policy
|
||||
# mailcloak
|
||||
|
||||
Postfix policy + socketmap daemon that validates recipients/senders against Keycloak and serves a local aliases SQLite database.
|
||||
|
||||
@@ -9,13 +9,13 @@ Postfix policy + socketmap daemon that validates recipients/senders against Keyc
|
||||
- **Socketmap service**: exposes an `alias` map to Postfix, rewriting alias -> `username@domain`.
|
||||
|
||||
## Project layout
|
||||
- `cmd/kc-policy/` – main package entrypoint
|
||||
- `internal/kcpolicy/` – daemon sources
|
||||
- `cmd/mailcloak/` – main package entrypoint
|
||||
- `internal/mailcloak/` – 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
|
||||
- `configs/config.yaml.sample` – sample config to copy to `/etc/mailcloak/config.yaml`
|
||||
- `configs/openrc-mailcloak` – OpenRC service file
|
||||
- `db-init.sql` – SQLite schema (also auto-created by the app)
|
||||
- `aliasctl.py` – CLI helper to manage aliases
|
||||
- `mailcloakctl` – CLI helper to manage aliases
|
||||
|
||||
## Build the binary
|
||||
From the repository root:
|
||||
@@ -40,8 +40,8 @@ make run
|
||||
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
|
||||
install -d -m 0750 -o root -g postfix /etc/mailcloak
|
||||
cp configs/config.yaml.sample /etc/mailcloak/config.yaml
|
||||
```
|
||||
|
||||
Key settings:
|
||||
@@ -50,12 +50,12 @@ Key settings:
|
||||
- `sqlite.path` is the aliases database path.
|
||||
- `sockets.*` must be under the Postfix chroot (usually `/var/spool/postfix`).
|
||||
|
||||
## Alias database
|
||||
## Mailcloak 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
|
||||
./mailcloakctl aliases add alias@example.com username
|
||||
./mailcloakctl aliases list
|
||||
```
|
||||
|
||||
The script creates the schema automatically if missing.
|
||||
@@ -63,21 +63,21 @@ The script creates the schema automatically if missing.
|
||||
## Postfix integration (example)
|
||||
Policy service (smtpd_recipient_restrictions):
|
||||
```
|
||||
check_policy_service unix:private/kc-policy
|
||||
check_policy_service unix:private/mailcloak
|
||||
```
|
||||
|
||||
Socketmap (virtual_alias_maps):
|
||||
```
|
||||
socketmap:unix:private/kc-socketmap:alias
|
||||
socketmap:unix:private/mailcloak-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
|
||||
cp configs/openrc-mailcloak /etc/init.d/mailcloak
|
||||
rc-update add mailcloak default
|
||||
rc-service mailcloak start
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
96
aliasctl.py
96
aliasctl.py
@@ -1,96 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,68 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"kc-policy/internal/kcpolicy"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "--version" {
|
||||
fmt.Println(version)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
85
cmd/mailcloak/main.go
Normal file
85
cmd/mailcloak/main.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"mailcloak/internal/mailcloak"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "--version" {
|
||||
fmt.Println(version)
|
||||
return
|
||||
}
|
||||
|
||||
cfgPath := "/etc/mailcloak/config.yaml"
|
||||
if len(os.Args) >= 2 {
|
||||
cfgPath = os.Args[1]
|
||||
}
|
||||
|
||||
cfg, err := mailcloak.LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
|
||||
policyListener, err := mailcloak.OpenPolicyListener(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("policy listener: %v", err)
|
||||
}
|
||||
|
||||
socketmapListener, err := mailcloak.OpenSocketmapListener(cfg)
|
||||
if err != nil {
|
||||
_ = policyListener.Close()
|
||||
log.Fatalf("socketmap listener: %v", err)
|
||||
}
|
||||
|
||||
if err := mailcloak.DropPrivileges(cfg); err != nil {
|
||||
_ = policyListener.Close()
|
||||
_ = socketmapListener.Close()
|
||||
log.Fatalf("privileges: %v", err)
|
||||
}
|
||||
|
||||
db, err := mailcloak.OpenAliasDB(cfg.SQLite.Path)
|
||||
if err != nil {
|
||||
log.Fatalf("sqlite: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
kc := mailcloak.NewKeycloak(cfg)
|
||||
cache := mailcloak.NewCache(time.Duration(cfg.Policy.CacheTTLSeconds) * time.Second)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Start socketmap server
|
||||
go func() {
|
||||
if err := mailcloak.ServeSocketmap(ctx, cfg, db, socketmapListener); err != nil {
|
||||
log.Fatalf("socketmap: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start policy server
|
||||
go func() {
|
||||
if err := mailcloak.ServePolicy(ctx, cfg, db, kc, cache, policyListener); err != nil {
|
||||
log.Fatalf("policy: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("mailcloak 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)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ keycloak:
|
||||
# admin API is derived: {base_url}/admin/realms/{realm}
|
||||
|
||||
sqlite:
|
||||
path: "/var/lib/kc-policy/aliases.db"
|
||||
path: "/var/lib/mailcloak/state.db"
|
||||
|
||||
policy:
|
||||
domain: "<EMail domain-name>"
|
||||
@@ -19,8 +19,11 @@ policy:
|
||||
|
||||
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"
|
||||
policy_socket: "/var/spool/postfix/private/mailcloak-policy"
|
||||
socketmap_socket: "/var/spool/postfix/private/mailcloak-socketmap"
|
||||
socket_owner_user: "postfix"
|
||||
socket_owner_group: "postfix"
|
||||
socket_mode: "0660"
|
||||
|
||||
daemon:
|
||||
user: "mailcloak"
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/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
|
||||
}
|
||||
23
configs/openrc-mailcloak
Normal file
23
configs/openrc-mailcloak
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/sbin/openrc-run
|
||||
|
||||
name="mailcloak"
|
||||
command="/usr/local/sbin/mailcloak"
|
||||
command_args="/etc/mailcloak/config.yaml"
|
||||
command_background="yes"
|
||||
pidfile="/run/mailcloak.pid"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
after postfix
|
||||
}
|
||||
|
||||
start_pre() {
|
||||
checkpath -d -m 0750 -o root:postfix /etc/mailcloak
|
||||
checkpath -d -m 0750 -o root:postfix /var/lib/mailcloak
|
||||
checkpath -d -m 0755 -o root:root /usr/local/sbin
|
||||
# sockets dir already exists
|
||||
}
|
||||
|
||||
stop_post() {
|
||||
rm -f /var/spool/postfix/private/mailcloak-policy /var/spool/postfix/private/mailcloak-socketmap
|
||||
}
|
||||
@@ -9,7 +9,7 @@ virtual_mailbox_domains = static:<EMail domain-name>
|
||||
virtual_transport = lmtp:unix:private/dovecot-lmtp
|
||||
|
||||
# Dynamic aliases via socketmap
|
||||
virtual_alias_maps = socketmap:unix:private/kc-socketmap:alias
|
||||
virtual_alias_maps = socketmap:unix:private/mailcloak-socketmap:alias
|
||||
|
||||
# Policy (RCPT existence + sender policy on 587 via master.cf)
|
||||
smtpd_recipient_restrictions =
|
||||
@@ -17,5 +17,5 @@ smtpd_recipient_restrictions =
|
||||
reject_unknown_recipient_domain,
|
||||
permit_sasl_authenticated,
|
||||
reject_unauth_destination,
|
||||
check_policy_service unix:private/kc-policy,
|
||||
check_policy_service unix:private/mailcloak-policy,
|
||||
permit
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Configuration to add to /etc/postfix/master.cf
|
||||
#
|
||||
|
||||
-o smtpd_sender_restrictions=check_policy_service unix:private/kc-policy
|
||||
-o smtpd_sender_restrictions=check_policy_service unix:private/mailcloak-policy
|
||||
|
||||
# You can remove `reject_senders_login_mismaych` + `sender_login_maps`
|
||||
# as this kc-policy will handle it.
|
||||
# You can remove `reject_senders_login_mismatch` + `sender_login_maps`
|
||||
# as mailcloak will handle it.
|
||||
15
db-init.sql
15
db-init.sql
@@ -6,3 +6,18 @@ CREATE TABLE IF NOT EXISTS aliases (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_aliases_username ON aliases(username);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS apps (
|
||||
app_id TEXT PRIMARY KEY,
|
||||
secret_hash TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_from (
|
||||
app_id TEXT NOT NULL,
|
||||
from_addr TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (app_id, from_addr),
|
||||
FOREIGN KEY (app_id) REFERENCES apps(app_id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -1,85 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package kcpolicy
|
||||
package mailcloak
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -8,6 +8,10 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Daemon struct {
|
||||
User string `yaml:"user"`
|
||||
} `yaml:"daemon"`
|
||||
|
||||
Keycloak struct {
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Realm string `yaml:"realm"`
|
||||
@@ -58,5 +62,8 @@ func LoadConfig(path string) (*Config, error) {
|
||||
if cfg.Policy.KeycloakFailureMode == "" {
|
||||
cfg.Policy.KeycloakFailureMode = "tempfail"
|
||||
}
|
||||
if cfg.Daemon.User == "" {
|
||||
cfg.Daemon.User = "mailcloak"
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package kcpolicy
|
||||
package mailcloak
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package kcpolicy
|
||||
package mailcloak
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -38,20 +38,26 @@ 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 {
|
||||
func OpenPolicyListener(cfg *Config) (net.Listener, error) {
|
||||
sock := cfg.Sockets.PolicySocket
|
||||
_ = os.Remove(sock)
|
||||
|
||||
l, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
if err := ChownChmodSocket(sock, cfg); err != nil {
|
||||
return err
|
||||
_ = l.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func ServePolicy(ctx context.Context, cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache, l net.Listener) error {
|
||||
defer l.Close()
|
||||
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
@@ -66,6 +72,14 @@ func RunPolicy(ctx context.Context, cfg *Config, db *AliasDB, kc *Keycloak, cach
|
||||
}
|
||||
}
|
||||
|
||||
func RunPolicy(ctx context.Context, cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache) error {
|
||||
l, err := OpenPolicyListener(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ServePolicy(ctx, cfg, db, kc, cache, l)
|
||||
}
|
||||
|
||||
func handlePolicyConn(conn net.Conn, cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache) {
|
||||
defer conn.Close()
|
||||
r := bufio.NewReader(conn)
|
||||
60
internal/mailcloak/privileges.go
Normal file
60
internal/mailcloak/privileges.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package mailcloak
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func DropPrivileges(cfg *Config) error {
|
||||
userName := cfg.Daemon.User
|
||||
if userName == "" {
|
||||
userName = "mailcloak"
|
||||
}
|
||||
|
||||
u, err := user.Lookup(userName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup user %s: %w", userName, err)
|
||||
}
|
||||
uid, err := strconv.Atoi(u.Uid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad uid for %s: %w", userName, err)
|
||||
}
|
||||
gid, err := strconv.Atoi(u.Gid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad gid for %s: %w", userName, err)
|
||||
}
|
||||
|
||||
euid := os.Geteuid()
|
||||
if euid == uid {
|
||||
return nil
|
||||
}
|
||||
if euid != 0 {
|
||||
return fmt.Errorf("must run as root to switch user to %s", userName)
|
||||
}
|
||||
|
||||
if groups, err := u.GroupIds(); err == nil {
|
||||
gids := make([]int, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
if id, err := strconv.Atoi(g); err == nil {
|
||||
gids = append(gids, id)
|
||||
}
|
||||
}
|
||||
if len(gids) > 0 {
|
||||
if err := syscall.Setgroups(gids); err != nil {
|
||||
return fmt.Errorf("setgroups: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := syscall.Setgid(gid); err != nil {
|
||||
return fmt.Errorf("setgid: %w", err)
|
||||
}
|
||||
if err := syscall.Setuid(uid); err != nil {
|
||||
return fmt.Errorf("setuid: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package kcpolicy
|
||||
package mailcloak
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
156
internal/mailcloak/socketmap.go
Normal file
156
internal/mailcloak/socketmap.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package mailcloak
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func OpenSocketmapListener(cfg *Config) (net.Listener, error) {
|
||||
sock := cfg.Sockets.SocketmapSocket
|
||||
_ = os.Remove(sock)
|
||||
|
||||
l, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ChownChmodSocket(sock, cfg); err != nil {
|
||||
_ = l.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func ServeSocketmap(ctx context.Context, cfg *Config, db *AliasDB, l net.Listener) error {
|
||||
defer l.Close()
|
||||
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
go handleSocketmapConn(conn, cfg, db)
|
||||
}
|
||||
}
|
||||
|
||||
func RunSocketmap(ctx context.Context, cfg *Config, db *AliasDB) error {
|
||||
l, err := OpenSocketmapListener(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ServeSocketmap(ctx, cfg, db, l)
|
||||
}
|
||||
|
||||
// Postfix socketmap framing: "<len>:<payload>,"
|
||||
func handleSocketmapConn(conn net.Conn, cfg *Config, db *AliasDB) {
|
||||
defer conn.Close()
|
||||
r := bufio.NewReader(conn)
|
||||
|
||||
for {
|
||||
payload, err := readSocketmapFrame(r)
|
||||
if err != nil {
|
||||
// normal close
|
||||
return
|
||||
}
|
||||
|
||||
payload = strings.TrimSpace(payload)
|
||||
if payload == "" {
|
||||
_ = writeSocketmapFrame(conn, "NOTFOUND")
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(payload, " ", 2)
|
||||
if len(parts) != 2 {
|
||||
_ = writeSocketmapFrame(conn, "TEMP")
|
||||
continue
|
||||
}
|
||||
|
||||
mapName := parts[0]
|
||||
key := strings.ToLower(strings.TrimSpace(parts[1]))
|
||||
|
||||
if mapName != "alias" {
|
||||
_ = writeSocketmapFrame(conn, "NOTFOUND")
|
||||
continue
|
||||
}
|
||||
|
||||
// Only handle our domain
|
||||
domain := strings.ToLower(cfg.Policy.Domain)
|
||||
if !strings.HasSuffix(key, "@"+domain) {
|
||||
_ = writeSocketmapFrame(conn, "NOTFOUND")
|
||||
continue
|
||||
}
|
||||
|
||||
username, ok, err := db.AliasOwner(key)
|
||||
if err != nil {
|
||||
_ = writeSocketmapFrame(conn, "TEMP")
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
_ = writeSocketmapFrame(conn, "NOTFOUND")
|
||||
continue
|
||||
}
|
||||
|
||||
// rewrite alias -> username@domain
|
||||
_ = writeSocketmapFrame(conn, fmt.Sprintf("OK %s@%s", username, domain))
|
||||
}
|
||||
}
|
||||
|
||||
func readSocketmapFrame(r *bufio.Reader) (string, error) {
|
||||
// read decimal length until ':'
|
||||
var lenBuf strings.Builder
|
||||
for {
|
||||
b, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if b == ':' {
|
||||
break
|
||||
}
|
||||
if b < '0' || b > '9' {
|
||||
return "", io.ErrUnexpectedEOF
|
||||
}
|
||||
lenBuf.WriteByte(b)
|
||||
if lenBuf.Len() > 10 {
|
||||
return "", io.ErrUnexpectedEOF
|
||||
}
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(lenBuf.String())
|
||||
if err != nil || n < 0 || n > 1024*1024 {
|
||||
return "", io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// expect trailing comma
|
||||
b, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if b != ',' {
|
||||
return "", io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func writeSocketmapFrame(w io.Writer, payload string) error {
|
||||
// "<len>:<payload>,"
|
||||
_, err := fmt.Fprintf(w, "%d:%s,", len(payload), payload)
|
||||
return err
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package kcpolicy
|
||||
package mailcloak
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -15,6 +15,7 @@ func OpenAliasDB(path string) (*AliasDB, error) {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE IF NOT EXISTS aliases (
|
||||
alias_email TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
@@ -22,6 +23,19 @@ CREATE TABLE IF NOT EXISTS aliases (
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_aliases_username ON aliases(username);
|
||||
CREATE TABLE IF NOT EXISTS apps (
|
||||
app_id TEXT PRIMARY KEY,
|
||||
secret_hash TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS app_from (
|
||||
app_id TEXT NOT NULL,
|
||||
from_addr TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (app_id, from_addr),
|
||||
FOREIGN KEY (app_id) REFERENCES apps(app_id) ON DELETE CASCADE
|
||||
);
|
||||
`); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("init schema: %w", err)
|
||||
197
mailcloakctl
Executable file
197
mailcloakctl
Executable file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import sqlite3
|
||||
import time
|
||||
import sys
|
||||
from argon2 import PasswordHasher, Type
|
||||
|
||||
DEFAULT_DB = "/var/lib/mailcloak/state.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);
|
||||
CREATE TABLE IF NOT EXISTS apps (
|
||||
app_id TEXT PRIMARY KEY,
|
||||
secret_hash TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS app_from (
|
||||
app_id TEXT NOT NULL,
|
||||
from_addr TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (app_id, from_addr),
|
||||
FOREIGN KEY (app_id) REFERENCES apps(app_id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
|
||||
def connect(db_path: str):
|
||||
con = sqlite3.connect(db_path)
|
||||
con.execute("PRAGMA foreign_keys=ON;")
|
||||
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 norm_id(s: str) -> str:
|
||||
return s.strip()
|
||||
|
||||
argon2_hasher = PasswordHasher(
|
||||
time_cost=3,
|
||||
memory_cost=65536,
|
||||
parallelism=1,
|
||||
hash_len=32,
|
||||
salt_len=16,
|
||||
type=Type.ID,
|
||||
)
|
||||
|
||||
def cmd_aliases_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_aliases_del(con, alias_email):
|
||||
alias_email = norm_email(alias_email)
|
||||
con.execute("DELETE FROM aliases WHERE alias_email=?", (alias_email,))
|
||||
con.commit()
|
||||
|
||||
def cmd_aliases_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_aliases_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 cmd_apps_add(con, app_id, password):
|
||||
app_id = norm_id(app_id)
|
||||
secret_hash = argon2_hasher.hash(password)
|
||||
now = int(time.time())
|
||||
con.execute(
|
||||
"INSERT INTO apps(app_id, secret_hash, enabled, created_at) VALUES(?,?,1,?) "
|
||||
"ON CONFLICT(app_id) DO UPDATE SET secret_hash=excluded.secret_hash, enabled=1, created_at=excluded.created_at",
|
||||
(app_id, secret_hash, now),
|
||||
)
|
||||
con.commit()
|
||||
|
||||
def cmd_apps_del(con, app_id):
|
||||
app_id = norm_id(app_id)
|
||||
con.execute("DELETE FROM apps WHERE app_id=?", (app_id,))
|
||||
con.commit()
|
||||
|
||||
def cmd_apps_allow(con, app_id, from_addr):
|
||||
app_id = norm_id(app_id)
|
||||
from_addr = norm_email(from_addr)
|
||||
con.execute(
|
||||
"INSERT INTO app_from(app_id, from_addr, enabled) VALUES(?,?,1) "
|
||||
"ON CONFLICT(app_id, from_addr) DO UPDATE SET enabled=1",
|
||||
(app_id, from_addr),
|
||||
)
|
||||
con.commit()
|
||||
|
||||
def cmd_apps_disallow(con, app_id, from_addr):
|
||||
app_id = norm_id(app_id)
|
||||
from_addr = norm_email(from_addr)
|
||||
con.execute("DELETE FROM app_from WHERE app_id=? AND from_addr=?", (app_id, from_addr))
|
||||
con.commit()
|
||||
|
||||
def cmd_apps_list(con):
|
||||
rows = con.execute(
|
||||
"SELECT app_id, enabled, created_at FROM apps ORDER BY app_id"
|
||||
).fetchall()
|
||||
for app_id,en,ts in rows:
|
||||
print(f"{app_id}\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="group", required=True)
|
||||
|
||||
aliases = sub.add_parser("aliases")
|
||||
aliases_sub = aliases.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_add = aliases_sub.add_parser("add")
|
||||
p_add.add_argument("alias_email")
|
||||
p_add.add_argument("username")
|
||||
|
||||
p_del = aliases_sub.add_parser("del")
|
||||
p_del.add_argument("alias_email")
|
||||
|
||||
p_dis = aliases_sub.add_parser("disable")
|
||||
p_dis.add_argument("alias_email")
|
||||
|
||||
p_ls = aliases_sub.add_parser("list")
|
||||
p_ls.add_argument("--user", default=None)
|
||||
|
||||
apps = sub.add_parser("apps")
|
||||
apps_sub = apps.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_app_add = apps_sub.add_parser("add")
|
||||
p_app_add.add_argument("app_id")
|
||||
p_app_add.add_argument("password")
|
||||
|
||||
p_app_del = apps_sub.add_parser("del")
|
||||
p_app_del.add_argument("app_id")
|
||||
|
||||
p_app_allow = apps_sub.add_parser("allow")
|
||||
p_app_allow.add_argument("app_id")
|
||||
p_app_allow.add_argument("from_addr")
|
||||
|
||||
p_app_disallow = apps_sub.add_parser("disallow")
|
||||
p_app_disallow.add_argument("app_id")
|
||||
p_app_disallow.add_argument("from_addr")
|
||||
|
||||
p_app_ls = apps_sub.add_parser("list")
|
||||
|
||||
args = ap.parse_args()
|
||||
con = connect(args.db)
|
||||
try:
|
||||
if args.group == "aliases":
|
||||
if args.cmd == "add":
|
||||
cmd_aliases_add(con, args.alias_email, args.username)
|
||||
elif args.cmd == "del":
|
||||
cmd_aliases_del(con, args.alias_email)
|
||||
elif args.cmd == "disable":
|
||||
cmd_aliases_disable(con, args.alias_email)
|
||||
elif args.cmd == "list":
|
||||
cmd_aliases_list(con, args.user)
|
||||
elif args.group == "apps":
|
||||
if args.cmd == "add":
|
||||
cmd_apps_add(con, args.app_id, args.password)
|
||||
elif args.cmd == "del":
|
||||
cmd_apps_del(con, args.app_id)
|
||||
elif args.cmd == "allow":
|
||||
cmd_apps_allow(con, args.app_id, args.from_addr)
|
||||
elif args.cmd == "disallow":
|
||||
cmd_apps_disallow(con, args.app_id, args.from_addr)
|
||||
elif args.cmd == "list":
|
||||
cmd_apps_list(con)
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
argon2-cffi>=23.1.0
|
||||
Reference in New Issue
Block a user