Compare commits

3 Commits

Author SHA1 Message Date
peio
b067a23bba feature: switch to simple user 2026-01-21 22:54:20 +00:00
peio
7fdcc9fb10 feature: apps management 2026-01-21 22:53:57 +00:00
peio
9447523c6b Rename to mailcloak 2026-01-21 19:56:07 +00:00
23 changed files with 483 additions and 233 deletions

View File

@@ -34,22 +34,22 @@ jobs:
run: | run: |
set -eux set -eux
mkdir -p dist mkdir -p dist
BIN="kc-policy-${GOOS}-${GOARCH}" BIN="mailcloak-${GOOS}-${GOARCH}"
go build \ go build \
-trimpath \ -trimpath \
-ldflags="-s -w -X main.version=${VERSION}" \ -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}" tar -C dist -czf "dist/${BIN}.tar.gz" "${BIN}"
cp aliasctl dist/ cp mailcloakctl dist/
sha256sum "dist/${BIN}.tar.gz" >> dist/checksums.txt sha256sum "dist/${BIN}.tar.gz" >> dist/checksums.txt
sha256sum "dist/aliasctl" >> dist/checksums.txt sha256sum "dist/mailcloakctl" >> dist/checksums.txt
- name: Upload assets to Gitea release - name: Upload assets to Gitea release
uses: https://gitea.com/actions/gitea-release-action@v1 uses: https://gitea.com/actions/gitea-release-action@v1
with: with:
files: | files: |
dist/*.tar.gz dist/*.tar.gz
dist/aliasctl dist/mailcloakctl
dist/checksums.txt dist/checksums.txt
generate_release_notes: true generate_release_notes: true
env: env:

View File

@@ -1,10 +1,14 @@
BINARY := kc-policy BINARY := mailcloak
BIN_DIR := bin BIN_DIR := bin
.PHONY: build run test tidy clean install .PHONY: build run test tidy clean install
build: 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: run:
go run ./cmd/$(BINARY) go run ./cmd/$(BINARY)

View File

@@ -1,4 +1,4 @@
# kc-policy # mailcloak
Postfix policy + socketmap daemon that validates recipients/senders against Keycloak and serves a local aliases SQLite database. 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`. - **Socketmap service**: exposes an `alias` map to Postfix, rewriting alias -> `username@domain`.
## Project layout ## Project layout
- `cmd/kc-policy/` main package entrypoint - `cmd/mailcloak/` main package entrypoint
- `internal/kcpolicy/` daemon sources - `internal/mailcloak/` daemon sources
- `go.mod` / `go.sum` Go module files - `go.mod` / `go.sum` Go module files
- `configs/config.yaml.sample` sample config to copy to `/etc/kc-policy/config.yaml` - `configs/config.yaml.sample` sample config to copy to `/etc/mailcloak/config.yaml`
- `configs/openrc-kc-policy` OpenRC service file - `configs/openrc-mailcloak` OpenRC service file
- `db-init.sql` SQLite schema (also auto-created by the app) - `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 ## Build the binary
From the repository root: From the repository root:
@@ -40,8 +40,8 @@ make run
Copy the sample config and edit it: Copy the sample config and edit it:
```bash ```bash
install -d -m 0750 -o root -g postfix /etc/kc-policy install -d -m 0750 -o root -g postfix /etc/mailcloak
cp configs/config.yaml.sample /etc/kc-policy/config.yaml cp configs/config.yaml.sample /etc/mailcloak/config.yaml
``` ```
Key settings: Key settings:
@@ -50,12 +50,12 @@ Key settings:
- `sqlite.path` is the aliases database path. - `sqlite.path` is the aliases database path.
- `sockets.*` must be under the Postfix chroot (usually `/var/spool/postfix`). - `sockets.*` must be under the Postfix chroot (usually `/var/spool/postfix`).
## Alias database ## Mailcloak database
You can manage aliases using the helper script: You can manage aliases using the helper script:
```bash ```bash
./aliasctl.py --db /var/lib/kc-policy/aliases.db add alias@example.com username ./mailcloakctl aliases add alias@example.com username
./aliasctl.py --db /var/lib/kc-policy/aliases.db list ./mailcloakctl aliases list
``` ```
The script creates the schema automatically if missing. The script creates the schema automatically if missing.
@@ -63,21 +63,21 @@ The script creates the schema automatically if missing.
## Postfix integration (example) ## Postfix integration (example)
Policy service (smtpd_recipient_restrictions): Policy service (smtpd_recipient_restrictions):
``` ```
check_policy_service unix:private/kc-policy check_policy_service unix:private/mailcloak
``` ```
Socketmap (virtual_alias_maps): Socketmap (virtual_alias_maps):
``` ```
socketmap:unix:private/kc-socketmap:alias socketmap:unix:private/mailcloak-socketmap:alias
``` ```
## OpenRC ## OpenRC
Use the provided service file: Use the provided service file:
```bash ```bash
cp configs/openrc-kc-policy /etc/init.d/kc-policy cp configs/openrc-mailcloak /etc/init.d/mailcloak
rc-update add kc-policy default rc-update add mailcloak default
rc-service kc-policy start rc-service mailcloak start
``` ```
## Notes ## Notes

View File

@@ -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())

View File

@@ -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
View 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)
}

View File

@@ -6,7 +6,7 @@ keycloak:
# admin API is derived: {base_url}/admin/realms/{realm} # admin API is derived: {base_url}/admin/realms/{realm}
sqlite: sqlite:
path: "/var/lib/kc-policy/aliases.db" path: "/var/lib/mailcloak/state.db"
policy: policy:
domain: "<EMail domain-name>" domain: "<EMail domain-name>"
@@ -19,8 +19,11 @@ policy:
sockets: sockets:
# These paths must be inside postfix chroot (/var/spool/postfix) # These paths must be inside postfix chroot (/var/spool/postfix)
policy_socket: "/var/spool/postfix/private/kc-policy" policy_socket: "/var/spool/postfix/private/mailcloak-policy"
socketmap_socket: "/var/spool/postfix/private/kc-socketmap" socketmap_socket: "/var/spool/postfix/private/mailcloak-socketmap"
socket_owner_user: "postfix" socket_owner_user: "postfix"
socket_owner_group: "postfix" socket_owner_group: "postfix"
socket_mode: "0660" socket_mode: "0660"
daemon:
user: "mailcloak"

View File

@@ -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
View 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
}

View File

@@ -9,7 +9,7 @@ virtual_mailbox_domains = static:<EMail domain-name>
virtual_transport = lmtp:unix:private/dovecot-lmtp virtual_transport = lmtp:unix:private/dovecot-lmtp
# Dynamic aliases via socketmap # 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) # Policy (RCPT existence + sender policy on 587 via master.cf)
smtpd_recipient_restrictions = smtpd_recipient_restrictions =
@@ -17,5 +17,5 @@ smtpd_recipient_restrictions =
reject_unknown_recipient_domain, reject_unknown_recipient_domain,
permit_sasl_authenticated, permit_sasl_authenticated,
reject_unauth_destination, reject_unauth_destination,
check_policy_service unix:private/kc-policy, check_policy_service unix:private/mailcloak-policy,
permit permit

View File

@@ -2,7 +2,7 @@
# Configuration to add to /etc/postfix/master.cf # 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` # You can remove `reject_senders_login_mismatch` + `sender_login_maps`
# as this kc-policy will handle it. # as mailcloak will handle it.

View File

@@ -6,3 +6,18 @@ CREATE TABLE IF NOT EXISTS aliases (
); );
CREATE INDEX IF NOT EXISTS idx_aliases_username ON aliases(username); 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
);

2
go.mod
View File

@@ -1,4 +1,4 @@
module kc-policy module mailcloak
go 1.22 go 1.22

View File

@@ -1,4 +1,4 @@
package kcpolicy package mailcloak
import ( import (
"fmt" "fmt"
@@ -8,6 +8,10 @@ import (
) )
type Config struct { type Config struct {
Daemon struct {
User string `yaml:"user"`
} `yaml:"daemon"`
Keycloak struct { Keycloak struct {
BaseURL string `yaml:"base_url"` BaseURL string `yaml:"base_url"`
Realm string `yaml:"realm"` Realm string `yaml:"realm"`
@@ -58,5 +62,8 @@ func LoadConfig(path string) (*Config, error) {
if cfg.Policy.KeycloakFailureMode == "" { if cfg.Policy.KeycloakFailureMode == "" {
cfg.Policy.KeycloakFailureMode = "tempfail" cfg.Policy.KeycloakFailureMode = "tempfail"
} }
if cfg.Daemon.User == "" {
cfg.Daemon.User = "mailcloak"
}
return &cfg, nil return &cfg, nil
} }

View File

@@ -1,4 +1,4 @@
package kcpolicy package mailcloak
import ( import (
"context" "context"

View File

@@ -1,4 +1,4 @@
package kcpolicy package mailcloak
import ( import (
"bufio" "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)} 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 sock := cfg.Sockets.PolicySocket
_ = os.Remove(sock) _ = os.Remove(sock)
l, err := net.Listen("unix", sock) l, err := net.Listen("unix", sock)
if err != nil { if err != nil {
return err return nil, err
} }
defer l.Close()
if err := ChownChmodSocket(sock, cfg); err != nil { 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 { for {
conn, err := l.Accept() conn, err := l.Accept()
if err != nil { 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) { func handlePolicyConn(conn net.Conn, cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache) {
defer conn.Close() defer conn.Close()
r := bufio.NewReader(conn) r := bufio.NewReader(conn)

View 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
}

View File

@@ -1,4 +1,4 @@
package kcpolicy package mailcloak
import ( import (
"fmt" "fmt"

View File

@@ -1,4 +1,4 @@
package kcpolicy package mailcloak
import ( import (
"bufio" "bufio"
@@ -11,20 +11,26 @@ import (
"strings" "strings"
) )
func RunSocketmap(ctx context.Context, cfg *Config, db *AliasDB) error { func OpenSocketmapListener(cfg *Config) (net.Listener, error) {
sock := cfg.Sockets.SocketmapSocket sock := cfg.Sockets.SocketmapSocket
_ = os.Remove(sock) _ = os.Remove(sock)
l, err := net.Listen("unix", sock) l, err := net.Listen("unix", sock)
if err != nil { if err != nil {
return err return nil, err
} }
defer l.Close()
if err := ChownChmodSocket(sock, cfg); err != nil { if err := ChownChmodSocket(sock, cfg); err != nil {
return err _ = 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 { for {
conn, err := l.Accept() conn, err := l.Accept()
if err != nil { if err != nil {
@@ -39,6 +45,14 @@ func RunSocketmap(ctx context.Context, cfg *Config, db *AliasDB) error {
} }
} }
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>," // Postfix socketmap framing: "<len>:<payload>,"
func handleSocketmapConn(conn net.Conn, cfg *Config, db *AliasDB) { func handleSocketmapConn(conn net.Conn, cfg *Config, db *AliasDB) {
defer conn.Close() defer conn.Close()

View File

@@ -1,4 +1,4 @@
package kcpolicy package mailcloak
import ( import (
"database/sql" "database/sql"
@@ -15,6 +15,7 @@ func OpenAliasDB(path string) (*AliasDB, error) {
return nil, err return nil, err
} }
if _, err := db.Exec(` if _, err := db.Exec(`
PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS aliases ( CREATE TABLE IF NOT EXISTS aliases (
alias_email TEXT PRIMARY KEY, alias_email TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -22,6 +23,19 @@ CREATE TABLE IF NOT EXISTS aliases (
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
); );
CREATE INDEX IF NOT EXISTS idx_aliases_username ON aliases(username); 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 { `); err != nil {
_ = db.Close() _ = db.Close()
return nil, fmt.Errorf("init schema: %w", err) return nil, fmt.Errorf("init schema: %w", err)

197
mailcloakctl Executable file
View 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
View File

@@ -0,0 +1 @@
argon2-cffi>=23.1.0

BIN
state.db Normal file

Binary file not shown.