`run` and `rehearse` always fetched migrate_v016.py from GitHub, so a mail server with no route out could not be migrated at all - an air-gapped host, or a clone deliberately cut off so it cannot renew certificates or deliver queued mail for the domains it was copied from. --migration-script takes a local copy instead, still checked against --migration-script-sha256 when one is pinned. Found while staging a production clone for a dress rehearsal: the clone has no route out on purpose, and that is exactly the property that stops a copy of a live mail server doing something in the real world.
434 lines
15 KiB
Go
434 lines
15 KiB
Go
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package backup
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestDownloadFileAcceptsMatchingChecksum(t *testing.T) {
|
|
content := "print('fake migration script')\n"
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte(content))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
sum := sha256.Sum256([]byte(content))
|
|
expected := hex.EncodeToString(sum[:])
|
|
|
|
dest := filepath.Join(t.TempDir(), "script.py")
|
|
got, err := DownloadFile(context.Background(), nil, srv.URL, dest, expected)
|
|
if err != nil {
|
|
t.Fatalf("DownloadFile: %v", err)
|
|
}
|
|
if got != expected {
|
|
t.Errorf("returned checksum = %s, want %s", got, expected)
|
|
}
|
|
data, _ := os.ReadFile(dest)
|
|
if string(data) != content {
|
|
t.Errorf("downloaded content = %q, want %q", data, content)
|
|
}
|
|
}
|
|
|
|
func TestDownloadFileRejectsMismatchedChecksum(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte("unexpected content"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
dest := filepath.Join(t.TempDir(), "script.py")
|
|
_, err := DownloadFile(context.Background(), nil, srv.URL, dest, "0000000000000000000000000000000000000000000000000000000000000000")
|
|
if err == nil {
|
|
t.Fatal("DownloadFile should reject a checksum mismatch")
|
|
}
|
|
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
|
|
t.Error("DownloadFile should remove the file it wrote after a checksum mismatch")
|
|
}
|
|
}
|
|
|
|
func TestDownloadFileWithoutPinReturnsComputedHash(t *testing.T) {
|
|
content := "arbitrary content"
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte(content))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
dest := filepath.Join(t.TempDir(), "script.py")
|
|
got, err := DownloadFile(context.Background(), nil, srv.URL, dest, "")
|
|
if err != nil {
|
|
t.Fatalf("DownloadFile: %v", err)
|
|
}
|
|
sum := sha256.Sum256([]byte(content))
|
|
want := hex.EncodeToString(sum[:])
|
|
if got != want {
|
|
t.Errorf("returned checksum = %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRunSettingsDumpInvokesScriptWithFlags(t *testing.T) {
|
|
dir := t.TempDir()
|
|
log := argsFile(t, dir)
|
|
pythonDir := withFakeExecutable(t, "python3", fakeScriptLoggingArgs(log, "exit 0"))
|
|
|
|
err := RunSettingsDump(context.Background(), SettingsDumpOptions{
|
|
PythonPath: filepath.Join(pythonDir, "python3"),
|
|
ScriptPath: "/opt/migrate_v016.py",
|
|
URL: "https://mail.example.com",
|
|
Username: "admin",
|
|
Password: "hunter2",
|
|
SettingsPath: filepath.Join(dir, "settings.json"),
|
|
PrincipalsPath: filepath.Join(dir, "principals.json"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunSettingsDump: %v", err)
|
|
}
|
|
got := readArgsFile(t, log)
|
|
for _, want := range []string{"/opt/migrate_v016.py", "dump", "--url https://mail.example.com", "--username admin"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("script invoked with %q, missing %q", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunSettingsDumpPropagatesFailure(t *testing.T) {
|
|
pythonDir := withFakeExecutable(t, "python3", "#!/bin/sh\necho 'auth failed' >&2\nexit 1\n")
|
|
err := RunSettingsDump(context.Background(), SettingsDumpOptions{
|
|
PythonPath: filepath.Join(pythonDir, "python3"),
|
|
ScriptPath: "/opt/migrate_v016.py",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("RunSettingsDump should error when the script exits non-zero")
|
|
}
|
|
if !strings.Contains(err.Error(), "auth failed") {
|
|
t.Errorf("error = %v, want it to include the script's stderr", err)
|
|
}
|
|
}
|
|
|
|
func TestRunSettingsConvertInvokesScriptWithFlags(t *testing.T) {
|
|
dir := t.TempDir()
|
|
log := argsFile(t, dir)
|
|
pythonDir := withFakeExecutable(t, "python3", fakeScriptLoggingArgs(log, "exit 0"))
|
|
|
|
err := RunSettingsConvert(context.Background(), SettingsConvertOptions{
|
|
PythonPath: filepath.Join(pythonDir, "python3"),
|
|
ScriptPath: "/opt/migrate_v016.py",
|
|
SettingsPath: filepath.Join(dir, "settings.json"),
|
|
PrincipalsPath: filepath.Join(dir, "principals.json"),
|
|
ConfigPath: filepath.Join(dir, "config.json"),
|
|
OutputPath: filepath.Join(dir, "export.json"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunSettingsConvert: %v", err)
|
|
}
|
|
got := readArgsFile(t, log)
|
|
for _, want := range []string{"convert", "--settings", "--config", "--output"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("script invoked with %q, missing %q", got, want)
|
|
}
|
|
}
|
|
if strings.Contains(got, "--patch-paths") {
|
|
t.Errorf("script invoked with %q, should not include --patch-paths when none given", got)
|
|
}
|
|
}
|
|
|
|
func TestRunSettingsConvertWithPatchPaths(t *testing.T) {
|
|
dir := t.TempDir()
|
|
log := argsFile(t, dir)
|
|
pythonDir := withFakeExecutable(t, "python3", fakeScriptLoggingArgs(log, "exit 0"))
|
|
|
|
err := RunSettingsConvert(context.Background(), SettingsConvertOptions{
|
|
PythonPath: filepath.Join(pythonDir, "python3"),
|
|
ScriptPath: "/opt/migrate_v016.py",
|
|
SettingsPath: filepath.Join(dir, "settings.json"),
|
|
PrincipalsPath: filepath.Join(dir, "principals.json"),
|
|
ConfigPath: filepath.Join(dir, "config.json"),
|
|
OutputPath: filepath.Join(dir, "export.json"),
|
|
PatchPaths: map[string]string{"/var/lib/stalwart": "/tmp/sandbox/stalwart"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RunSettingsConvert: %v", err)
|
|
}
|
|
got := readArgsFile(t, log)
|
|
if !strings.Contains(got, "--patch-paths /var/lib/stalwart=/tmp/sandbox/stalwart") {
|
|
t.Errorf("script invoked with %q, missing the expected --patch-paths flag", got)
|
|
}
|
|
}
|
|
|
|
func TestRunSettingsConvertPropagatesFailure(t *testing.T) {
|
|
pythonDir := withFakeExecutable(t, "python3", "#!/bin/sh\necho 'unsupported settings key' >&2\nexit 1\n")
|
|
err := RunSettingsConvert(context.Background(), SettingsConvertOptions{
|
|
PythonPath: filepath.Join(pythonDir, "python3"),
|
|
ScriptPath: "/opt/migrate_v016.py",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("RunSettingsConvert should error when the script exits non-zero")
|
|
}
|
|
if !strings.Contains(err.Error(), "unsupported settings key") {
|
|
t.Errorf("error = %v, want it to include the script's stderr", err)
|
|
}
|
|
}
|
|
|
|
// The report this parses is the most consequential output of a real
|
|
// migration: against a production instance with 12,401 settings,
|
|
// migrate_v016.py migrated 219 of them and listed the other 12,182 here.
|
|
// Losing or ignoring this file means bringing up a server that answers on
|
|
// no ports, since server.listener is among the settings that don't carry.
|
|
const sampleUnmigrated = `# Unmigrated v0.15 settings
|
|
|
|
These v0.15 settings were not migrated by the script and must be
|
|
reviewed manually.
|
|
|
|
Total unmigrated keys: 12182 across 69 prefixes.
|
|
|
|
server.blocked-ip 8547 keys
|
|
lookup.url-redirectors 1076 keys
|
|
spam-filter.rule 424 keys
|
|
server.listener 26 keys
|
|
asn.expires 1 keys
|
|
`
|
|
|
|
func TestReadUnmigratedReportParsesTotalsAndPrefixes(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "unmigrated.txt")
|
|
if err := os.WriteFile(path, []byte(sampleUnmigrated), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
report, err := ReadUnmigratedReport(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if report.TotalKeys != 12182 {
|
|
t.Errorf("TotalKeys = %d, want 12182", report.TotalKeys)
|
|
}
|
|
if len(report.Prefixes) != 5 {
|
|
t.Fatalf("parsed %d prefixes, want 5", len(report.Prefixes))
|
|
}
|
|
// Largest first, so the summary leads with what matters most.
|
|
if report.Prefixes[0].Prefix != "server.blocked-ip" || report.Prefixes[0].Keys != 8547 {
|
|
t.Errorf("first prefix = %+v, want server.blocked-ip 8547", report.Prefixes[0])
|
|
}
|
|
|
|
summary := report.Summary(3)
|
|
if !strings.Contains(summary, "12182") || !strings.Contains(summary, "must be recreated by hand") {
|
|
t.Errorf("summary should lead with the scale of the problem:\n%s", summary)
|
|
}
|
|
if !strings.Contains(summary, "and 2 more prefix(es)") {
|
|
t.Errorf("summary should say how much it elided:\n%s", summary)
|
|
}
|
|
}
|
|
|
|
// An older script, or a conversion with nothing left over, writes no file.
|
|
// That is not an error.
|
|
func TestReadUnmigratedReportTreatsMissingFileAsNoReport(t *testing.T) {
|
|
report, err := ReadUnmigratedReport(filepath.Join(t.TempDir(), "absent.txt"))
|
|
if err != nil {
|
|
t.Errorf("missing report should not be an error: %v", err)
|
|
}
|
|
if report != nil {
|
|
t.Errorf("report = %+v, want nil", report)
|
|
}
|
|
}
|
|
|
|
// migrate_v016.py writes unmigrated.txt into its working directory, so the
|
|
// convert has to run somewhere writable that the caller knows about.
|
|
func TestRunSettingsConvertRunsInTheGivenWorkDir(t *testing.T) {
|
|
dir := t.TempDir()
|
|
workDir := filepath.Join(dir, "work")
|
|
log := argsFile(t, dir)
|
|
withFakeExecutable(t, "python3", fakeScriptLoggingArgs(log, "pwd >> "+log+"\ntouch unmigrated.txt"))
|
|
|
|
err := RunSettingsConvert(context.Background(), SettingsConvertOptions{
|
|
ScriptPath: "/tmp/migrate_v016.py", SettingsPath: "/tmp/s.json", PrincipalsPath: "/tmp/p.json",
|
|
ConfigPath: filepath.Join(dir, "c.json"), OutputPath: filepath.Join(dir, "e.json"),
|
|
WorkDir: workDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(readArgsFile(t, log), workDir) {
|
|
t.Errorf("script did not run in WorkDir; log:\n%s", readArgsFile(t, log))
|
|
}
|
|
if _, err := os.Stat(filepath.Join(workDir, "unmigrated.txt")); err != nil {
|
|
t.Errorf("unmigrated.txt should land in WorkDir, not the caller's cwd: %v", err)
|
|
}
|
|
}
|
|
|
|
// The numbers here are the real ones from a production instance, because
|
|
// the point of classifying is what it does to those numbers: 12,182 reads
|
|
// as impossible, and is mostly nothing to do.
|
|
const productionUnmigrated = `# Unmigrated v0.15 settings
|
|
|
|
Total unmigrated keys: 12182 across 69 prefixes.
|
|
|
|
server.blocked-ip 8547 keys
|
|
lookup.url-redirectors 1076 keys
|
|
lookup.trusted-domains 828 keys
|
|
spam-filter.list 537 keys
|
|
spam-filter.rule 424 keys
|
|
spam-filter.dnsbl 292 keys
|
|
lookup.surbl-hashbl 180 keys
|
|
queue.schedule 41 keys
|
|
server.listener 26 keys
|
|
signature.rsa-example.com 22 keys
|
|
server.auto-ban 16 keys
|
|
session.auth 14 keys
|
|
`
|
|
|
|
func classifyProduction(t *testing.T) *ClassifiedReport {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "unmigrated.txt")
|
|
if err := os.WriteFile(path, []byte(productionUnmigrated), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
report, err := ReadUnmigratedReport(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return report.Classify()
|
|
}
|
|
|
|
func TestClassifySeparatesWorkFromNoise(t *testing.T) {
|
|
c := classifyProduction(t)
|
|
|
|
// Runtime state: auto-ban repopulates it.
|
|
if got := c.Counts[DispositionRegenerates]; got != 8547 {
|
|
t.Errorf("regenerates = %d, want 8547 (server.blocked-ip)", got)
|
|
}
|
|
// Stock data v0.16 ships: restoring v0.15's would revert it.
|
|
if got := c.Counts[DispositionShipped]; got != 1076+828+537+424+292+180 {
|
|
t.Errorf("shipped = %d, want the stock spam/lookup groups", got)
|
|
}
|
|
// Carried by another route.
|
|
if got := c.Counts[DispositionCarried]; got != 22+26 {
|
|
t.Errorf("carried = %d, want the signature and listener groups", got)
|
|
}
|
|
// What's actually left for a human.
|
|
if got := c.Counts[DispositionReview]; got != 41+16+14 {
|
|
t.Errorf("needs review = %d, want %d", got, 41+16+14)
|
|
}
|
|
}
|
|
|
|
// server.blocked-ip is runtime state; server.auto-ban sitting right next to
|
|
// it is configuration. A shortest-prefix match would get this wrong.
|
|
func TestClassifyPrefersTheMoreSpecificRule(t *testing.T) {
|
|
c := classifyProduction(t)
|
|
for _, g := range c.Groups {
|
|
switch g.Prefix {
|
|
case "server.blocked-ip":
|
|
if g.Disposition != DispositionRegenerates {
|
|
t.Errorf("server.blocked-ip = %q, want regenerates", g.Disposition)
|
|
}
|
|
case "server.auto-ban":
|
|
if g.Disposition != DispositionReview {
|
|
t.Errorf("server.auto-ban = %q, want review - it is configuration, not runtime state", g.Disposition)
|
|
}
|
|
case "server.listener":
|
|
if g.Disposition != DispositionCarried {
|
|
t.Errorf("server.listener = %q, want carried - the apply plan regenerates it", g.Disposition)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClassifyReviewListIsTheActualWorklist(t *testing.T) {
|
|
review := classifyProduction(t).NeedsReview()
|
|
if len(review) != 3 {
|
|
t.Fatalf("review groups = %d, want 3", len(review))
|
|
}
|
|
if review[0].Prefix != "queue.schedule" {
|
|
t.Errorf("first review group = %q, want the largest (queue.schedule)", review[0].Prefix)
|
|
}
|
|
for _, g := range review {
|
|
if g.Disposition != DispositionReview {
|
|
t.Errorf("%s is in the review list with disposition %q", g.Prefix, g.Disposition)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClassifySummaryLeadsWithWhatMatters(t *testing.T) {
|
|
summary := classifyProduction(t).Summary("/var/lib/stalwart-migrator/runs/x/unmigrated.txt")
|
|
if !strings.Contains(summary, "NEED YOUR REVIEW") {
|
|
t.Errorf("summary should call out the review bucket:\n%s", summary)
|
|
}
|
|
if !strings.Contains(summary, "71 needing review are work") {
|
|
t.Errorf("summary should say how much is actually work:\n%s", summary)
|
|
}
|
|
if !strings.Contains(summary, "would revert them") {
|
|
t.Errorf("summary should warn against restoring stock data:\n%s", summary)
|
|
}
|
|
}
|
|
|
|
func TestClassifyUnknownPrefixesDefaultToReview(t *testing.T) {
|
|
d, note := classifyPrefix("something.nobody.has.seen")
|
|
if d != DispositionReview {
|
|
t.Errorf("unknown prefix = %q, want review - guessing that an unknown setting is safe to ignore is the wrong default", d)
|
|
}
|
|
if note != "" {
|
|
t.Errorf("note = %q, want empty for an unclassified prefix", note)
|
|
}
|
|
}
|
|
|
|
// A host with no route to the internet - an air-gapped server, or a clone cut
|
|
// off so it cannot renew certificates or deliver queued mail for the domains
|
|
// it was copied from - cannot fetch the migration script, and without a local
|
|
// copy could not be migrated at all.
|
|
func TestProvideFileUsesALocalCopy(t *testing.T) {
|
|
dir := t.TempDir()
|
|
src := filepath.Join(dir, "migrate_v016.py")
|
|
if err := os.WriteFile(src, []byte("print('hello')\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dest := filepath.Join(dir, "out.py")
|
|
|
|
// A client that would fail loudly if it were used.
|
|
refuse := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
|
return nil, fmt.Errorf("network must not be touched when a local copy was given")
|
|
})}
|
|
|
|
sum, err := ProvideFile(context.Background(), refuse, src, DefaultMigrationScriptURL, dest, "")
|
|
if err != nil {
|
|
t.Fatalf("ProvideFile: %v", err)
|
|
}
|
|
got, err := os.ReadFile(dest)
|
|
if err != nil || string(got) != "print('hello')\n" {
|
|
t.Fatalf("dest = %q, %v", got, err)
|
|
}
|
|
if want := fmt.Sprintf("%x", sha256.Sum256([]byte("print('hello')\n"))); sum != want {
|
|
t.Fatalf("sha256 = %s, want %s", sum, want)
|
|
}
|
|
}
|
|
|
|
func TestProvideFileChecksThePinnedHash(t *testing.T) {
|
|
dir := t.TempDir()
|
|
src := filepath.Join(dir, "migrate_v016.py")
|
|
if err := os.WriteFile(src, []byte("print('hello')\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := ProvideFile(context.Background(), nil, src, DefaultMigrationScriptURL, filepath.Join(dir, "out.py"), "deadbeef")
|
|
if err == nil {
|
|
t.Fatal("a local copy must still be checked against a pinned hash")
|
|
}
|
|
}
|
|
|
|
func TestProvideFileReportsAMissingLocalCopy(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if _, err := ProvideFile(context.Background(), nil, filepath.Join(dir, "nope.py"), DefaultMigrationScriptURL, filepath.Join(dir, "out.py"), ""); err == nil {
|
|
t.Fatal("expected an error for a local copy that is not there")
|
|
}
|
|
}
|
|
|
|
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|