package main // What the mesh installs (novox/hq ADR 0316 §1–§9, to-be 56 §1–§3): Betterbird from its pinned upstream release // in /opt/betterbird, where the community package betterbird-bin put it, so Betterbird keeps choosing the same // profile; the enterprise policy in its distribution directory, which turns Betterbird's own updater off and // installs Owl into every profile from the xpi the mesh placed beside it; and a defaults file naming the // system's spelling dictionaries. The tools read these files and say what they find: the version against the // pin and the newest published build, whether the policy is placed, Owl's version against its pin. // // **The pin on the machine is what the mesh placed.** The manifest pins the release and the xpi by sha256; the // node-engine places exactly those bytes, so the version in /opt/betterbird/application.ini (when the mesh // placed it) and the version in the placed xpi are the pins. The community package's own copy is said as // such, and the switch from it waits for the operator (ADR 0316 §4). import ( "archive/zip" "encoding/json" "fmt" "io" "net/http" "path/filepath" "strings" "time" ) const ( installDir = "/opt/betterbird" // policyFile is the enterprise policy the module places (ADR 0316 §7). policyFile = installDir + "/distribution/policies.json" // owlFile is Owl's xpi as the mesh placed it, and the URL the policy installs it from. owlFile = installDir + "/owl/owl.xpi" owlID = "owl@beonex.com" // defaultClientPref is Betterbird's ask at its start to be the default mail, news, feed and calendar // client (Thunderbird's all-thunderbird.js: pref("mail.shell.checkDefaultClient", true); messenger.js opens // systemIntegrationDialog.xhtml while it is true). The module's policy locks it off (novox/hq issue 345). defaultClientPref = "mail.shell.checkDefaultClient" // preparation is the tools' program as the module places it for its start (betterbird-tools prepare). preparation = "/usr/local/lib/mesh-betterbird/betterbird-tools" // versionsURL is where Betterbird publishes its newest build per major version: what its own updater // reads (MailGlue's BetterbirdUpdateCheck), and what the status compares the installed build with. versionsURL = "https://www.betterbird.eu/start/versions.txt" ) // publishedBuilds fetches Betterbird's list of newest builds: a variable so a test answers. var publishedBuilds = func() (string, error) { client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Get(versionsURL) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("%s answered %s", versionsURL, resp.Status) } body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) return string(body), err } // Installation is what is installed, and from where. type Installation struct { // From is the mesh, the community package (betterbird-bin, before the switch), or nothing. From string `json:"from"` Version string `json:"version,omitempty"` Build string `json:"build,omitempty"` // Newest is the newest build Betterbird published for this major version, and whether this one is behind. Newest string `json:"newest_published_build,omitempty"` Behind bool `json:"behind,omitempty"` // Policy says whether the module's enterprise policy is placed, and what it does. Policy string `json:"policy"` // Owl is the add-on's version as the mesh placed it (the pin), and as the profile runs it. Owl *OwlState `json:"owl,omitempty"` } // OwlState is Owl against its pin. type OwlState struct { Pinned string `json:"pinned,omitempty"` InProfile string `json:"in_profile,omitempty"` Active bool `json:"active"` AtThePin bool `json:"at_the_pin"` // Note says, as a fact, a profile running an Owl newer than the pin: the policy's install keeps a newer // version, and Owl installed by policy cannot be removed in Betterbird. Note string `json:"note,omitempty"` } // newerVersion says whether version a is newer than b, comparing dotted numbers. func newerVersion(a, b string) bool { if a == "" || b == "" { return false } pa, pb := strings.Split(a, "."), strings.Split(b, ".") for i := 0; i < len(pa) || i < len(pb); i++ { var x, y int if i < len(pa) { fmt.Sscanf(pa[i], "%d", &x) } if i < len(pb) { fmt.Sscanf(pb[i], "%d", &y) } if x != y { return x > y } } return false } // installation reads what is installed. profile may be empty when none is found yet. func (m *Machine) installation(profile string) Installation { var in Installation community, _ := m.installed(packageFor) raw, err := readBounded(m.path(appIni)) switch { case community != "": in.From = "the community package " + packageFor + " " + community + ": the switch to the mesh's release waits for the operator's push, with Betterbird quit (novox/hq ADR 0316 §4)" case err == nil: in.From = "the mesh" default: in.From = "not installed" } if err == nil { _, sec := iniSections(raw) in.Version, in.Build = sec["App"]["Version"], sec["App"]["BuildID"] in.Newest, in.Behind = newestBuild(in.Version, in.Build) } in.Policy = m.policyState() pinned := xpiVersion(m.path(owlFile)) owl := &OwlState{Pinned: pinned} if profile != "" { for _, a := range addOns(profile) { if a.ID == owlID { owl.InProfile, owl.Active = a.Version, a.Active } } } owl.AtThePin = owl.Pinned != "" && owl.Pinned == owl.InProfile if newerVersion(owl.InProfile, owl.Pinned) { owl.Note = "the profile runs Owl " + owl.InProfile + ", newer than the mesh's pin " + owl.Pinned + ": the policy " + "keeps a newer version and installs the pin only where Owl is missing. To run the same Owl everywhere, " + "move the module's pin in a reviewed change" } if owl.Pinned != "" || owl.InProfile != "" { in.Owl = owl } return in } // newestBuild is the newest build Betterbird published for a version's major, and whether build is older. func newestBuild(version, build string) (string, bool) { major, _, _ := strings.Cut(version, ".") if major == "" || build == "" { return "", false } list, err := publishedBuilds() if err != nil { return "unknown: " + err.Error(), false } for _, l := range strings.Split(list, "\n") { f := strings.Split(strings.TrimSpace(l), ",") if len(f) == 3 && f[0] == "Linux" && f[1] == major { return f[2], f[2] > build } } return "unknown: " + versionsURL + " names no build for " + major, false } // policyState says whether the module's policy is placed and does what ADR 0316 §5 and §7 and issue 345 ask. func (m *Machine) policyState() string { raw, err := readBounded(m.path(policyFile)) if err != nil { return "not placed: " + policyFile + " is missing" } if why := policyProblem(raw); why != "" { return "placed, and " + why } return "placed: Betterbird's updater, its telemetry and add-on updates off, Owl installed into every profile from " + "the mesh's xpi with its updates off, and the ask to be the default mail client, the vendor's in-app notices " + "and the start page locked off" } // lockedOff are the preferences the module's policy locks to false (novox/hq issue 345), each with what it is. var lockedOff = []struct{ Name, What string }{ {defaultClientPref, "the ask to be the default mail client"}, {"mail.inappnotifications.enabled", "the vendor's in-app notices"}, {"mailnews.start_page.enabled", "the start page, loaded from the vendor at every start"}, } // policyProblem is everything the policy fails to say of what ADR 0316 and issue 345 ask, or "". func policyProblem(raw []byte) string { var p struct { Policies struct { DisableAppUpdate bool `json:"DisableAppUpdate"` DisableTelemetry bool `json:"DisableTelemetry"` ExtensionUpdate *bool `json:"ExtensionUpdate"` ExtensionSettings map[string]struct { Mode string `json:"installation_mode"` URL string `json:"install_url"` Disabled bool `json:"updates_disabled"` } `json:"ExtensionSettings"` } `json:"policies"` } if err := json.Unmarshal(raw, &p); err != nil { return "it is not JSON: " + err.Error() } var problems []string if !p.Policies.DisableAppUpdate { problems = append(problems, "it does not turn Betterbird's updater off (DisableAppUpdate)") } if !p.Policies.DisableTelemetry { problems = append(problems, "it does not turn telemetry off (DisableTelemetry)") } if p.Policies.ExtensionUpdate == nil || *p.Policies.ExtensionUpdate { problems = append(problems, "it leaves add-ons updating themselves (ExtensionUpdate)") } owl, ok := p.Policies.ExtensionSettings[owlID] switch { case !ok || owl.Mode != "force_installed": problems = append(problems, "it does not install Owl into every profile (force_installed)") case owl.URL != "file://"+owlFile: problems = append(problems, "it installs Owl from "+owl.URL+", not from the mesh's xpi") case !owl.Disabled: problems = append(problems, "it leaves Owl's updates on") } prefs := policyPrefsOf(raw) for _, l := range lockedOff { if pr, ok := prefs[l.Name]; !ok || pr.Value != "false" || !pr.Locked { problems = append(problems, "it does not lock "+l.What+" off (Preferences "+l.Name+": false, locked)") } } return strings.Join(problems, "; ") } // defaultClientAsk says why Betterbird asks at its start to be the default mail client, or "" when it does // not: the placed policy's own Preferences entry locks the ask off, else the profile's user.js (which wins at // the start) or prefs.js turns it off. Betterbird's own default is to ask. Decided from the entry itself, never // from what else the policy lacks. func (m *Machine) defaultClientAsk(profile string) string { if v, ok := m.locks()[defaultClientPref]; ok && v == "false" { return "" } if profile != "" { for _, f := range []string{"user.js", "prefs.js"} { b, err := readBounded(filepath.Join(profile, f)) if err != nil { continue } if v, ok := statementsOf(b)[defaultClientPref]; ok { if v == "false" { return "" } return "the profile's " + f + " sets " + defaultClientPref + " to " + v + " and no policy locks it off" } } } return "no policy locks " + defaultClientPref + " off and Betterbird's default is to ask" } // xpiVersion is the version an add-on's manifest.json says, or "" when it cannot be read. func xpiVersion(path string) string { r, err := zip.OpenReader(path) if err != nil { return "" } defer r.Close() for _, f := range r.File { if f.Name != "manifest.json" { continue } rc, err := f.Open() if err != nil { return "" } defer rc.Close() var mf struct { Version string `json:"version"` } if json.NewDecoder(io.LimitReader(rc, 1<<20)).Decode(&mf) != nil { return "" } return mf.Version } return "" } // addOns reads a profile's extensions.json: each add-on's id, version, whether it is on, and where from. func addOns(profile string) []AddOn { raw, err := readBounded(filepath.Join(profile, "extensions.json")) if err != nil { return nil } var ext struct { Addons []struct { ID string `json:"id"` Version string `json:"version"` Active bool `json:"active"` Location string `json:"location"` } `json:"addons"` } if json.Unmarshal(raw, &ext) != nil { return nil } var out []AddOn for _, a := range ext.Addons { if a.Location == "app-profile" { out = append(out, AddOn{ID: a.ID, Version: a.Version, Active: a.Active}) } } return out }