Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57966de4fa | ||
|
|
41f6a63687 | ||
|
|
3269654f42 | ||
|
|
1645ff9853 |
@@ -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.22.x"
|
go-version: "1.25.x"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
@@ -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:
|
||||||
|
|||||||
5
Makefile
5
Makefile
@@ -1,10 +1,11 @@
|
|||||||
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)
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||||
|
go build -trimpath -ldflags="-s -w" -o bin/mailcloak ./cmd/mailcloak
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go run ./cmd/$(BINARY)
|
go run ./cmd/$(BINARY)
|
||||||
|
|||||||
47
README.md
47
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.
|
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,34 +50,49 @@ 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
|
||||||
|
|
||||||
|
### Aliases
|
||||||
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.
|
||||||
|
|
||||||
|
### 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):
|
||||||
```
|
```
|
||||||
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
|
||||||
|
|||||||
96
aliasctl
96
aliasctl
@@ -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)
|
|
||||||
}
|
|
||||||
92
cmd/mailcloak/main.go
Normal file
92
cmd/mailcloak/main.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mailcloak/internal/mailcloak"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Printf("mailcloak %s\n", version)
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "--version" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgPath := "/etc/mailcloak/config.yaml"
|
||||||
|
if len(os.Args) >= 2 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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() {
|
||||||
|
log.Printf("socketmap server started")
|
||||||
|
if err := mailcloak.ServeSocketmap(ctx, cfg, db, socketmapListener); err != nil {
|
||||||
|
log.Fatalf("socketmap: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
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}
|
# 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"
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
21
configs/openrc-mailcloak
Normal file
21
configs/openrc-mailcloak
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/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:root /etc/mailcloak
|
||||||
|
checkpath -d -m 0750 -o mailcloak:dovecot /var/lib/mailcloak
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,8 +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);
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package kcpolicy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AliasDB struct{ DB *sql.DB }
|
|
||||||
|
|
||||||
func OpenAliasDB(path string) (*AliasDB, error) {
|
|
||||||
db, err := sql.Open("sqlite", path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if _, err := db.Exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS aliases (
|
|
||||||
alias_email TEXT PRIMARY KEY,
|
|
||||||
username TEXT NOT NULL,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_aliases_username ON aliases(username);
|
|
||||||
`); err != nil {
|
|
||||||
_ = db.Close()
|
|
||||||
return nil, fmt.Errorf("init schema: %w", err)
|
|
||||||
}
|
|
||||||
return &AliasDB{DB: db}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AliasDB) Close() error { return a.DB.Close() }
|
|
||||||
|
|
||||||
// Returns username owning alias, ok
|
|
||||||
func (a *AliasDB) AliasOwner(aliasEmail string) (string, bool, error) {
|
|
||||||
var username string
|
|
||||||
var enabled int
|
|
||||||
err := a.DB.QueryRow(`SELECT username, enabled FROM aliases WHERE alias_email=?`, aliasEmail).Scan(&username, &enabled)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", false, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return "", false, err
|
|
||||||
}
|
|
||||||
if enabled != 1 {
|
|
||||||
return "", false, nil
|
|
||||||
}
|
|
||||||
return username, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns true if alias belongs to username
|
|
||||||
func (a *AliasDB) AliasBelongsTo(aliasEmail, username string) (bool, error) {
|
|
||||||
var enabled int
|
|
||||||
err := a.DB.QueryRow(`SELECT enabled FROM aliases WHERE alias_email=? AND username=?`, aliasEmail, username).Scan(&enabled)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return enabled == 1, nil
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
package kcpolicy
|
package mailcloak
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
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"`
|
||||||
@@ -54,9 +59,15 @@ 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 == "" {
|
||||||
|
cfg.Daemon.User = "mailcloak"
|
||||||
|
log.Printf("config: daemon.user not set, defaulting to %s", cfg.Daemon.User)
|
||||||
}
|
}
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
package kcpolicy
|
package mailcloak
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -43,18 +44,22 @@ 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
|
||||||
@@ -82,15 +87,18 @@ 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
|
||||||
@@ -108,11 +116,13 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,11 +146,13 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package kcpolicy
|
package mailcloak
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -38,19 +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
|
||||||
}
|
}
|
||||||
|
log.Printf("policy listener ready on %s", sock)
|
||||||
|
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ServePolicy(ctx context.Context, cfg *Config, db *MailcloakDB, kc *Keycloak, cache *Cache, l net.Listener) error {
|
||||||
|
defer l.Close()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
conn, err := l.Accept()
|
conn, err := l.Accept()
|
||||||
@@ -59,6 +66,7 @@ func RunPolicy(ctx context.Context, cfg *Config, db *AliasDB, kc *Keycloak, cach
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
|
log.Printf("policy accept error: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,7 +74,15 @@ func RunPolicy(ctx context.Context, cfg *Config, db *AliasDB, kc *Keycloak, cach
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handlePolicyConn(conn net.Conn, cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache) {
|
func RunPolicy(ctx context.Context, cfg *Config, db *MailcloakDB, 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 *MailcloakDB, kc *Keycloak, cache *Cache) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
r := bufio.NewReader(conn)
|
r := bufio.NewReader(conn)
|
||||||
|
|
||||||
@@ -85,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
|
// 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"]
|
||||||
@@ -96,19 +114,28 @@ func handlePolicyConn(conn net.Conn, cfg *Config, db *AliasDB, kc *Keycloak, cac
|
|||||||
switch state {
|
switch state {
|
||||||
case "RCPT":
|
case "RCPT":
|
||||||
action = policyRCPT(cfg, db, kc, cache, rcpt)
|
action = policyRCPT(cfg, db, kc, cache, rcpt)
|
||||||
case "MAIL":
|
if action == "DUNNO" {
|
||||||
// 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 *AliasDB, kc *Keycloak, cache *Cache, rcpt string) string {
|
func policyRCPT(cfg *Config, db *MailcloakDB, kc *Keycloak, cache *Cache, rcpt string) string {
|
||||||
if rcpt == "" {
|
if rcpt == "" {
|
||||||
return "DUNNO"
|
return "DUNNO"
|
||||||
}
|
}
|
||||||
@@ -128,6 +155,7 @@ func policyRCPT(cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache, rcpt strin
|
|||||||
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"
|
||||||
}
|
}
|
||||||
@@ -152,7 +180,7 @@ func policyRCPT(cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache, rcpt strin
|
|||||||
return "550 5.1.1 No such user"
|
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)
|
// Allow empty sender (bounce)
|
||||||
if sender == "" || sender == "<>" {
|
if sender == "" || sender == "<>" {
|
||||||
return "DUNNO"
|
return "DUNNO"
|
||||||
@@ -170,6 +198,7 @@ func policyMAIL(cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache, saslUser,
|
|||||||
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"
|
||||||
}
|
}
|
||||||
@@ -194,5 +223,17 @@ func policyMAIL(cfg *Config, db *AliasDB, kc *Keycloak, cache *Cache, saslUser,
|
|||||||
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"
|
||||||
}
|
}
|
||||||
64
internal/mailcloak/privileges.go
Normal file
64
internal/mailcloak/privileges.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package mailcloak
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"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 {
|
||||||
|
log.Printf("privileges: already running as %s", userName)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("privileges: dropped to %s (uid=%d gid=%d)", userName, uid, gid)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
package kcpolicy
|
package mailcloak
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/user"
|
"os/user"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -27,5 +28,6 @@ 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))
|
||||||
}
|
}
|
||||||
@@ -1,29 +1,38 @@
|
|||||||
package kcpolicy
|
package mailcloak
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"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
|
||||||
}
|
}
|
||||||
|
log.Printf("socketmap listener ready on %s", sock)
|
||||||
|
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ServeSocketmap(ctx context.Context, cfg *Config, db *MailcloakDB, l net.Listener) error {
|
||||||
|
defer l.Close()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
conn, err := l.Accept()
|
conn, err := l.Accept()
|
||||||
@@ -32,6 +41,7 @@ func RunSocketmap(ctx context.Context, cfg *Config, db *AliasDB) error {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
|
log.Printf("socketmap accept error: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,34 +49,49 @@ 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
|
||||||
|
}
|
||||||
|
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 *MailcloakDB) {
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -74,22 +99,27 @@ func handleSocketmapConn(conn net.Conn, cfg *Config, db *AliasDB) {
|
|||||||
// 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
|
||||||
_ = 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
119
internal/mailcloak/sqlite.go
Normal file
119
internal/mailcloak/sqlite.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package mailcloak
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA journal_mode=WAL;
|
||||||
|
PRAGMA synchronous=NORMAL;
|
||||||
|
`); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("init pragmas: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("sqlite: db ready")
|
||||||
|
|
||||||
|
return &MailcloakDB{DB: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *MailcloakDB) Close() error { return a.DB.Close() }
|
||||||
|
|
||||||
|
// Returns username owning alias, ok
|
||||||
|
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)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if enabled != 1 {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return username, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if alias belongs to username
|
||||||
|
func (a *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 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
225
mailcloakctl
Executable file
225
mailcloakctl
Executable file
@@ -0,0 +1,225 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
from argon2 import PasswordHasher, Type
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DEFAULT_DB = "/var/lib/mailcloak/state.db"
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
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 = f"{{ARGON2ID}}{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}")
|
||||||
|
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)
|
||||||
|
|
||||||
|
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()
|
||||||
|
if args.group == "init":
|
||||||
|
cmd_init(args.db)
|
||||||
|
return
|
||||||
|
|
||||||
|
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