From 179866e6d386fab8137b38c7ebfed98dd352ef11 Mon Sep 17 00:00:00 2001 From: peio Date: Fri, 23 Jan 2026 17:54:15 +0000 Subject: [PATCH] feat: enhance logging and refactor database handling --- .gitea/workflows/release.yml | 2 +- Makefile | 7 +-- README.md | 15 +++++ cmd/mailcloak/main.go | 11 +++- configs/openrc-mailcloak | 6 +- db-init.sql | 23 ------- internal/mailcloak/config.go | 4 ++ internal/mailcloak/keycloak.go | 12 ++++ internal/mailcloak/policy.go | 43 ++++++++++--- internal/mailcloak/privileges.go | 4 ++ internal/mailcloak/socket_perms.go | 2 + internal/mailcloak/socketmap.go | 24 ++++++-- internal/mailcloak/sqlite.go | 97 +++++++++++++++++++++--------- mailcloakctl | 64 ++++++++++++++------ 14 files changed, 222 insertions(+), 92 deletions(-) delete mode 100644 db-init.sql diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 9b1a67b..8ff5abe 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -22,7 +22,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "1.22.x" + go-version: "1.25.x" cache: true - name: Build diff --git a/Makefile b/Makefile index 04908d9..207924a 100644 --- a/Makefile +++ b/Makefile @@ -4,11 +4,8 @@ BIN_DIR := bin .PHONY: build run test tidy clean install build: - export CGO_ENABLED=0 - go build \ - -trimpath \ - -ldflags="-s -w" \ - -o "$(BIN_DIR)/$(BINARY)" ./cmd/$(BINARY) + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o bin/mailcloak ./cmd/mailcloak run: go run ./cmd/$(BINARY) diff --git a/README.md b/README.md index 2ca5142..54206a2 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ Key settings: - `sockets.*` must be under the Postfix chroot (usually `/var/spool/postfix`). ## Mailcloak database + +### Aliases You can manage aliases using the helper script: ```bash @@ -60,6 +62,19 @@ You can manage aliases using the helper script: 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) Policy service (smtpd_recipient_restrictions): ``` diff --git a/cmd/mailcloak/main.go b/cmd/mailcloak/main.go index 62886b7..86dae56 100644 --- a/cmd/mailcloak/main.go +++ b/cmd/mailcloak/main.go @@ -15,8 +15,8 @@ import ( var version = "dev" func main() { + fmt.Printf("mailcloak %s\n", version) if len(os.Args) > 1 && os.Args[1] == "--version" { - fmt.Println(version) return } @@ -25,29 +25,34 @@ func main() { cfgPath = os.Args[1] } + log.Printf("loading config from %s", cfgPath) cfg, err := mailcloak.LoadConfig(cfgPath) if err != nil { log.Fatalf("config: %v", err) } + log.Printf("opening policy listener at %s", cfg.Sockets.PolicySocket) policyListener, err := mailcloak.OpenPolicyListener(cfg) if err != nil { log.Fatalf("policy listener: %v", err) } + log.Printf("opening socketmap listener at %s", cfg.Sockets.SocketmapSocket) socketmapListener, err := mailcloak.OpenSocketmapListener(cfg) if err != nil { _ = policyListener.Close() log.Fatalf("socketmap listener: %v", err) } + log.Printf("dropping privileges to %s", cfg.Daemon.User) if err := mailcloak.DropPrivileges(cfg); err != nil { _ = policyListener.Close() _ = socketmapListener.Close() log.Fatalf("privileges: %v", err) } - db, err := mailcloak.OpenAliasDB(cfg.SQLite.Path) + log.Printf("opening sqlite db at %s", cfg.SQLite.Path) + db, err := mailcloak.OpenMailcloakDB(cfg.SQLite.Path) if err != nil { log.Fatalf("sqlite: %v", err) } @@ -61,6 +66,7 @@ func main() { // Start socketmap server go func() { + log.Printf("socketmap server started") if err := mailcloak.ServeSocketmap(ctx, cfg, db, socketmapListener); err != nil { log.Fatalf("socketmap: %v", err) } @@ -68,6 +74,7 @@ func main() { // Start policy server go func() { + log.Printf("policy server started") if err := mailcloak.ServePolicy(ctx, cfg, db, kc, cache, policyListener); err != nil { log.Fatalf("policy: %v", err) } diff --git a/configs/openrc-mailcloak b/configs/openrc-mailcloak index c7355e8..8e2aaed 100644 --- a/configs/openrc-mailcloak +++ b/configs/openrc-mailcloak @@ -12,10 +12,8 @@ depend() { } 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 + checkpath -d -m 0750 -o root:root /etc/mailcloak + checkpath -d -m 0750 -o mailcloak:dovecot /var/lib/mailcloak } stop_post() { diff --git a/db-init.sql b/db-init.sql deleted file mode 100644 index fcc5d66..0000000 --- a/db-init.sql +++ /dev/null @@ -1,23 +0,0 @@ -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 -); \ No newline at end of file diff --git a/internal/mailcloak/config.go b/internal/mailcloak/config.go index 75b6213..0f62025 100644 --- a/internal/mailcloak/config.go +++ b/internal/mailcloak/config.go @@ -2,6 +2,7 @@ package mailcloak import ( "fmt" + "log" "os" "gopkg.in/yaml.v3" @@ -58,12 +59,15 @@ func LoadConfig(path string) (*Config, error) { } if cfg.Policy.CacheTTLSeconds <= 0 { cfg.Policy.CacheTTLSeconds = 120 + log.Printf("config: policy.cache_ttl_seconds not set, defaulting to %d", cfg.Policy.CacheTTLSeconds) } if cfg.Policy.KeycloakFailureMode == "" { cfg.Policy.KeycloakFailureMode = "tempfail" + log.Printf("config: policy.keycloak_failure_mode not set, defaulting to %s", cfg.Policy.KeycloakFailureMode) } if cfg.Daemon.User == "" { cfg.Daemon.User = "mailcloak" + log.Printf("config: daemon.user not set, defaulting to %s", cfg.Daemon.User) } return &cfg, nil } diff --git a/internal/mailcloak/keycloak.go b/internal/mailcloak/keycloak.go index dcfb731..775d46d 100644 --- a/internal/mailcloak/keycloak.go +++ b/internal/mailcloak/keycloak.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "net/url" "strings" @@ -43,18 +44,22 @@ func (k *Keycloak) token(ctx context.Context) (string, error) { resp, err := k.hc.Do(req) if err != nil { + log.Printf("keycloak token request error: %v", err) return "", err } defer resp.Body.Close() if resp.StatusCode/100 != 2 { 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)) } var tr tokenResp if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { + log.Printf("keycloak token decode error: %v", err) return "", err } if tr.AccessToken == "" { + log.Printf("keycloak token response missing access_token") return "", fmt.Errorf("empty access_token") } return tr.AccessToken, nil @@ -82,15 +87,18 @@ func (k *Keycloak) adminGet(ctx context.Context, bearer, path string, q url.Valu resp, err := k.hc.Do(req) if err != nil { + log.Printf("keycloak admin request error: %v", err) return nil, err } defer resp.Body.Close() if resp.StatusCode/100 != 2 { 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)) } var users []kcUser if err := json.NewDecoder(resp.Body).Decode(&users); err != nil { + log.Printf("keycloak admin decode error: %v", err) return nil, err } return users, nil @@ -108,11 +116,13 @@ func (k *Keycloak) EmailByUsername(ctx context.Context, username string) (string q.Set("exact", "true") users, err := k.adminGet(ctx, bearer, "/users", q) if err != nil { + log.Printf("keycloak admin exact username lookup failed for %s: %v", username, err) // fallback: search q2 := url.Values{} q2.Set("search", username) users, err = k.adminGet(ctx, bearer, "/users", q2) if err != nil { + log.Printf("keycloak admin search username lookup failed for %s: %v", username, err) return "", false, err } } @@ -136,11 +146,13 @@ func (k *Keycloak) EmailExists(ctx context.Context, email string) (bool, error) q.Set("exact", "true") users, err := k.adminGet(ctx, bearer, "/users", q) if err != nil { + log.Printf("keycloak admin exact email lookup failed for %s: %v", email, err) // fallback: search q2 := url.Values{} q2.Set("search", email) users, err = k.adminGet(ctx, bearer, "/users", q2) if err != nil { + log.Printf("keycloak admin search email lookup failed for %s: %v", email, err) return false, err } } diff --git a/internal/mailcloak/policy.go b/internal/mailcloak/policy.go index 4dcf30d..d40c32b 100644 --- a/internal/mailcloak/policy.go +++ b/internal/mailcloak/policy.go @@ -51,11 +51,12 @@ func OpenPolicyListener(cfg *Config) (net.Listener, error) { _ = l.Close() return nil, err } + log.Printf("policy listener ready on %s", sock) return l, nil } -func ServePolicy(ctx context.Context, cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache, l net.Listener) error { +func ServePolicy(ctx context.Context, cfg *Config, db *MailcloakDB, kc *Keycloak, cache *Cache, l net.Listener) error { defer l.Close() for { @@ -65,6 +66,7 @@ func ServePolicy(ctx context.Context, cfg *Config, db *AliasDB, kc *Keycloak, ca case <-ctx.Done(): return nil default: + log.Printf("policy accept error: %v", err) return err } } @@ -72,7 +74,7 @@ func ServePolicy(ctx context.Context, cfg *Config, db *AliasDB, kc *Keycloak, ca } } -func RunPolicy(ctx context.Context, cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache) error { +func RunPolicy(ctx context.Context, cfg *Config, db *MailcloakDB, kc *Keycloak, cache *Cache) error { l, err := OpenPolicyListener(cfg) if err != nil { return err @@ -80,7 +82,7 @@ func RunPolicy(ctx context.Context, cfg *Config, db *AliasDB, kc *Keycloak, cach 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 *MailcloakDB, kc *Keycloak, cache *Cache) { defer conn.Close() r := bufio.NewReader(conn) @@ -99,6 +101,8 @@ func handlePolicyConn(conn net.Conn, cfg *Config, db *AliasDB, kc *Keycloak, cac } } + 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 state := req["protocol_state"] // e.g. RCPT, MAIL saslUser := req["sasl_username"] @@ -110,19 +114,28 @@ func handlePolicyConn(conn net.Conn, cfg *Config, db *AliasDB, kc *Keycloak, cac switch state { case "RCPT": action = policyRCPT(cfg, db, kc, cache, rcpt) - case "MAIL": - // On MAIL stage we can validate sender if authenticated (submission) - if saslUser != "" && sender != "" { + if action == "DUNNO" { 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: 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) } -func policyRCPT(cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache, rcpt string) string { +func policyRCPT(cfg *Config, db *MailcloakDB, kc *Keycloak, cache *Cache, rcpt string) string { if rcpt == "" { return "DUNNO" } @@ -142,6 +155,7 @@ func policyRCPT(cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache, rcpt strin defer cancel() exists, err := kc.EmailExists(ctx, rcpt) if err != nil { + log.Printf("keycloak email exists lookup error for %s: %v", rcpt, err) if cfg.Policy.KeycloakFailureMode == "dunno" { return "DUNNO" } @@ -166,7 +180,7 @@ func policyRCPT(cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache, rcpt strin return "550 5.1.1 No such user" } -func policyMAIL(cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache, saslUser, sender string) string { +func policyMAIL(cfg *Config, db *MailcloakDB, kc *Keycloak, cache *Cache, saslUser, sender string) string { // Allow empty sender (bounce) if sender == "" || sender == "<>" { return "DUNNO" @@ -184,6 +198,7 @@ func policyMAIL(cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache, saslUser, defer cancel() e, exists, err := kc.EmailByUsername(ctx, saslUser) if err != nil { + log.Printf("keycloak email-by-username lookup error for %s: %v", saslUser, err) if cfg.Policy.KeycloakFailureMode == "dunno" { return "DUNNO" } @@ -208,5 +223,17 @@ func policyMAIL(cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache, saslUser, 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" } diff --git a/internal/mailcloak/privileges.go b/internal/mailcloak/privileges.go index 20b66fb..f79a671 100644 --- a/internal/mailcloak/privileges.go +++ b/internal/mailcloak/privileges.go @@ -2,6 +2,7 @@ package mailcloak import ( "fmt" + "log" "os" "os/user" "strconv" @@ -29,6 +30,7 @@ func DropPrivileges(cfg *Config) error { euid := os.Geteuid() if euid == uid { + log.Printf("privileges: already running as %s", userName) return nil } if euid != 0 { @@ -56,5 +58,7 @@ func DropPrivileges(cfg *Config) error { return fmt.Errorf("setuid: %w", err) } + log.Printf("privileges: dropped to %s (uid=%d gid=%d)", userName, uid, gid) + return nil } diff --git a/internal/mailcloak/socket_perms.go b/internal/mailcloak/socket_perms.go index 54e9057..5907353 100644 --- a/internal/mailcloak/socket_perms.go +++ b/internal/mailcloak/socket_perms.go @@ -2,6 +2,7 @@ package mailcloak import ( "fmt" + "log" "os" "os/user" "strconv" @@ -27,5 +28,6 @@ func ChownChmodSocket(path string, cfg *Config) error { if err != nil { 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)) } diff --git a/internal/mailcloak/socketmap.go b/internal/mailcloak/socketmap.go index 3717064..20b66e4 100644 --- a/internal/mailcloak/socketmap.go +++ b/internal/mailcloak/socketmap.go @@ -3,8 +3,10 @@ package mailcloak import ( "bufio" "context" + "errors" "fmt" "io" + "log" "net" "os" "strconv" @@ -24,11 +26,12 @@ func OpenSocketmapListener(cfg *Config) (net.Listener, error) { _ = l.Close() return nil, err } + log.Printf("socketmap listener ready on %s", sock) return l, nil } -func ServeSocketmap(ctx context.Context, cfg *Config, db *AliasDB, l net.Listener) error { +func ServeSocketmap(ctx context.Context, cfg *Config, db *MailcloakDB, l net.Listener) error { defer l.Close() for { @@ -38,6 +41,7 @@ func ServeSocketmap(ctx context.Context, cfg *Config, db *AliasDB, l net.Listene case <-ctx.Done(): return nil default: + log.Printf("socketmap accept error: %v", err) return err } } @@ -45,7 +49,7 @@ func ServeSocketmap(ctx context.Context, cfg *Config, db *AliasDB, l net.Listene } } -func RunSocketmap(ctx context.Context, cfg *Config, db *AliasDB) error { +func RunSocketmap(ctx context.Context, cfg *Config, db *MailcloakDB) error { l, err := OpenSocketmapListener(cfg) if err != nil { return err @@ -54,33 +58,40 @@ func RunSocketmap(ctx context.Context, cfg *Config, db *AliasDB) error { } // Postfix socketmap framing: ":," -func handleSocketmapConn(conn net.Conn, cfg *Config, db *AliasDB) { +func handleSocketmapConn(conn net.Conn, cfg *Config, db *MailcloakDB) { defer conn.Close() r := bufio.NewReader(conn) for { payload, err := readSocketmapFrame(r) if err != nil { + if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + log.Printf("socketmap read error: %v", err) + } // normal close return } payload = strings.TrimSpace(payload) if payload == "" { + log.Printf("socketmap request: empty payload") _ = writeSocketmapFrame(conn, "NOTFOUND") continue } parts := strings.SplitN(payload, " ", 2) if len(parts) != 2 { + log.Printf("socketmap request: malformed payload=%q", payload) _ = writeSocketmapFrame(conn, "TEMP") continue } mapName := parts[0] key := strings.ToLower(strings.TrimSpace(parts[1])) + log.Printf("socketmap request: map=%s key=%s", mapName, key) if mapName != "alias" { + log.Printf("socketmap decision: map=%s action=NOTFOUND", mapName) _ = writeSocketmapFrame(conn, "NOTFOUND") continue } @@ -88,22 +99,27 @@ func handleSocketmapConn(conn net.Conn, cfg *Config, db *AliasDB) { // Only handle our domain domain := strings.ToLower(cfg.Policy.Domain) if !strings.HasSuffix(key, "@"+domain) { + log.Printf("socketmap decision: map=alias key=%s action=NOTFOUND (other domain)", key) _ = writeSocketmapFrame(conn, "NOTFOUND") continue } username, ok, err := db.AliasOwner(key) if err != nil { + log.Printf("socketmap db error: key=%s err=%v", key, err) _ = writeSocketmapFrame(conn, "TEMP") continue } if !ok { + log.Printf("socketmap decision: map=alias key=%s action=NOTFOUND", key) _ = writeSocketmapFrame(conn, "NOTFOUND") continue } // rewrite alias -> username@domain - _ = writeSocketmapFrame(conn, fmt.Sprintf("OK %s@%s", username, domain)) + reply := fmt.Sprintf("OK %s@%s", username, domain) + log.Printf("socketmap decision: map=alias key=%s action=%s", key, reply) + _ = writeSocketmapFrame(conn, reply) } } diff --git a/internal/mailcloak/sqlite.go b/internal/mailcloak/sqlite.go index b007709..a059659 100644 --- a/internal/mailcloak/sqlite.go +++ b/internal/mailcloak/sqlite.go @@ -3,50 +3,43 @@ package mailcloak import ( "database/sql" "fmt" + "log" + "os" + "path/filepath" + "strings" _ "modernc.org/sqlite" ) -type AliasDB struct{ DB *sql.DB } +type MailcloakDB 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) if err != nil { 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, - 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 -); +PRAGMA journal_mode=WAL; +PRAGMA synchronous=NORMAL; `); err != nil { _ = db.Close() - return nil, fmt.Errorf("init schema: %w", err) + return nil, fmt.Errorf("init pragmas: %w", err) } - return &AliasDB{DB: db}, nil + log.Printf("sqlite: db ready") + + return &MailcloakDB{DB: db}, nil } -func (a *AliasDB) Close() error { return a.DB.Close() } +func (a *MailcloakDB) Close() error { return a.DB.Close() } // Returns username owning alias, ok -func (a *AliasDB) AliasOwner(aliasEmail string) (string, bool, error) { +func (a *MailcloakDB) AliasOwner(aliasEmail string) (string, bool, error) { var username string var enabled int err := a.DB.QueryRow(`SELECT username, enabled FROM aliases WHERE alias_email=?`, aliasEmail).Scan(&username, &enabled) @@ -63,7 +56,7 @@ func (a *AliasDB) AliasOwner(aliasEmail string) (string, bool, error) { } // Returns true if alias belongs to username -func (a *AliasDB) AliasBelongsTo(aliasEmail, username string) (bool, error) { +func (a *MailcloakDB) AliasBelongsTo(aliasEmail, username string) (bool, error) { var enabled int err := a.DB.QueryRow(`SELECT enabled FROM aliases WHERE alias_email=? AND username=?`, aliasEmail, username).Scan(&enabled) if err == sql.ErrNoRows { @@ -74,3 +67,53 @@ func (a *AliasDB) AliasBelongsTo(aliasEmail, username string) (bool, error) { } 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 +} diff --git a/mailcloakctl b/mailcloakctl index 5a7dd32..2e8e550 100755 --- a/mailcloakctl +++ b/mailcloakctl @@ -4,38 +4,47 @@ import sqlite3 import time import sys from argon2 import PasswordHasher, Type +from pathlib import Path 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 ( 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 + 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 + 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: @@ -89,7 +98,7 @@ def cmd_aliases_list(con, username=None): def cmd_apps_add(con, app_id, password): app_id = norm_id(app_id) - secret_hash = argon2_hasher.hash(password) + secret_hash = f"{{ARGON2ID}}{argon2_hasher.hash(password)}" now = int(time.time()) con.execute( "INSERT INTO apps(app_id, secret_hash, enabled, created_at) VALUES(?,?,1,?) " @@ -125,12 +134,27 @@ def cmd_apps_list(con): ).fetchall() for app_id,en,ts in rows: 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(): ap = argparse.ArgumentParser() ap.add_argument("--db", default=DEFAULT_DB) sub = ap.add_subparsers(dest="group", required=True) + p_init = sub.add_parser("init") + aliases = sub.add_parser("aliases") aliases_sub = aliases.add_subparsers(dest="cmd", required=True) @@ -168,6 +192,10 @@ def main(): p_app_ls = apps_sub.add_parser("list") args = ap.parse_args() + if args.group == "init": + cmd_init(args.db) + return + con = connect(args.db) try: if args.group == "aliases":