Compare commits

4 Commits

Author SHA1 Message Date
peio
9447523c6b Rename to mailcloak 2026-01-21 19:56:07 +00:00
peio
6845060d00 fix: postfix socketmap protocol
All checks were successful
release / build (amd64, linux) (push) Successful in 29s
2026-01-19 17:18:37 +00:00
peio
762cb11389 rename aliasctl script and add it to release 2026-01-19 14:22:39 +00:00
peio
a7e6b5420e fix release workflow 2026-01-19 13:20:40 +00:00
20 changed files with 233 additions and 154 deletions

View File

@@ -25,7 +25,7 @@ jobs:
go-version: "1.22.x" go-version: "1.22.x"
cache: true cache: true
- name: Build (linux/musl) - name: Build
env: env:
GOOS: ${{ matrix.goos }} GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.arch }} GOARCH: ${{ matrix.arch }}
@@ -34,20 +34,23 @@ 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 mailcloakctl dist/
sha256sum "dist/${BIN}.tar.gz" >> dist/checksums.txt sha256sum "dist/${BIN}.tar.gz" >> 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/*.sha256 dist/mailcloakctl
dist/checksums.txt
generate_release_notes: true generate_release_notes: true
env: env:
GITEA_TOKEN: ${{ github.token }} GITEA_TOKEN: ${{ github.token }}

View File

@@ -1,10 +1,14 @@
BINARY := kc-policy BINARY := mailcloak
BIN_DIR := bin BIN_DIR := bin
.PHONY: build run test tidy clean install .PHONY: build run test tidy clean install
build: build:
go build -o $(BIN_DIR)/$(BINARY) ./cmd/$(BINARY) export CGO_ENABLED=0
go build \
-trimpath \
-ldflags="-s -w" \
-o "$(BIN_DIR)/$(BINARY)" ./cmd/$(BINARY)
run: run:
go run ./cmd/$(BINARY) go run ./cmd/$(BINARY)

View File

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

View File

@@ -9,7 +9,7 @@ import (
"syscall" "syscall"
"time" "time"
"kc-policy/internal/kcpolicy" "mailcloak/internal/mailcloak"
) )
var version = "dev" var version = "dev"
@@ -20,43 +20,43 @@ func main() {
return return
} }
cfgPath := "/etc/kc-policy/config.yaml" cfgPath := "/etc/mailcloak/config.yaml"
if len(os.Args) >= 2 { if len(os.Args) >= 2 {
cfgPath = os.Args[1] cfgPath = os.Args[1]
} }
cfg, err := kcpolicy.LoadConfig(cfgPath) cfg, err := mailcloak.LoadConfig(cfgPath)
if err != nil { if err != nil {
log.Fatalf("config: %v", err) log.Fatalf("config: %v", err)
} }
db, err := kcpolicy.OpenAliasDB(cfg.SQLite.Path) db, err := mailcloak.OpenAliasDB(cfg.SQLite.Path)
if err != nil { if err != nil {
log.Fatalf("sqlite: %v", err) log.Fatalf("sqlite: %v", err)
} }
defer db.Close() defer db.Close()
kc := kcpolicy.NewKeycloak(cfg) kc := mailcloak.NewKeycloak(cfg)
cache := kcpolicy.NewCache(time.Duration(cfg.Policy.CacheTTLSeconds) * time.Second) cache := mailcloak.NewCache(time.Duration(cfg.Policy.CacheTTLSeconds) * time.Second)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
// Start socketmap server // Start socketmap server
go func() { go func() {
if err := kcpolicy.RunSocketmap(ctx, cfg, db); err != nil { if err := mailcloak.RunSocketmap(ctx, cfg, db); err != nil {
log.Fatalf("socketmap: %v", err) log.Fatalf("socketmap: %v", err)
} }
}() }()
// Start policy server // Start policy server
go func() { go func() {
if err := kcpolicy.RunPolicy(ctx, cfg, db, kc, cache); err != nil { if err := mailcloak.RunPolicy(ctx, cfg, db, kc, cache); err != nil {
log.Fatalf("policy: %v", err) log.Fatalf("policy: %v", err)
} }
}() }()
log.Printf("kc-policy started") log.Printf("mailcloak started")
// Handle signals // Handle signals
ch := make(chan os.Signal, 2) ch := make(chan os.Signal, 2)

View File

@@ -6,7 +6,7 @@ keycloak:
# admin API is derived: {base_url}/admin/realms/{realm} # admin API is derived: {base_url}/admin/realms/{realm}
sqlite: sqlite:
path: "/var/lib/kc-policy/aliases.db" path: "/var/lib/mailcloak/state.db"
policy: policy:
domain: "<EMail domain-name>" domain: "<EMail domain-name>"
@@ -19,8 +19,8 @@ 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"

View File

@@ -1,23 +0,0 @@
#!/sbin/openrc-run
name="kc-policy"
command="/usr/local/sbin/kc-policy"
command_args="/etc/kc-policy/config.yaml"
command_background="yes"
pidfile="/run/kc-policy.pid"
depend() {
need net
after postfix
}
start_pre() {
checkpath -d -m 0750 -o root:postfix /etc/kc-policy
checkpath -d -m 0750 -o root:postfix /var/lib/kc-policy
checkpath -d -m 0755 -o root:root /usr/local/sbin
# sockets dir already exists
}
stop_post() {
rm -f /var/spool/postfix/private/kc-policy /var/spool/postfix/private/kc-socketmap
}

23
configs/openrc-mailcloak Normal file
View File

@@ -0,0 +1,23 @@
#!/sbin/openrc-run
name="mailcloak"
command="/usr/local/sbin/mailcloak"
command_args="/etc/mailcloak/config.yaml"
command_background="yes"
pidfile="/run/mailcloak.pid"
depend() {
need net
after postfix
}
start_pre() {
checkpath -d -m 0750 -o root:postfix /etc/mailcloak
checkpath -d -m 0750 -o root:postfix /var/lib/mailcloak
checkpath -d -m 0755 -o root:root /usr/local/sbin
# sockets dir already exists
}
stop_post() {
rm -f /var/spool/postfix/private/mailcloak-policy /var/spool/postfix/private/mailcloak-socketmap
}

View File

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

View File

@@ -2,7 +2,7 @@
# Configuration to add to /etc/postfix/master.cf # Configuration to add to /etc/postfix/master.cf
# #
-o smtpd_sender_restrictions=check_policy_service unix:private/kc-policy -o smtpd_sender_restrictions=check_policy_service unix:private/mailcloak-policy
# You can remove `reject_senders_login_mismaych` + `sender_login_maps` # You can remove `reject_senders_login_mismatch` + `sender_login_maps`
# as this kc-policy will handle it. # as mailcloak will handle it.

View File

@@ -6,3 +6,18 @@ CREATE TABLE IF NOT EXISTS aliases (
); );
CREATE INDEX IF NOT EXISTS idx_aliases_username ON aliases(username); CREATE INDEX IF NOT EXISTS idx_aliases_username ON aliases(username);
CREATE TABLE IF NOT EXISTS apps (
app_id TEXT PRIMARY KEY,
secret_hash TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS app_from (
app_id TEXT NOT NULL,
from_addr TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (app_id, from_addr),
FOREIGN KEY (app_id) REFERENCES apps(app_id) ON DELETE CASCADE
);

2
go.mod
View File

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

View File

@@ -1,85 +0,0 @@
package kcpolicy
import (
"bufio"
"context"
"fmt"
"net"
"os"
"strings"
)
func RunSocketmap(ctx context.Context, cfg *Config, db *AliasDB) error {
sock := cfg.Sockets.SocketmapSocket
_ = os.Remove(sock)
l, err := net.Listen("unix", sock)
if err != nil {
return err
}
defer l.Close()
if err := ChownChmodSocket(sock, cfg); err != nil {
return err
}
for {
conn, err := l.Accept()
if err != nil {
select {
case <-ctx.Done():
return nil
default:
return err
}
}
go handleSocketmapConn(conn, cfg, db)
}
}
// Socketmap protocol: "mapname key\n" -> "OK value\n" or "NOTFOUND\n" or "TEMP\n"
func handleSocketmapConn(conn net.Conn, cfg *Config, db *AliasDB) {
defer conn.Close()
r := bufio.NewReader(conn)
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, " ", 2)
if len(parts) != 2 {
fmt.Fprint(conn, "TEMP\n")
continue
}
mapName := parts[0]
key := strings.ToLower(strings.TrimSpace(parts[1]))
if mapName != "alias" {
fmt.Fprint(conn, "NOTFOUND\n")
continue
}
// Only handle our domain
if !strings.HasSuffix(key, "@"+strings.ToLower(cfg.Policy.Domain)) {
fmt.Fprint(conn, "NOTFOUND\n")
continue
}
username, ok, err := db.AliasOwner(key)
if err != nil {
fmt.Fprint(conn, "TEMP\n")
continue
}
if !ok {
fmt.Fprint(conn, "NOTFOUND\n")
continue
}
// rewrite alias -> primary rcpt (username@domain)
fmt.Fprintf(conn, "OK %s@%s\n", username, strings.ToLower(cfg.Policy.Domain))
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,142 @@
package mailcloak
import (
"bufio"
"context"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
)
func RunSocketmap(ctx context.Context, cfg *Config, db *AliasDB) error {
sock := cfg.Sockets.SocketmapSocket
_ = os.Remove(sock)
l, err := net.Listen("unix", sock)
if err != nil {
return err
}
defer l.Close()
if err := ChownChmodSocket(sock, cfg); err != nil {
return err
}
for {
conn, err := l.Accept()
if err != nil {
select {
case <-ctx.Done():
return nil
default:
return err
}
}
go handleSocketmapConn(conn, cfg, db)
}
}
// Postfix socketmap framing: "<len>:<payload>,"
func handleSocketmapConn(conn net.Conn, cfg *Config, db *AliasDB) {
defer conn.Close()
r := bufio.NewReader(conn)
for {
payload, err := readSocketmapFrame(r)
if err != nil {
// normal close
return
}
payload = strings.TrimSpace(payload)
if payload == "" {
_ = writeSocketmapFrame(conn, "NOTFOUND")
continue
}
parts := strings.SplitN(payload, " ", 2)
if len(parts) != 2 {
_ = writeSocketmapFrame(conn, "TEMP")
continue
}
mapName := parts[0]
key := strings.ToLower(strings.TrimSpace(parts[1]))
if mapName != "alias" {
_ = writeSocketmapFrame(conn, "NOTFOUND")
continue
}
// Only handle our domain
domain := strings.ToLower(cfg.Policy.Domain)
if !strings.HasSuffix(key, "@"+domain) {
_ = writeSocketmapFrame(conn, "NOTFOUND")
continue
}
username, ok, err := db.AliasOwner(key)
if err != nil {
_ = writeSocketmapFrame(conn, "TEMP")
continue
}
if !ok {
_ = writeSocketmapFrame(conn, "NOTFOUND")
continue
}
// rewrite alias -> username@domain
_ = writeSocketmapFrame(conn, fmt.Sprintf("OK %s@%s", username, domain))
}
}
func readSocketmapFrame(r *bufio.Reader) (string, error) {
// read decimal length until ':'
var lenBuf strings.Builder
for {
b, err := r.ReadByte()
if err != nil {
return "", err
}
if b == ':' {
break
}
if b < '0' || b > '9' {
return "", io.ErrUnexpectedEOF
}
lenBuf.WriteByte(b)
if lenBuf.Len() > 10 {
return "", io.ErrUnexpectedEOF
}
}
n, err := strconv.Atoi(lenBuf.String())
if err != nil || n < 0 || n > 1024*1024 {
return "", io.ErrUnexpectedEOF
}
buf := make([]byte, n)
if _, err := io.ReadFull(r, buf); err != nil {
return "", err
}
// expect trailing comma
b, err := r.ReadByte()
if err != nil {
return "", err
}
if b != ',' {
return "", io.ErrUnexpectedEOF
}
return string(buf), nil
}
func writeSocketmapFrame(w io.Writer, payload string) error {
// "<len>:<payload>,"
_, err := fmt.Fprintf(w, "%d:%s,", len(payload), payload)
return err
}

View File

@@ -1,4 +1,4 @@
package kcpolicy package mailcloak
import ( import (
"database/sql" "database/sql"

View File

@@ -4,7 +4,7 @@ import sqlite3
import time import time
import sys import sys
DEFAULT_DB = "/var/lib/kc-policy/aliases.db" DEFAULT_DB = "/var/lib/mailcloak/state.db"
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS aliases ( CREATE TABLE IF NOT EXISTS aliases (

BIN
state.db Normal file

Binary file not shown.