release: 0.1.0: Phases 0-11: panel, Postfix/DKIM, milter, backup, Docker image
Initial feature-complete implementation of the v1.0 specification (phases 0-11 of `docs/implementation-plan.md`). Added: - Panel (Go, single static binary) with SQLite persistence, one-time - Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and - Application (sender identity) management: SASL credentials via `sasldb2`, - Full Postfix relay config generated from env at container start: SMTPS 465, - Journal milter (pure Go, `go-milter`) recording every send to `send_log`; … (6 more in CHANGELOG) Full detail: CHANGELOG.md (0.1.0).
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/app"
|
||||
"codeberg.org/mix/selfpost/internal/buildinfo"
|
||||
"codeberg.org/mix/selfpost/internal/domain"
|
||||
"codeberg.org/mix/selfpost/internal/postfix"
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
"codeberg.org/mix/selfpost/internal/web"
|
||||
)
|
||||
|
||||
// serveHTTP runs the control-panel HTTP server until ctx is cancelled, using
|
||||
// the database handle shared by all roles. From Phase 2 this serves the real
|
||||
// setup, login and authenticated panel surface (spec 7.6).
|
||||
func serveHTTP(ctx context.Context, cfg config, st *store.Store) error {
|
||||
// Applications own the SASL accounts and the Postfix sender map; the domain
|
||||
// service delegates to them when a domain (and its applications) is deleted.
|
||||
pf := postfix.New(cfg.postfixDir)
|
||||
apps := app.NewService(st, app.NewSASLDB(cfg.saslDBPath, cfg.saslRealm), pf)
|
||||
domains := domain.NewService(st, domain.NewOpenDKIM(cfg.opendkimDir), apps, cfg.dkimSelectorDef)
|
||||
|
||||
srvApp, err := web.New(st, domains, apps, web.Config{
|
||||
Hostname: cfg.hostname,
|
||||
CookieSecure: cfg.cookieSecure,
|
||||
SubmissionEnabled: cfg.submissionEnabled,
|
||||
MailLogPath: cfg.mailLog,
|
||||
DataDir: cfg.dataDir,
|
||||
DBPath: cfg.dbPath,
|
||||
Version: buildinfo.Version,
|
||||
TrustedProxyCIDRs: cfg.trustedProxies,
|
||||
TLSCertFile: cfg.tlsCertFile,
|
||||
OpenDKIMSocket: cfg.opendkimSocket,
|
||||
JournalSocket: cfg.journalSocket,
|
||||
SessionIdleDays: cfg.sessionIdleDays,
|
||||
}, cfg.setupTokenPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := srvApp.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.httpAddr,
|
||||
Handler: srvApp.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// Shut the server down cleanly when the process is asked to stop.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
log.Printf("http panel listening on %s", cfg.httpAddr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/milter"
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// serveJournal opens the journal-milter Unix socket and runs the real milter
|
||||
// (spec 7.3), recording accepted messages into the send log. Socket lifecycle
|
||||
// (creation, stale cleanup, group permissions) lives here; the protocol handler
|
||||
// lives in internal/milter.
|
||||
func serveJournal(ctx context.Context, cfg config, st *store.Store) error {
|
||||
socketPath := cfg.journalSocket
|
||||
if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// Clear a stale socket left behind by an unclean shutdown, otherwise the
|
||||
// listen below fails with "address already in use".
|
||||
if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
ln, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Postfix (user `postfix`) connects to this socket as a milter and needs
|
||||
// group access. The socket inherits group `selfpost` from the setgid parent
|
||||
// dir entrypoint.sh prepares; make it group read/write so postfix can reach
|
||||
// it (connecting to a Unix socket needs write permission on the node).
|
||||
if err := os.Chmod(socketPath, 0o660); err != nil {
|
||||
ln.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("journal-milter listening on %s", socketPath)
|
||||
return milter.Serve(ctx, ln, st)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// Command panel is the SelfPost control panel. In the finished product this
|
||||
// single binary combines several roles (spec 7.1): the HTTP panel server,
|
||||
// the journal-milter, the mail.log tailer and the rate-limit checks.
|
||||
//
|
||||
// Phase 1 wires those roles up as a supervised process with a minimal HTTP
|
||||
// stub, a journal-milter socket stub (so the Postfix start wrapper's readiness
|
||||
// probe passes) and a log-tailer stub. Real behaviour lands in later phases.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/backup"
|
||||
"codeberg.org/mix/selfpost/internal/buildinfo"
|
||||
"codeberg.org/mix/selfpost/internal/logtail"
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
showVersion := flag.Bool("version", false, "print version and exit")
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println(buildinfo.Version)
|
||||
return
|
||||
}
|
||||
|
||||
log.SetFlags(log.LstdFlags | log.LUTC)
|
||||
log.SetPrefix("panel: ")
|
||||
|
||||
if err := run(); err != nil {
|
||||
log.Fatalf("fatal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// config holds the runtime knobs the panel reads from the environment. Every
|
||||
// value has a safe default so the binary also runs outside the container.
|
||||
type config struct {
|
||||
httpAddr string
|
||||
journalSocket string
|
||||
mailLog string
|
||||
retentionDays int
|
||||
|
||||
dataDir string
|
||||
dbPath string
|
||||
manifestPath string
|
||||
setupTokenPath string
|
||||
hostname string
|
||||
cookieSecure bool
|
||||
submissionEnabled bool
|
||||
trustedProxies []*net.IPNet
|
||||
sessionIdleDays int
|
||||
|
||||
// Read-only inputs to the panel's status page: the certificate Postfix
|
||||
// serves and the two milter sockets it connects to. The defaults mirror
|
||||
// build/postfix-config.sh, so the status page checks exactly what Postfix
|
||||
// was configured with.
|
||||
tlsCertFile string
|
||||
opendkimSocket string
|
||||
|
||||
opendkimDir string
|
||||
dkimSelectorDef string
|
||||
|
||||
saslDBPath string
|
||||
saslRealm string
|
||||
postfixDir string
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
dataDir := envDefault("SELFPOST_DATA_DIR", "/data")
|
||||
return config{
|
||||
httpAddr: envDefault("PANEL_HTTP_ADDR", ":8080"),
|
||||
journalSocket: envDefault("JOURNAL_MILTER_SOCKET", "/run/selfpost/journal.sock"),
|
||||
mailLog: envDefault("MAIL_LOG", "/var/log/mail.log"),
|
||||
// Send-log retention window (spec 7.3). Non-positive/invalid falls back
|
||||
// to the 90-day default inside the log-tailer.
|
||||
retentionDays: envInt("SEND_LOG_RETENTION_DAYS", 90),
|
||||
|
||||
dataDir: dataDir,
|
||||
dbPath: envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db")),
|
||||
manifestPath: filepath.Join(dataDir, backup.ManifestName),
|
||||
setupTokenPath: envDefault("SELFPOST_SETUP_TOKEN_FILE", filepath.Join(dataDir, "setup-token")),
|
||||
hostname: os.Getenv("SELFPOST_HOSTNAME"),
|
||||
// Secure cookies by default (spec 7.6.6); PANEL_COOKIE_SECURE=false is a
|
||||
// development-only escape hatch for testing over plain HTTP.
|
||||
cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false",
|
||||
// Whether this deployment also runs the 587 submission listener. The
|
||||
// panel only displays it as a client connection setting; the comparison
|
||||
// matches postfix-config.sh, which enables the listener on "true" alone.
|
||||
submissionEnabled: os.Getenv("SUBMISSION_ENABLE") == "true",
|
||||
// Reverse-proxy addresses allowed to supply X-Forwarded-For for
|
||||
// rate-limiting. Empty by default: an untrusted peer's
|
||||
// XFF header is trivially forgeable, so it's ignored unless the panel is
|
||||
// told which proxy to trust.
|
||||
trustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDR")),
|
||||
// Sliding session idle timeout (spec 7.6.6, plan B.1). Non-positive/invalid
|
||||
// falls back to the 7-day default inside internal/web.
|
||||
sessionIdleDays: envInt("PANEL_SESSION_IDLE_DAYS", 7),
|
||||
|
||||
tlsCertFile: envDefault("TLS_CERT_FILE", "/etc/postfix/tls/fullchain.pem"),
|
||||
opendkimSocket: envDefault("OPENDKIM_SOCKET", "/run/opendkim/opendkim.sock"),
|
||||
|
||||
// Per-domain DKIM state (spec 6). The directory layout matches what
|
||||
// entrypoint.sh prepares (setgid, shared `selfpost` group).
|
||||
opendkimDir: envDefault("OPENDKIM_DIR", filepath.Join(dataDir, "opendkim")),
|
||||
dkimSelectorDef: envDefault("DKIM_SELECTOR_DEFAULT", "selfpost"),
|
||||
|
||||
// Application SASL accounts and the Postfix sender map (spec 5.1, 9),
|
||||
// both under /data so they survive restarts. The SASL realm defaults to
|
||||
// the server hostname so account identities line up with Postfix's SASL
|
||||
// configuration; it falls back to localhost outside the container.
|
||||
saslDBPath: envDefault("SASL_DB_PATH", filepath.Join(dataDir, "sasl", "sasldb2")),
|
||||
saslRealm: saslRealm(),
|
||||
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
|
||||
}
|
||||
}
|
||||
|
||||
// saslRealm chooses the realm new SASL accounts live under. It mirrors the
|
||||
// hostname Postfix's SASL layer uses so a client authenticating with a bare
|
||||
// login resolves to the right account (finalised in Phase 5).
|
||||
func saslRealm() string {
|
||||
if r := os.Getenv("SASL_REALM"); r != "" {
|
||||
return r
|
||||
}
|
||||
if h := os.Getenv("SELFPOST_HOSTNAME"); h != "" {
|
||||
return h
|
||||
}
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
func envDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// envInt reads an integer environment variable, returning def if it is unset or
|
||||
// not a valid integer.
|
||||
func envInt(key string, def int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
log.Printf("ignoring invalid %s=%q, using %d", key, v, def)
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// parseTrustedProxies parses a comma-separated list of CIDRs (bare IPs are
|
||||
// accepted and treated as /32 or /128). Invalid entries are logged and
|
||||
// skipped rather than failing startup, matching envInt's tolerance of
|
||||
// misconfiguration.
|
||||
func parseTrustedProxies(raw string) []*net.IPNet {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var nets []*net.IPNet
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
cidr := strings.TrimSpace(part)
|
||||
if cidr == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(cidr, "/") {
|
||||
if ip := net.ParseIP(cidr); ip != nil && ip.To4() != nil {
|
||||
cidr += "/32"
|
||||
} else {
|
||||
cidr += "/128"
|
||||
}
|
||||
}
|
||||
_, n, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
log.Printf("ignoring invalid TRUSTED_PROXY_CIDR entry %q: %v", part, err)
|
||||
continue
|
||||
}
|
||||
nets = append(nets, n)
|
||||
}
|
||||
return nets
|
||||
}
|
||||
|
||||
// run starts the panel's three roles and blocks until a shutdown signal or the
|
||||
// first fatal error from any role. A signal triggers a clean stop of all roles;
|
||||
// a role error cancels the others and is returned so the process exits non-zero
|
||||
// (letting supervisord/Docker see the failure — spec 4).
|
||||
func run() error {
|
||||
cfg := loadConfig()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
log.Printf("starting selfpost panel %s", buildinfo.Version)
|
||||
|
||||
// Restore version guard (spec 7.5.A): if a backup was extracted into /data,
|
||||
// its manifest version must match this binary before we touch the database,
|
||||
// so schema/format skew between versions cannot corrupt the restored state.
|
||||
// A match consumes the manifest; its absence is the normal (non-restore) case.
|
||||
if err := backup.CheckRestore(cfg.manifestPath, buildinfo.Version); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// One database handle shared by every role. The store serialises writes
|
||||
// (MaxOpenConns(1)), so the HTTP panel, the journal-milter and the tailer
|
||||
// can all use it without stepping on each other under WAL.
|
||||
st, err := store.Open(cfg.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errc := make(chan error, 3)
|
||||
|
||||
roles := []struct {
|
||||
name string
|
||||
fn func(context.Context) error
|
||||
}{
|
||||
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
|
||||
{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
|
||||
{"log-tailer", func(ctx context.Context) error { return logtail.Run(ctx, cfg.mailLog, st, cfg.retentionDays) }},
|
||||
}
|
||||
|
||||
for _, r := range roles {
|
||||
wg.Add(1)
|
||||
go func(name string, fn func(context.Context) error) {
|
||||
defer wg.Done()
|
||||
if err := fn(ctx); err != nil {
|
||||
errc <- fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
}(r.name, r.fn)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("shutdown signal received, stopping")
|
||||
wg.Wait()
|
||||
log.Printf("panel stopped cleanly")
|
||||
return nil
|
||||
case err := <-errc:
|
||||
log.Printf("role failed: %v", err)
|
||||
stop() // cancel ctx so the other roles wind down
|
||||
wg.Wait()
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Command selfpost-backup produces the full persistent-state archive from inside
|
||||
// the container, invoked via `docker exec` for scripted/cron backups — the CLI
|
||||
// equivalent of the panel's backup button (spec 7.5.A, 11.6).
|
||||
//
|
||||
// By default the gzip-compressed tar is written to stdout, so the usual form is:
|
||||
//
|
||||
// docker exec <container> selfpost-backup > selfpost-backup.tar.gz
|
||||
//
|
||||
// Use -o to write to a file instead. The resulting archive contains DKIM private
|
||||
// keys, the admin password hash and SASL credentials — treat it as a secret
|
||||
// (spec 7.5.A).
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/backup"
|
||||
"codeberg.org/mix/selfpost/internal/buildinfo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
showVersion := flag.Bool("version", false, "print version and exit")
|
||||
out := flag.String("o", "", "write the archive to this file instead of stdout")
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println(buildinfo.Version)
|
||||
return
|
||||
}
|
||||
|
||||
if err := run(*out); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "selfpost-backup: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(outPath string) error {
|
||||
dataDir := envDefault("SELFPOST_DATA_DIR", "/data")
|
||||
dbPath := envDefault("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
|
||||
|
||||
w := os.Stdout
|
||||
if outPath != "" {
|
||||
// Backups are secret; create them owner-only.
|
||||
f, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
w = f
|
||||
}
|
||||
|
||||
if err := backup.Create(w, backup.Params{
|
||||
DataDir: dataDir,
|
||||
DBPath: dbPath,
|
||||
Version: buildinfo.Version,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if outPath != "" {
|
||||
fmt.Fprintf(os.Stderr, "selfpost-backup: wrote %s (SelfPost %s)\n", outPath, buildinfo.Version)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func envDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
Reference in New Issue
Block a user