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
15 changed files with 92 additions and 222 deletions

View File

@@ -22,7 +22,7 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: "1.25.x" go-version: "1.22.x"
cache: true cache: true
- name: Build - name: Build

View File

@@ -4,8 +4,11 @@ BIN_DIR := bin
.PHONY: build run test tidy clean install .PHONY: build run test tidy clean install
build: build:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ export CGO_ENABLED=0
go build -trimpath -ldflags="-s -w" -o bin/mailcloak ./cmd/mailcloak go build \
-trimpath \
-ldflags="-s -w" \
-o "$(BIN_DIR)/$(BINARY)" ./cmd/$(BINARY)
run: run:
go run ./cmd/$(BINARY) go run ./cmd/$(BINARY)

View File

@@ -51,8 +51,6 @@ Key settings:
- `sockets.*` must be under the Postfix chroot (usually `/var/spool/postfix`). - `sockets.*` must be under the Postfix chroot (usually `/var/spool/postfix`).
## Mailcloak database ## Mailcloak database
### Aliases
You can manage aliases using the helper script: You can manage aliases using the helper script:
```bash ```bash
@@ -62,19 +60,6 @@ You can manage aliases using the helper script:
The script creates the schema automatically if missing. The script creates the schema automatically if missing.
### Apps (Dovecot app passwords)
The helper script also manages application credentials. The application password is a token: updating the application ID and password is handled by the script and stored as a hash in SQLite. Dovecot can verify these credentials using plain authentication against the stored hash. Applications are restricted to sending emails only (they cannot receive them) and may use only their authorized sender addresses.
Examples:
```bash
./mailcloakctl apps add my-app-id "my-app-token"
./mailcloakctl apps allow my-app-id sender@example.com
./mailcloakctl apps list
./mailcloakctl apps disallow my-app-id sender@example.com
./mailcloakctl apps del my-app-id
```
## Postfix integration (example) ## Postfix integration (example)
Policy service (smtpd_recipient_restrictions): Policy service (smtpd_recipient_restrictions):
``` ```

View File

