release: 0.5.0: Pre-1.0 security review, domain-admin role, doc consolidation
A bounce could be recorded as a successful delivery. The log-tailer's Fixed: - A bounce could be recorded as a successful delivery. The log-tailer's - Log-tailer resumes where it stopped instead of jumping to end-of-file on - Level-2 rate limit no longer overshoots under concurrency: messages that Changed: - The project has a single public home: `github.com/mixeme/selfpost`. Codeberg - Code comments no longer cite the archived specification. References like - `docs/code-review.md` is gone. Its plan is finished — phases 0 (bar the - `docs/architecture.md` gained a *Code layers* section: a diagram of - Phase 1 doc/code hygiene (`docs/code-review.md`): removed ~30 stale … (1 more in CHANGELOG) Security: - Pre-release security review (plan § D, model Fable, 2026-08-06): full pass - `saslpasswd2` argv: the application login is now passed after a `--` Added: - Optional password encryption for the two secret-bearing downloads (plan - Domain import accepts an encrypted export: the envelope is detected by its - `selfpost-backup` writes encrypted archives and reads them back: - `TestParseDelivery` covers the exotic mail.log shapes the review asked for - docs: README *Encrypting a backup or export*; `docs/security.md` § … (14 more in CHANGELOG) Removed: - docs: `security.md` — accepted risk «restore old backup revives session rows» Changed: - docs: `implementation-plan.md` trimmed to the sole open v1.x gate — pre-release - docs: `architecture.md` — sessions (SQLite, idle renew, password change), - docs: `development.md` — expanded e2e stack and `release.yml` matrix workflow. - docs: `security.md` — accepted risk for send-log rows stuck at `queued` after - docs: `roadmap.md` — optional send-log / `mail.log` follow-ups under v1.x tail. … (10 more in CHANGELOG) Full detail: CHANGELOG.md (0.5.0).
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// documentedPublic matches the README "Environment variables" table.
|
||||
var documentedPublic = []string{
|
||||
"SELFPOST_HOSTNAME",
|
||||
"SUBMISSION_ENABLE",
|
||||
"RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"RATE_LIMIT_WINDOW_SECONDS",
|
||||
"SEND_LOG_RETENTION_DAYS",
|
||||
"PANEL_SESSION_IDLE_DAYS",
|
||||
"SELFPOST_DNS_RESOLVERS",
|
||||
"TRUSTED_PROXY_CIDR",
|
||||
}
|
||||
|
||||
// documentedInternal matches README "Internal variables (not part of the operator interface)".
|
||||
var documentedInternal = []string{
|
||||
"SELFPOST_DATA_DIR",
|
||||
"SELFPOST_DB_PATH",
|
||||
"SELFPOST_SETUP_TOKEN_FILE",
|
||||
"PANEL_HTTP_ADDR",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"MAIL_LOG",
|
||||
"PANEL_COOKIE_SECURE",
|
||||
"OPENDKIM_SOCKET",
|
||||
"OPENDKIM_DIR",
|
||||
"DKIM_SELECTOR_DEFAULT",
|
||||
"SASL_DB_PATH",
|
||||
"SASL_REALM",
|
||||
"POSTFIX_DIR",
|
||||
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||
"MILTER_CONNECT_TIMEOUT",
|
||||
"MILTER_COMMAND_TIMEOUT",
|
||||
"MILTER_CONTENT_TIMEOUT",
|
||||
"MILTER_WAIT_TIMEOUT",
|
||||
"TLS_RELOAD_INTERVAL_SECONDS",
|
||||
"LOGROTATE_INTERVAL_SECONDS",
|
||||
}
|
||||
|
||||
// documentedComposeFixed are env keys documented outside the table (TLS paths fixed in compose).
|
||||
var documentedComposeFixed = []string{
|
||||
"TLS_CERT_FILE",
|
||||
"TLS_KEY_FILE",
|
||||
}
|
||||
|
||||
// loadConfigKeys is every environment variable read by loadConfig() in main.go.
|
||||
// Update together with loadConfig when adding a new key.
|
||||
var loadConfigKeys = []string{
|
||||
"SELFPOST_DATA_DIR",
|
||||
"PANEL_HTTP_ADDR",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"MAIL_LOG",
|
||||
"SEND_LOG_RETENTION_DAYS",
|
||||
"SELFPOST_DB_PATH",
|
||||
"SELFPOST_SETUP_TOKEN_FILE",
|
||||
"SELFPOST_HOSTNAME",
|
||||
"PANEL_COOKIE_SECURE",
|
||||
"SUBMISSION_ENABLE",
|
||||
"TRUSTED_PROXY_CIDR",
|
||||
"PANEL_SESSION_IDLE_DAYS",
|
||||
"SELFPOST_DNS_RESOLVERS",
|
||||
"TLS_CERT_FILE",
|
||||
"OPENDKIM_SOCKET",
|
||||
"OPENDKIM_DIR",
|
||||
"DKIM_SELECTOR_DEFAULT",
|
||||
"SASL_DB_PATH",
|
||||
"SASL_REALM",
|
||||
"POSTFIX_DIR",
|
||||
}
|
||||
|
||||
// buildScriptKeys is every ${VAR:-…} / os.Getenv used in build/*.sh and entrypoint.sh
|
||||
// but not necessarily in loadConfig. Update when startup scripts gain a new knob.
|
||||
var buildScriptKeys = []string{
|
||||
"SELFPOST_HOSTNAME",
|
||||
"TLS_CERT_FILE",
|
||||
"TLS_KEY_FILE",
|
||||
"RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"RATE_LIMIT_WINDOW_SECONDS",
|
||||
"OPENDKIM_SOCKET",
|
||||
"JOURNAL_MILTER_SOCKET",
|
||||
"POSTFIX_SENDER_LOGIN_MAPS",
|
||||
"SASL_DB_PATH",
|
||||
"SUBMISSION_ENABLE",
|
||||
"MILTER_CONNECT_TIMEOUT",
|
||||
"MILTER_COMMAND_TIMEOUT",
|
||||
"MILTER_CONTENT_TIMEOUT",
|
||||
"MILTER_WAIT_TIMEOUT",
|
||||
"TLS_RELOAD_INTERVAL_SECONDS",
|
||||
"LOGROTATE_INTERVAL_SECONDS",
|
||||
}
|
||||
|
||||
func documentedKeys() []string {
|
||||
keys := append([]string{}, documentedPublic...)
|
||||
keys = append(keys, documentedInternal...)
|
||||
keys = append(keys, documentedComposeFixed...)
|
||||
slices.Sort(keys)
|
||||
return slices.Compact(keys)
|
||||
}
|
||||
|
||||
func TestLoadConfigKeysDocumented(t *testing.T) {
|
||||
doc := documentedKeys()
|
||||
for _, key := range loadConfigKeys {
|
||||
if !slices.Contains(doc, key) {
|
||||
t.Errorf("loadConfig reads %s but it is not listed in README public, internal, or compose-fixed env docs", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScriptKeysDocumented(t *testing.T) {
|
||||
doc := documentedKeys()
|
||||
for _, key := range buildScriptKeys {
|
||||
if !slices.Contains(doc, key) {
|
||||
t.Errorf("build scripts read %s but it is not listed in README env documentation", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentedKeysAreRead(t *testing.T) {
|
||||
read := append([]string{}, loadConfigKeys...)
|
||||
read = append(read, buildScriptKeys...)
|
||||
slices.Sort(read)
|
||||
read = slices.Compact(read)
|
||||
|
||||
for _, key := range documentedKeys() {
|
||||
if !slices.Contains(read, key) {
|
||||
t.Errorf("README documents %s but no code in loadConfig or build scripts reads it", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,17 @@ import (
|
||||
"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"
|
||||
"github.com/mixeme/selfpost/internal/app"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/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).
|
||||
// the database handle shared by all roles: setup, login and the authenticated
|
||||
// panel surface (security.md).
|
||||
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.
|
||||
|
||||
@@ -8,14 +8,14 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/milter"
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/milter"
|
||||
"github.com/mixeme/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.
|
||||
// (architecture.md § Mail path), 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 {
|
||||
|
||||
+31
-30
@@ -1,10 +1,7 @@
|
||||
// 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.
|
||||
// Command panel is the SelfPost control panel. This single binary combines
|
||||
// several roles (architecture.md § Image and processes) as a supervised
|
||||
// process: the HTTP panel server, the journal-milter, the mail.log tailer and
|
||||
// the rate-limit checks.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -21,11 +18,11 @@ import (
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/backup"
|
||||
"codeberg.org/mix/selfpost/internal/buildinfo"
|
||||
"codeberg.org/mix/selfpost/internal/dnscheck"
|
||||
"codeberg.org/mix/selfpost/internal/logtail"
|
||||
"codeberg.org/mix/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/backup"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/logtail"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -85,8 +82,9 @@ func loadConfig() 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.
|
||||
// Send-log retention window (architecture.md § Persistence).
|
||||
// Non-positive/invalid falls back to the 90-day default inside the
|
||||
// log-tailer.
|
||||
retentionDays: envInt("SEND_LOG_RETENTION_DAYS", 90),
|
||||
|
||||
dataDir: dataDir,
|
||||
@@ -94,7 +92,7 @@ func loadConfig() config {
|
||||
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
|
||||
// Secure cookies by default (security.md); 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
|
||||
@@ -106,7 +104,7 @@ func loadConfig() config {
|
||||
// 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
|
||||
// Sliding session idle timeout (security.md, plan B.1). Non-positive/invalid
|
||||
// falls back to the 7-day default inside internal/web.
|
||||
sessionIdleDays: envInt("PANEL_SESSION_IDLE_DAYS", 7),
|
||||
// Recursive resolvers the deliverability checks query directly. Empty
|
||||
@@ -116,15 +114,16 @@ func loadConfig() config {
|
||||
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).
|
||||
// Per-domain DKIM state (architecture.md § OpenDKIM). 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.
|
||||
// Application SASL accounts and the Postfix sender map (architecture.md §
|
||||
// Mail path), 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")),
|
||||
@@ -133,7 +132,7 @@ func loadConfig() config {
|
||||
|
||||
// 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).
|
||||
// login resolves to the right account.
|
||||
func saslRealm() string {
|
||||
if r := os.Getenv("SASL_REALM"); r != "" {
|
||||
return r
|
||||
@@ -195,9 +194,10 @@ func parseTrustedProxies(raw string) []*net.IPNet {
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 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 — architecture.md §
|
||||
// Image and processes).
|
||||
func run() error {
|
||||
cfg := loadConfig()
|
||||
|
||||
@@ -206,10 +206,11 @@ func run() error {
|
||||
|
||||
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.
|
||||
// Restore version guard (architecture.md § Persistence): 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
|
||||
}
|
||||
|
||||
+130
-15
@@ -1,6 +1,6 @@
|
||||
// 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).
|
||||
// equivalent of the panel's backup button (architecture.md § Persistence).
|
||||
//
|
||||
// By default the gzip-compressed tar is written to stdout, so the usual form is:
|
||||
//
|
||||
@@ -8,22 +8,40 @@
|
||||
//
|
||||
// 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).
|
||||
// (architecture.md § Persistence).
|
||||
//
|
||||
// Given a password (SELFPOST_BACKUP_PASSWORD or -password-file, never an
|
||||
// argument, which would show up in the process list) the archive is written as
|
||||
// an encrypted .spbk envelope instead. Turn one back into a plain .tar.gz with
|
||||
// the same password:
|
||||
//
|
||||
// docker exec -i <container> selfpost-backup -decrypt < backup.spbk > backup.tar.gz
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/backup"
|
||||
"codeberg.org/mix/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/backup"
|
||||
"github.com/mixeme/selfpost/internal/buildinfo"
|
||||
"github.com/mixeme/selfpost/internal/secretfile"
|
||||
)
|
||||
|
||||
// passwordEnv names the environment variable holding the encryption password.
|
||||
// A password must never be a command-line argument: the process list is
|
||||
// readable by every process in the container.
|
||||
const passwordEnv = "SELFPOST_BACKUP_PASSWORD"
|
||||
|
||||
func main() {
|
||||
showVersion := flag.Bool("version", false, "print version and exit")
|
||||
out := flag.String("o", "", "write the archive to this file instead of stdout")
|
||||
out := flag.String("o", "", "write the output to this file instead of stdout")
|
||||
in := flag.String("i", "", "read the encrypted archive from this file instead of stdin (-decrypt only)")
|
||||
decrypt := flag.Bool("decrypt", false, "decrypt an encrypted backup (.spbk) back to a plain .tar.gz")
|
||||
pwFile := flag.String("password-file", "", "read the encryption password from this file (first line); "+passwordEnv+" is used when unset")
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
@@ -31,40 +49,137 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := run(*out); err != nil {
|
||||
password, err := readPassword(*pwFile)
|
||||
if err == nil {
|
||||
if *decrypt {
|
||||
err = runDecrypt(*in, *out, password)
|
||||
} else {
|
||||
err = run(*out, password)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "selfpost-backup: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(outPath string) error {
|
||||
// run writes a backup, encrypting it when a password was supplied.
|
||||
func run(outPath, password 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)
|
||||
w, closeOut, err := openOutput(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeOut()
|
||||
|
||||
sink := w
|
||||
var env *secretfile.Writer
|
||||
if password != "" {
|
||||
env, err = secretfile.NewWriter(w, secretfile.TypeFullBackup, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
w = f
|
||||
sink = env
|
||||
}
|
||||
|
||||
if err := backup.Create(w, backup.Params{
|
||||
if err := backup.Create(sink, backup.Params{
|
||||
DataDir: dataDir,
|
||||
DBPath: dbPath,
|
||||
Version: buildinfo.Version,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if env != nil {
|
||||
if err := env.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if outPath != "" {
|
||||
fmt.Fprintf(os.Stderr, "selfpost-backup: wrote %s (SelfPost %s)\n", outPath, buildinfo.Version)
|
||||
kind := "plain"
|
||||
if password != "" {
|
||||
kind = "encrypted"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "selfpost-backup: wrote %s (%s, SelfPost %s)\n", outPath, kind, buildinfo.Version)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runDecrypt turns a .spbk envelope back into the plain gzip tar, so an
|
||||
// encrypted backup can be extracted with ordinary tools during a restore.
|
||||
func runDecrypt(inPath, outPath, password string) error {
|
||||
if password == "" {
|
||||
return fmt.Errorf("-decrypt needs the password (set %s or use -password-file)", passwordEnv)
|
||||
}
|
||||
|
||||
r := io.Reader(os.Stdin)
|
||||
if inPath != "" {
|
||||
f, err := os.Open(inPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
r = f
|
||||
}
|
||||
|
||||
env, err := secretfile.NewReader(r, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if env.Type() != secretfile.TypeFullBackup {
|
||||
return fmt.Errorf("that file is a %s, not a full backup", env.Type())
|
||||
}
|
||||
|
||||
w, closeOut, err := openOutput(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeOut()
|
||||
|
||||
if _, err := io.Copy(w, env); err != nil {
|
||||
return err
|
||||
}
|
||||
if outPath != "" {
|
||||
fmt.Fprintf(os.Stderr, "selfpost-backup: wrote %s (decrypted)\n", outPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openOutput returns stdout, or a freshly created owner-only file: both plain
|
||||
// and encrypted backups are secret enough to keep off other users' eyes.
|
||||
func openOutput(outPath string) (io.Writer, func(), error) {
|
||||
if outPath == "" {
|
||||
return os.Stdout, func() {}, nil
|
||||
}
|
||||
f, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return f, func() { _ = f.Close() }, nil
|
||||
}
|
||||
|
||||
// readPassword takes the password from the given file (first line) or, when no
|
||||
// file is named, from the environment. An empty result means "no encryption".
|
||||
func readPassword(pwFile string) (string, error) {
|
||||
if pwFile == "" {
|
||||
return os.Getenv(passwordEnv), nil
|
||||
}
|
||||
data, err := os.ReadFile(pwFile)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read password file: %w", err)
|
||||
}
|
||||
// A password file is usually written with a trailing newline; take the first
|
||||
// line and strip the line ending, but keep any other whitespace, which may
|
||||
// well be part of the password.
|
||||
line, _, _ := strings.Cut(string(data), "\n")
|
||||
line = strings.TrimSuffix(line, "\r")
|
||||
if line == "" {
|
||||
return "", fmt.Errorf("password file %s is empty", pwFile)
|
||||
}
|
||||
return line, nil
|
||||
}
|
||||
|
||||
func envDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// seedDataDir builds the minimum /data tree a backup can be taken from.
|
||||
func seedDataDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
st, err := store.Open(filepath.Join(dataDir, "selfpost.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
|
||||
t.Fatalf("add domain: %v", err)
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("close store: %v", err)
|
||||
}
|
||||
t.Setenv("SELFPOST_DATA_DIR", dataDir)
|
||||
t.Setenv("SELFPOST_DB_PATH", filepath.Join(dataDir, "selfpost.db"))
|
||||
return dataDir
|
||||
}
|
||||
|
||||
// An encrypted backup is only worth having if the container it came from can
|
||||
// hand it back as an ordinary archive during a restore, so the two halves of
|
||||
// the CLI are tested as the one round trip an operator actually performs.
|
||||
func TestEncryptedBackupRoundTrip(t *testing.T) {
|
||||
seedDataDir(t)
|
||||
dir := t.TempDir()
|
||||
encrypted := filepath.Join(dir, "backup.spbk")
|
||||
plain := filepath.Join(dir, "backup.tar.gz")
|
||||
const password = "a long enough password"
|
||||
|
||||
if err := run(encrypted, password); err != nil {
|
||||
t.Fatalf("create encrypted backup: %v", err)
|
||||
}
|
||||
head, err := os.ReadFile(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("read backup: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(string(head), "SELFPOST") {
|
||||
t.Fatalf("encrypted backup does not start with the envelope magic")
|
||||
}
|
||||
|
||||
if err := runDecrypt(encrypted, plain, "the wrong password"); err == nil {
|
||||
t.Fatal("decryption with the wrong password succeeded")
|
||||
}
|
||||
if err := runDecrypt(encrypted, plain, password); err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
|
||||
// What comes out must be the same gzip tar the plain path produces.
|
||||
f, err := os.Open(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("open decrypted archive: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
t.Fatalf("gzip: %v", err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("tar: %v", err)
|
||||
}
|
||||
names[hdr.Name] = true
|
||||
}
|
||||
for _, want := range []string{"manifest.json", "selfpost.db"} {
|
||||
if !names[want] {
|
||||
t.Errorf("decrypted archive has no %s (entries: %v)", want, names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Without a password the CLI keeps producing the plain archive that existing
|
||||
// backup scripts consume.
|
||||
func TestUnencryptedBackupStaysPlain(t *testing.T) {
|
||||
seedDataDir(t)
|
||||
out := filepath.Join(t.TempDir(), "backup.tar.gz")
|
||||
if err := run(out, ""); err != nil {
|
||||
t.Fatalf("create backup: %v", err)
|
||||
}
|
||||
f, err := os.Open(out)
|
||||
if err != nil {
|
||||
t.Fatalf("open archive: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := gzip.NewReader(f); err != nil {
|
||||
t.Fatalf("plain backup is not a gzip archive: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypting needs a password, and it must come from a file or the environment
|
||||
// — never an argument, which the process list would expose.
|
||||
func TestReadPassword(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pwFile := filepath.Join(dir, "pw")
|
||||
if err := os.WriteFile(pwFile, []byte("from the file\nignored second line\n"), 0o600); err != nil {
|
||||
t.Fatalf("write password file: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(passwordEnv, "from the environment")
|
||||
got, err := readPassword("")
|
||||
if err != nil || got != "from the environment" {
|
||||
t.Errorf("readPassword(\"\") = %q, %v", got, err)
|
||||
}
|
||||
got, err = readPassword(pwFile)
|
||||
if err != nil || got != "from the file" {
|
||||
t.Errorf("readPassword(file) = %q, %v", got, err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(pwFile, nil, 0o600); err != nil {
|
||||
t.Fatalf("truncate password file: %v", err)
|
||||
}
|
||||
if _, err := readPassword(pwFile); err == nil {
|
||||
t.Error("an empty password file was accepted")
|
||||
}
|
||||
|
||||
os.Unsetenv(passwordEnv)
|
||||
if err := runDecrypt("", "", ""); err == nil {
|
||||
t.Error("-decrypt without a password was accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user