119 SPDX-FileCopyrightText headers and the README's licence line. The distinction that matters here: LINUXexpert-org appears in this repository in two completely different roles. As a copyright holder in the SPDX headers, which is what changes, and as the GitHub organisation in the module path and 64 import statements, which does not -- the repository still lives at github.com/LINUXexpert-org/stalwart-migrator, and rewriting that would not be a licence change, it would break the build. Both replacements are anchored to their copyright forms, so an import path cannot match either. Import count is 64 before and after, and go.mod is untouched. LICENSE untouched: the FSF's copyright on the GPL text and the "<name of author>" placeholders are not ours to edit. go vet, go build and go test all clean.
177 lines
6.3 KiB
Go
177 lines
6.3 KiB
Go
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package backup
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Disposition is what an operator actually has to do about a group of
|
|
// settings migrate_v016.py didn't carry over.
|
|
//
|
|
// The raw count is misleading on its own, and alarmingly so. A real
|
|
// production instance reported 12,182 unmigrated settings, which reads as
|
|
// an impossible amount of manual work. Categorised, 8,547 of them
|
|
// repopulate themselves, 3,337 ship with v0.16, ~198 had already been
|
|
// carried by another mechanism, and roughly 290 genuinely needed a human.
|
|
// The difference between those two framings is the difference between a
|
|
// migration nobody attempts and an afternoon's review.
|
|
type Disposition string
|
|
|
|
const (
|
|
// DispositionRegenerates: runtime state, not configuration. Nothing to
|
|
// do; it rebuilds itself in normal operation.
|
|
DispositionRegenerates Disposition = "regenerates"
|
|
// DispositionShipped: v0.16 provides its own copy, and restoring
|
|
// v0.15's would duplicate it or replace newer definitions with older
|
|
// ones.
|
|
DispositionShipped Disposition = "shipped with v0.16"
|
|
// DispositionCarried: the substance came across by another route even
|
|
// though these particular keys are listed as unmigrated.
|
|
DispositionCarried Disposition = "already carried"
|
|
// DispositionReview: genuinely site-specific. This is the real work.
|
|
DispositionReview Disposition = "needs review"
|
|
)
|
|
|
|
// dispositionRule maps a setting prefix to what to do about it.
|
|
//
|
|
// Every entry here was checked against a v0.16.14 instance migrated from a
|
|
// real v0.15.5 - by snapshotting the migrated store and counting what was
|
|
// already in it - not inferred from documentation. Rules are matched
|
|
// longest-prefix-first, because "server.blocked-ip" is runtime state while
|
|
// "server.auto-ban" right next to it is configuration.
|
|
type dispositionRule struct {
|
|
prefix string
|
|
disposition Disposition
|
|
note string
|
|
}
|
|
|
|
var dispositionRules = []dispositionRule{
|
|
{"server.blocked-ip", DispositionRegenerates,
|
|
"auto-ban repopulates these from live traffic; a migrated instance simply starts with an empty list"},
|
|
|
|
{"lookup.url-redirectors", DispositionShipped,
|
|
"v0.16 ships this list itself (observed as MemoryLookupKey/SpamUrlRedirector entries in a migrated store)"},
|
|
{"lookup.trusted-domains", DispositionShipped,
|
|
"v0.16 ships this list itself (MemoryLookupKey/SpamTrustedDomain)"},
|
|
{"lookup.surbl-hashbl", DispositionShipped,
|
|
"v0.16 ships this list itself (MemoryLookupKey)"},
|
|
{"spam-filter.rule", DispositionShipped,
|
|
"v0.16 ships its own rules - 66 SpamRule objects were present in a migrated store, the same stock set under v0.16's naming (stwt_x -> STWT_X). Restoring v0.15's would duplicate them and revert a year of updates"},
|
|
{"spam-filter.dnsbl", DispositionShipped,
|
|
"v0.16 ships its own DNSBL servers (18 SpamDnsblServer objects present after migration)"},
|
|
{"spam-filter.list", DispositionShipped,
|
|
"stock lists (file extensions and similar) that v0.16 provides itself"},
|
|
|
|
{"signature.", DispositionCarried,
|
|
"DKIM: carried across as DkimSignature objects, private keys included - verified on a migrated instance"},
|
|
{"server.listener", DispositionCarried,
|
|
"regenerated by this tool's own apply plan (see the supplement)"},
|
|
}
|
|
|
|
// Classification is one group of unmigrated settings with its disposition.
|
|
type Classification struct {
|
|
Prefix string
|
|
Keys int
|
|
Disposition Disposition
|
|
Note string
|
|
}
|
|
|
|
// ClassifiedReport is an UnmigratedReport with each group's disposition
|
|
// resolved, and totals per disposition.
|
|
type ClassifiedReport struct {
|
|
TotalKeys int
|
|
Groups []Classification
|
|
Counts map[Disposition]int
|
|
}
|
|
|
|
// Classify resolves what to do about each group in the report.
|
|
func (r *UnmigratedReport) Classify() *ClassifiedReport {
|
|
out := &ClassifiedReport{Counts: map[Disposition]int{}}
|
|
if r == nil {
|
|
return out
|
|
}
|
|
out.TotalKeys = r.TotalKeys
|
|
for _, p := range r.Prefixes {
|
|
disposition, note := classifyPrefix(p.Prefix)
|
|
out.Groups = append(out.Groups, Classification{
|
|
Prefix: p.Prefix, Keys: p.Keys, Disposition: disposition, Note: note,
|
|
})
|
|
out.Counts[disposition] += p.Keys
|
|
}
|
|
sort.Slice(out.Groups, func(i, j int) bool { return out.Groups[i].Keys > out.Groups[j].Keys })
|
|
return out
|
|
}
|
|
|
|
func classifyPrefix(prefix string) (Disposition, string) {
|
|
best := -1
|
|
var disposition Disposition = DispositionReview
|
|
var note string
|
|
for _, rule := range dispositionRules {
|
|
if !strings.HasPrefix(prefix, rule.prefix) {
|
|
continue
|
|
}
|
|
if len(rule.prefix) > best {
|
|
best, disposition, note = len(rule.prefix), rule.disposition, rule.note
|
|
}
|
|
}
|
|
return disposition, note
|
|
}
|
|
|
|
// NeedsReview returns just the groups a human has to deal with, largest
|
|
// first - the actual worklist, as opposed to the raw count.
|
|
func (c *ClassifiedReport) NeedsReview() []Classification {
|
|
var out []Classification
|
|
for _, g := range c.Groups {
|
|
if g.Disposition == DispositionReview {
|
|
out = append(out, g)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Summary renders the categorised report, leading with what needs a human
|
|
// rather than with the total.
|
|
func (c *ClassifiedReport) Summary(worklistPath string) string {
|
|
if c == nil || c.TotalKeys == 0 {
|
|
return "no unmigrated settings were reported"
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "%d setting(s) were not carried over by migrate_v016.py. What they actually are:\n", c.TotalKeys)
|
|
|
|
for _, d := range []Disposition{DispositionRegenerates, DispositionShipped, DispositionCarried, DispositionReview} {
|
|
n := c.Counts[d]
|
|
if n == 0 {
|
|
continue
|
|
}
|
|
label := string(d)
|
|
if d == DispositionReview {
|
|
label = "NEED YOUR REVIEW"
|
|
}
|
|
fmt.Fprintf(&b, "\n %6d %-18s", n, label)
|
|
groups := []string{}
|
|
for _, g := range c.Groups {
|
|
if g.Disposition == d && g.Keys > 0 {
|
|
groups = append(groups, fmt.Sprintf("%s (%d)", g.Prefix, g.Keys))
|
|
}
|
|
}
|
|
shown := groups
|
|
if len(shown) > 6 {
|
|
shown = shown[:6]
|
|
}
|
|
fmt.Fprintf(&b, " %s", strings.Join(shown, ", "))
|
|
if len(groups) > len(shown) {
|
|
fmt.Fprintf(&b, ", +%d more", len(groups)-len(shown))
|
|
}
|
|
}
|
|
|
|
review := c.Counts[DispositionReview]
|
|
fmt.Fprintf(&b, "\n\n Only the %d needing review are work. The rest either rebuild themselves,\n", review)
|
|
fmt.Fprintf(&b, " ship with v0.16 (restoring v0.15's copies would revert them), or came\n")
|
|
fmt.Fprintf(&b, " across by another route. Full key list: %s", worklistPath)
|
|
return b.String()
|
|
}
|