@@ -15,8 +15,8 @@ import (
var version = "dev" var version = "dev"
func main() { func main() {
fmt.Printf("mailcloak %s\n", version)
if len(os.Args) > 1 && os.Args[1] == "--version" { if len(os.Args) > 1 && os.Args[1] == "--version" {
fmt.Println(version)
return return
} }
@@ -25,34 +25,29 @@ func main() {
cfgPath = os.Args[1] cfgPath = os.Args[1]
} }
log.Printf("loading config from %s", cfgPath)
cfg, err := mailcloak.LoadConfig(cfgPath) cfg, err := mailcloak.LoadConfig(cfgPath)
if err != nil { if err != nil {
log.Fatalf("config: %v", err) log.Fatalf("config: %v", err)
} }
log.Printf("opening policy listener at %s", cfg.Sockets.PolicySocket)
policyListener, err := mailcloak.OpenPolicyListener(cfg) policyListener, err := mailcloak.OpenPolicyListener(cfg)
if err != nil { if err != nil {
log.Fatalf("policy listener: %v", err) log.Fatalf("policy listener: %v", err)
} }
log.Printf("opening socketmap listener at %s", cfg.Sockets.SocketmapSocket)
socketmapListener, err := mailcloak.OpenSocketmapListener(cfg) socketmapListener, err := mailcloak.OpenSocketmapListener(cfg)
if err != nil { if err != nil {
_ = policyListener.Close() _ = policyListener.Close()
log.Fatalf("socketmap listener: %v", err) log.Fatalf("socketmap listener: %v", err)
} }
log.Printf("dropping privileges to %s", cfg.Daemon.User)
if err := mailcloak.DropPrivileges(cfg); err != nil { if err := mailcloak.DropPrivileges(cfg); err != nil {
_ = policyListener.Close() _ = policyListener.Close()
_ = socketmapListener.Close() _ = socketmapListener.Close()
log.Fatalf("privileges: %v", err) log.Fatalf("privileges: %v", err)
} }
log.Printf("opening sqlite db at %s", cfg.SQLite.Path) db, err := mailcloak.OpenAliasDB(cfg.SQLite.Path)
db, err := mailcloak.OpenMailcloakDB(cfg.SQLite.Path)
if err != nil { if err != nil {
log.Fatalf("sqlite: %v", err) log.Fatalf("sqlite: %v", err)
} }
@@ -66,7 +61,6 @@ func main() {
// Start socketmap server // Start socketmap server
go func() { go func() {
log.Printf("socketmap server started")
if err := mailcloak.ServeSocketmap(ctx, cfg, db, socketmapListener); err != nil { if err := mailcloak.ServeSocketmap(ctx, cfg, db, socketmapListener); err != nil {
log.Fatalf("socketmap: %v", err) log.Fatalf("socketmap: %v", err)
} }
@@ -74,7 +68,6 @@ func main() {
// Start policy server // Start policy server
go func() { go func() {
log.Printf("policy server started")
if err := mailcloak.ServePolicy(ctx, cfg, db, kc, cache, policyListener); err != nil { if err := mailcloak.ServePolicy(ctx, cfg, db, kc, cache, policyListener); err != nil {
log.Fatalf("policy: %v", err) log.Fatalf("policy: %v", err)
} }

View File

@@ -12,8 +12,10 @@ depend() {
} }
start_pre() { start_pre() {
checkpath -d -m 0750 -o root:root /etc/mailcloak checkpath -d -m 0750 -o root:postfix /etc/mailcloak
checkpath -d -m 0750 -o mailcloak:dovecot /var/lib/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() { stop_post() {

23
db-init.sql Normal file
View File

@@ -0,0 +1,23 @@
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
);

View File

@@ -2,7 +2,6 @@ package mailcloak
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -59,15 +58,12 @@ func LoadConfig(path string) (*Config, error) {
} }
if cfg.Policy.CacheTTLSeconds <= 0 { if cfg.Policy.CacheTTLSeconds <= 0 {
cfg.Policy.CacheTTLSeconds = 120 cfg.Policy.CacheTTLSeconds = 120
log.Printf("config: policy.cache_ttl_seconds not set, defaulting to %d", cfg.Policy.CacheTTLSeconds)
} }
if cfg.Policy.KeycloakFailureMode == "" { if cfg.Policy.KeycloakFailureMode == "" {
cfg.Policy.KeycloakFailureMode = "tempfail" cfg.Policy.KeycloakFailureMode = "tempfail"
log.Printf("config: policy.keycloak_failure_mode not set, defaulting to %s", cfg.Policy.KeycloakFailureMode)
} }
if cfg.Daemon.User == "" { if cfg.Daemon.User == "" {
cfg.Daemon.User = "mailcloak" cfg.Daemon.User = "mailcloak"
log.Printf("config: daemon.user not set, defaulting to %s", cfg.Daemon.User)
} }
return &cfg, nil return &cfg, nil
} }

View File

@@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@@ -44,22 +43,18 @@ func (k *Keycloak) token(ctx context.Context) (string, error) {
resp, err := k.hc.Do(req) resp, err := k.hc.Do(req)
if err != nil { if err != nil {
log.Printf("keycloak token request error: %v", err)
return "", err return "", err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode/100 != 2 { if resp.StatusCode/100 != 2 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
log.Printf("keycloak token non-2xx: %d", resp.StatusCode)
return "", fmt.Errorf("token http %d: %s", resp.StatusCode, string(b)) return "", fmt.Errorf("token http %d: %s", resp.StatusCode, string(b))
} }
var tr tokenResp var tr tokenResp
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
log.Printf("keycloak token decode error: %v", err)
return "", err return "", err
} }
if tr.AccessToken == "" { if tr.AccessToken == "" {
log.Printf("keycloak token response missing access_token")
return "", fmt.Errorf("empty access_token") return "", fmt.Errorf("empty access_token")
} }
return tr.AccessToken, nil return tr.AccessToken, nil
@@ -87,18 +82,15 @@ func (k *Keycloak) adminGet(ctx context.Context, bearer, path string, q url.Valu
resp, err := k.hc.Do(req) resp, err := k.hc.Do(req)
if err != nil { if err != nil {
log.Printf("keycloak admin request error: %v", err)
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode/100 != 2 { if resp.StatusCode/100 != 2 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
log.Printf("keycloak admin non-2xx: %d", resp.StatusCode)
return nil, fmt.Errorf("admin http %d: %s", resp.StatusCode, string(b)) return nil, fmt.Errorf("admin http %d: %s", resp.StatusCode, string(b))
} }
var users []kcUser var users []kcUser
if err := json.NewDecoder(resp.Body).Decode(&users); err != nil { if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
log.Printf("keycloak admin decode error: %v", err)
return nil, err return nil, err
} }
return users, nil return users, nil
@@ -116,13 +108,11 @@ func (k *Keycloak) EmailByUsername(ctx context.Context, username string) (string
q.Set("exact", "true") q.Set("exact", "true")
users, err := k.adminGet(ctx, bearer, "/users", q) users, err := k.adminGet(ctx, bearer, "/users", q)
if err != nil { if err != nil {
log.Printf("keycloak admin exact username lookup failed for %s: %v", username, err)
// fallback: search // fallback: search
q2 := url.Values{} q2 := url.Values{}
q2.Set("search", username) q2.Set("search", username)
users, err = k.adminGet(ctx, bearer, "/users", q2) users, err = k.adminGet(ctx, bearer, "/users", q2)
if err != nil { if err != nil {
log.Printf("keycloak admin search username lookup failed for %s: %v", username, err)
return "", false, err return "", false, err
} }
} }
@@ -146,13 +136,11 @@ func (k *Keycloak) EmailExists(ctx context.Context, email string) (bool, error)
q.Set("exact", "true") q.Set("exact", "true")
users, err := k.adminGet(ctx, bearer, "/users", q) users, err := k.adminGet(ctx, bearer, "/users", q)
if err != nil { if err != nil {
log.Printf("keycloak admin exact email lookup failed for %s: %v", email, err)
// fallback: search // fallback: search
q2 := url.Values{} q2 := url.Values{}
q2.Set("search", email) q2.Set("search", email)
users, err = k.adminGet(ctx, bearer, "/users", q2) users, err = k.adminGet(ctx, bearer, "/users", q2)
if err != nil { if err != nil {
log.Printf("keycloak admin search email lookup failed for %s: %v", email, err)
return false, err return false, err
} }
} }

View File

@@ -51,12 +51,11 @@ func OpenPolicyListener(cfg *Config) (net.Listener, error) {
_ = l.Close() _ = l.Close()
return nil, err return nil, err
} }
log.Printf("policy listener ready on %s", sock)
return l, nil return l, nil
} }
func ServePolicy(ctx context.Context, cfg *Config, db *MailcloakDB, kc *Keycloak, cache *Cache, l net.Listener) error { func ServePolicy(ctx context.Context, cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache, l net.Listener) error {
defer l.Close() defer l.Close()
for { for {
@@ -66,7 +65,6 @@ func ServePolicy(ctx context.Context, cfg *Config, db *MailcloakDB, kc *Keycloak
case <-ctx.Done(): case <-ctx.Done():
return nil return nil
default: default:
log.Printf("policy accept error: %v", err)
return err return err
} }
} }
@@ -74,7 +72,7 @@ func ServePolicy(ctx context.Context, cfg *Config, db *MailcloakDB, kc *Keycloak
} }
} }
func RunPolicy(ctx context.Context, cfg *Config, db *MailcloakDB, kc *Keycloak, cache *Cache) error { func RunPolicy(ctx context.Context, cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache) error {
l, err := OpenPolicyListener(cfg) l, err := OpenPolicyListener(cfg)
if err != nil { if err != nil {
return err return err
@@ -82,7 +80,7 @@ func RunPolicy(ctx context.Context, cfg *Config, db *MailcloakDB, kc *Keycloak,
return ServePolicy(ctx, cfg, db, kc, cache, l) return ServePolicy(ctx, cfg, db, kc, cache, l)
} }
func handlePolicyConn(conn net.Conn, cfg *Config, db *MailcloakDB, 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)
@@ -101,8 +99,6 @@ func handlePolicyConn(conn net.Conn, cfg *Config, db *MailcloakDB, kc *Keycloak,
} }
} }
log.Printf("policy request: state=%s sasl=%s sender=%s rcpt=%s client=%s helo=%s", req["protocol_state"], req["sasl_username"], req["sender"], req["recipient"], req["client_address"], req["helo_name"])
// Decide based on protocol_state // Decide based on protocol_state
state := req["protocol_state"] // e.g. RCPT, MAIL state := req["protocol_state"] // e.g. RCPT, MAIL
saslUser := req["sasl_username"] saslUser := req["sasl_username"]
@@ -114,28 +110,19 @@ func handlePolicyConn(conn net.Conn, cfg *Config, db *MailcloakDB, kc *Keycloak,
switch state { switch state {
case "RCPT": case "RCPT":
action = policyRCPT(cfg, db, kc, cache, rcpt) action = policyRCPT(cfg, db, kc, cache, rcpt)
if action == "DUNNO" { case "MAIL":
// On MAIL stage we can validate sender if authenticated (submission)
if saslUser != "" && sender != "" {
action = policyMAIL(cfg, db, kc, cache, saslUser, sender) action = policyMAIL(cfg, db, kc, cache, saslUser, sender)
} }
case "MAIL":
// With "smtpd_delay_reject = yes" in Postfix, MAIL stage is bypassed
// So we move all checks to RCPT stage
action = "DUNNO"
// On MAIL stage we can validate sender if authenticated (submission)
//if saslUser != "" && sender != "" {
// action = policyMAIL(cfg, db, kc, cache, saslUser, sender)
//}
default: default:
action = "DUNNO" action = "DUNNO"
} }
log.Printf("policy decision: state=%s action=%s sasl=%s sender=%s rcpt=%s", state, action, saslUser, sender, rcpt)
fmt.Fprintf(conn, "action=%s\n\n", action) fmt.Fprintf(conn, "action=%s\n\n", action)
} }
func policyRCPT(cfg *Config, db *MailcloakDB, kc *Keycloak, cache *Cache, rcpt string) string { func policyRCPT(cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache, rcpt string) string {
if rcpt == "" { if rcpt == "" {
return "DUNNO" return "DUNNO"
} }
@@ -155,7 +142,6 @@ func policyRCPT(cfg *Config, db *MailcloakDB, kc *Keycloak, cache *Cache, rcpt s
defer cancel() defer cancel()
exists, err := kc.EmailExists(ctx, rcpt) exists, err := kc.EmailExists(ctx, rcpt)
if err != nil { if err != nil {
log.Printf("keycloak email exists lookup error for %s: %v", rcpt, err)
if cfg.Policy.KeycloakFailureMode == "dunno" { if cfg.Policy.KeycloakFailureMode == "dunno" {
return "DUNNO" return "DUNNO"
} }
@@ -180,7 +166,7 @@ func policyRCPT(cfg *Config, db *MailcloakDB, kc *Keycloak, cache *Cache, rcpt s
return "550 5.1.1 No such user" return "550 5.1.1 No such user"
} }
func policyMAIL(cfg *Config, db *MailcloakDB, kc *Keycloak, cache *Cache, saslUser, sender string) string { func policyMAIL(cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache, saslUser, sender string) string {
// Allow empty sender (bounce) // Allow empty sender (bounce)
if sender == "" || sender == "<>" { if sender == "" || sender == "<>" {
return "DUNNO" return "DUNNO"
@@ -198,7 +184,6 @@ func policyMAIL(cfg *Config, db *MailcloakDB, kc *Keycloak, cache *Cache, saslUs
defer cancel() defer cancel()
e, exists, err := kc.EmailByUsername(ctx, saslUser) e, exists, err := kc.EmailByUsername(ctx, saslUser)
if err != nil { if err != nil {
log.Printf("keycloak email-by-username lookup error for %s: %v", saslUser, err)
if cfg.Policy.KeycloakFailureMode == "dunno" { if cfg.Policy.KeycloakFailureMode == "dunno" {
return "DUNNO" return "DUNNO"
} }
@@ -223,17 +208,5 @@ func policyMAIL(cfg *Config, db *MailcloakDB, kc *Keycloak, cache *Cache, saslUs
return "DUNNO" return "DUNNO"
} }
// 3) sender is allowed for app (saslUser = app_id)
if saslUser != "" {
allowed, err := db.AppFromAllowed(saslUser, sender)
if err != nil {
log.Printf("sqlite app sender lookup error: %v", err)
return "451 4.3.0 Temporary internal error"
}
if allowed {
return "DUNNO"
}
}
return "553 5.7.1 Sender not owned by authenticated user" return "553 5.7.1 Sender not owned by authenticated user"
} }

View File

@@ -2,7 +2,6 @@ package mailcloak
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"os/user" "os/user"
"strconv" "strconv"
@@ -30,7 +29,6 @@ func DropPrivileges(cfg *Config) error {
euid := os.Geteuid() euid := os.Geteuid()
if euid == uid { if euid == uid {
log.Printf("privileges: already running as %s", userName)
return nil return nil
} }
if euid != 0 { if euid != 0 {
@@ -58,7 +56,5 @@ func DropPrivileges(cfg *Config) error {
return fmt.Errorf("setuid: %w", err) return fmt.Errorf("setuid: %w", err)
} }
log.Printf("privileges: dropped to %s (uid=%d gid=%d)", userName, uid, gid)
return nil return nil
} }

View File

@@ -2,7 +2,6 @@ package mailcloak
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"os/user" "os/user"
"strconv" "strconv"
@@ -28,6 +27,5 @@ func ChownChmodSocket(path string, cfg *Config) error {
if err != nil { if err != nil {
return fmt.Errorf("bad socket_mode: %w", err) return fmt.Errorf("bad socket_mode: %w", err)
} }
log.Printf("socket perms: chown %s:%s mode %s on %s", cfg.Sockets.SocketOwnerUser, cfg.Sockets.SocketOwnerGroup, cfg.Sockets.SocketMode, path)
return os.Chmod(path, os.FileMode(mode)) return os.Chmod(path, os.FileMode(mode))
} }

View File

@@ -3,10 +3,8 @@ package mailcloak
import ( import (
"bufio" "bufio"
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"log"
"net" "net"
"os" "os"
"strconv" "strconv"
@@ -26,12 +24,11 @@ func OpenSocketmapListener(cfg *Config) (net.Listener, error) {
_ = l.Close() _ = l.Close()
return nil, err return nil, err
} }
log.Printf("socketmap listener ready on %s", sock)
return l, nil return l, nil
} }
func ServeSocketmap(ctx context.Context, cfg *Config, db *MailcloakDB, l net.Listener) error { func ServeSocketmap(ctx context.Context, cfg *Config, db *AliasDB, l net.Listener) error {
defer l.Close() defer l.Close()
for { for {
@@ -41,7 +38,6 @@ func ServeSocketmap(ctx context.Context, cfg *Config, db *MailcloakDB, l net.Lis
case <-ctx.Done(): case <-ctx.Done():
return nil return nil
default: default:
log.Printf("socketmap accept error: %v", err)
return err return err
} }
} }
@@ -49,7 +45,7 @@ func ServeSocketmap(ctx context.Context, cfg *Config, db *MailcloakDB, l net.Lis
} }
} }
func RunSocketmap(ctx context.Context, cfg *Config, db *MailcloakDB) error { func RunSocketmap(ctx context.Context, cfg *Config, db *AliasDB) error {
l, err := OpenSocketmapListener(cfg) l, err := OpenSocketmapListener(cfg)
if err != nil { if err != nil {
return err return err
@@ -58,40 +54,33 @@ func RunSocketmap(ctx context.Context, cfg *Config, db *MailcloakDB) error {
} }
// Postfix socketmap framing: "<len>:<payload>," // Postfix socketmap framing: "<len>:<payload>,"
func handleSocketmapConn(conn net.Conn, cfg *Config, db *MailcloakDB) { func handleSocketmapConn(conn net.Conn, cfg *Config, db *AliasDB) {
defer conn.Close() defer conn.Close()
r := bufio.NewReader(conn) r := bufio.NewReader(conn)
for { for {
payload, err := readSocketmapFrame(r) payload, err := readSocketmapFrame(r)
if err != nil { if err != nil {
if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
log.Printf("socketmap read error: %v", err)
}
// normal close // normal close
return return
} }
payload = strings.TrimSpace(payload) payload = strings.TrimSpace(payload)
if payload == "" { if payload == "" {
log.Printf("socketmap request: empty payload")
_ = writeSocketmapFrame(conn, "NOTFOUND") _ = writeSocketmapFrame(conn, "NOTFOUND")
continue continue
} }
parts := strings.SplitN(payload, " ", 2) parts := strings.SplitN(payload, " ", 2)
if len(parts) != 2 { if len(parts) != 2 {
log.Printf("socketmap request: malformed payload=%q", payload)
_ = writeSocketmapFrame(conn, "TEMP") _ = writeSocketmapFrame(conn, "TEMP")
continue continue
} }
mapName := parts[0] mapName := parts[0]
key := strings.ToLower(strings.TrimSpace(parts[1])) key := strings.ToLower(strings.TrimSpace(parts[1]))
log.Printf("socketmap request: map=%s key=%s", mapName, key)
if mapName != "alias" { if mapName != "alias" {
log.Printf("socketmap decision: map=%s action=NOTFOUND", mapName)
_ = writeSocketmapFrame(conn, "NOTFOUND") _ = writeSocketmapFrame(conn, "NOTFOUND")
continue continue
} }
@@ -99,27 +88,22 @@ func handleSocketmapConn(conn net.Conn, cfg *Config, db *MailcloakDB) {
// Only handle our domain // Only handle our domain
domain := strings.ToLower(cfg.Policy.Domain) domain := strings.ToLower(cfg.Policy.Domain)
if !strings.HasSuffix(key, "@"+domain) { if !strings.HasSuffix(key, "@"+domain) {
log.Printf("socketmap decision: map=alias key=%s action=NOTFOUND (other domain)", key)
_ = writeSocketmapFrame(conn, "NOTFOUND") _ = writeSocketmapFrame(conn, "NOTFOUND")
continue continue
} }
username, ok, err := db.AliasOwner(key) username, ok, err := db.AliasOwner(key)
if err != nil { if err != nil {
log.Printf("socketmap db error: key=%s err=%v", key, err)
_ = writeSocketmapFrame(conn, "TEMP") _ = writeSocketmapFrame(conn, "TEMP")
continue continue
} }
if !ok { if !ok {
log.Printf("socketmap decision: map=alias key=%s action=NOTFOUND", key)
_ = writeSocketmapFrame(conn, "NOTFOUND") _ = writeSocketmapFrame(conn, "NOTFOUND")
continue continue
} }
// rewrite alias -> username@domain // rewrite alias -> username@domain
reply := fmt.Sprintf("OK %s@%s", username, domain) _ = writeSocketmapFrame(conn, fmt.Sprintf("OK %s@%s", username, domain))
log.Printf("socketmap decision: map=alias key=%s action=%s", key, reply)
_ = writeSocketmapFrame(conn, reply)
} }
} }

View File

@@ -3,43 +3,50 @@ package mailcloak
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"log"
"os"
"path/filepath"
"strings"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
type MailcloakDB struct{ DB *sql.DB } type AliasDB struct{ DB *sql.DB }
func OpenMailcloakDB(path string) (*MailcloakDB, error) {
log.Printf("sqlite: opening db at %s", path)
if err := ensureDBExists(path); err != nil {
return nil, err
}
func OpenAliasDB(path string) (*AliasDB, error) {
db, err := sql.Open("sqlite", path) db, err := sql.Open("sqlite", path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if _, err := db.Exec(` if _, err := db.Exec(`
PRAGMA foreign_keys=ON; PRAGMA foreign_keys=ON;
PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS aliases (
PRAGMA synchronous=NORMAL; 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
);
`); err != nil { `); err != nil {
_ = db.Close() _ = db.Close()
return nil, fmt.Errorf("init pragmas: %w", err) return nil, fmt.Errorf("init schema: %w", err)
} }
log.Printf("sqlite: db ready") return &AliasDB{DB: db}, nil
return &MailcloakDB{DB: db}, nil
} }
func (a *MailcloakDB) Close() error { return a.DB.Close() } func (a *AliasDB) Close() error { return a.DB.Close() }
// Returns username owning alias, ok // Returns username owning alias, ok
func (a *MailcloakDB) AliasOwner(aliasEmail string) (string, bool, error) { func (a *AliasDB) AliasOwner(aliasEmail string) (string, bool, error) {
var username string var username string
var enabled int var enabled int
err := a.DB.QueryRow(`SELECT username, enabled FROM aliases WHERE alias_email=?`, aliasEmail).Scan(&username, &enabled) err := a.DB.QueryRow(`SELECT username, enabled FROM aliases WHERE alias_email=?`, aliasEmail).Scan(&username, &enabled)
@@ -56,7 +63,7 @@ func (a *MailcloakDB) AliasOwner(aliasEmail string) (string, bool, error) {
} }
// Returns true if alias belongs to username // Returns true if alias belongs to username
func (a *MailcloakDB) AliasBelongsTo(aliasEmail, username string) (bool, error) { func (a *AliasDB) AliasBelongsTo(aliasEmail, username string) (bool, error) {
var enabled int var enabled int
err := a.DB.QueryRow(`SELECT enabled FROM aliases WHERE alias_email=? AND username=?`, aliasEmail, username).Scan(&enabled) err := a.DB.QueryRow(`SELECT enabled FROM aliases WHERE alias_email=? AND username=?`, aliasEmail, username).Scan(&enabled)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@@ -67,53 +74,3 @@ func (a *MailcloakDB) AliasBelongsTo(aliasEmail, username string) (bool, error)
} }
return enabled == 1, nil return enabled == 1, nil
} }
// Returns true if app_id is enabled and sender is allowed for app
func (a *MailcloakDB) AppFromAllowed(appID, fromAddr string) (bool, error) {
var appEnabled int
var fromEnabled int
err := a.DB.QueryRow(`
SELECT a.enabled, af.enabled
FROM app_from af
JOIN apps a ON a.app_id = af.app_id
WHERE af.app_id=? AND af.from_addr=?`, appID, fromAddr).Scan(&appEnabled, &fromEnabled)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return appEnabled == 1 && fromEnabled == 1, nil
}
func ensureDBExists(path string) error {
if path == ":memory:" || strings.HasPrefix(path, "file:") {
return nil
}
if path == "" {
return fmt.Errorf("sqlite path is empty")
}
dir := filepath.Dir(path)
if dir == "." || dir == "" {
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("sqlite db not found at %s; create it with 'mailcloakctl init'", path)
}
return err
}
return nil
}
if _, err := os.Stat(dir); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("sqlite db directory not found at %s; create the db with 'mailcloakctl init'", dir)
}
return err
}
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("sqlite db not found at %s; create it with 'mailcloakctl init'", path)
}
return err
}
return nil
}

View File

@@ -4,38 +4,23 @@ import sqlite3
import time import time
import sys import sys
from argon2 import PasswordHasher, Type from argon2 import PasswordHasher, Type
from pathlib import Path
DEFAULT_DB = "/var/lib/mailcloak/state.db" DEFAULT_DB = "/var/lib/mailcloak/state.db"
SCHEMA = """
def connect(db_path: str, create: bool = False):
db_file = Path(db_path).expanduser().resolve()
if not create and not db_file.exists():
raise SystemExit(f"sqlite db not found at {db_file}; create it with mailcloakctl init")
if create:
db_file.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(str(db_file))
con.execute("PRAGMA foreign_keys=ON;")
con.execute("PRAGMA journal_mode=WAL;")
con.execute("PRAGMA synchronous=NORMAL;")
con.executescript("""
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,
enabled INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
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 ( CREATE TABLE IF NOT EXISTS apps (
app_id TEXT PRIMARY KEY, app_id TEXT PRIMARY KEY,
secret_hash TEXT NOT NULL, secret_hash TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS app_from ( CREATE TABLE IF NOT EXISTS app_from (
app_id TEXT NOT NULL, app_id TEXT NOT NULL,
from_addr TEXT NOT NULL, from_addr TEXT NOT NULL,
@@ -44,7 +29,13 @@ CREATE TABLE IF NOT EXISTS app_from (
FOREIGN KEY (app_id) REFERENCES apps(app_id) ON DELETE CASCADE 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 return con
def norm_email(s: str) -> str: def norm_email(s: str) -> str:
@@ -98,7 +89,7 @@ def cmd_aliases_list(con, username=None):
def cmd_apps_add(con, app_id, password): def cmd_apps_add(con, app_id, password):
app_id = norm_id(app_id) app_id = norm_id(app_id)
secret_hash = f"{{ARGON2ID}}{argon2_hasher.hash(password)}" secret_hash = argon2_hasher.hash(password)
now = int(time.time()) now = int(time.time())
con.execute( con.execute(
"INSERT INTO apps(app_id, secret_hash, enabled, created_at) VALUES(?,?,1,?) " "INSERT INTO apps(app_id, secret_hash, enabled, created_at) VALUES(?,?,1,?) "
@@ -134,27 +125,12 @@ def cmd_apps_list(con):
).fetchall() ).fetchall()
for app_id,en,ts in rows: for app_id,en,ts in rows:
print(f"{app_id}\t{'enabled' if en else 'disabled'}\t{ts}") print(f"{app_id}\t{'enabled' if en else 'disabled'}\t{ts}")
from_rows = con.execute(
"SELECT from_addr, enabled FROM app_from WHERE app_id=? ORDER BY from_addr",
(app_id,),
).fetchall()
if from_rows:
parts = [f"{addr}" if en else f"{addr} (disabled)" for addr, en in from_rows]
print("\t\t" + ", ".join(parts))
else:
print("\t\t-")
def cmd_init(db_path: str):
con = connect(db_path, create=True)
con.close()
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--db", default=DEFAULT_DB) ap.add_argument("--db", default=DEFAULT_DB)
sub = ap.add_subparsers(dest="group", required=True) sub = ap.add_subparsers(dest="group", required=True)
p_init = sub.add_parser("init")
aliases = sub.add_parser("aliases") aliases = sub.add_parser("aliases")
aliases_sub = aliases.add_subparsers(dest="cmd", required=True) aliases_sub = aliases.add_subparsers(dest="cmd", required=True)
@@ -192,10 +168,6 @@ def main():
p_app_ls = apps_sub.add_parser("list") p_app_ls = apps_sub.add_parser("list")
args = ap.parse_args() args = ap.parse_args()
if args.group == "init":
cmd_init(args.db)
return
con = connect(args.db) con = connect(args.db)
try: try:
if args.group == "aliases": if args.group == "aliases":

BIN
state.db Normal file

Binary file not shown.