mesh/merge-gate head 481e44cdaa29 merged into trunk 23ea0f431faf — pass: builds betterbird, sent nowhere; no bus step; 1 wait(s) for a person; every machin…
mesh/repo-check head 481e44cdaa29 merged into trunk 23ea0f431faf — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Betterbird's own default asks at every start with a window to become the default for mail, news, feeds and calendar files. The policy locks mail.shell.checkDefaultClient off, which wins over a profile's own value, and betterbird_check says when a node would still ask.
291 lines
10 KiB
Go
291 lines
10 KiB
Go
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 say.
|
|
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 off, Owl installed into every profile from the mesh's xpi, its updates off, " +
|
|
"and the ask to be the default mail client locked off"
|
|
}
|
|
|
|
// policyProblem is what the policy fails to say of what ADR 0316 asks, or "".
|
|
func policyProblem(raw []byte) string {
|
|
var p struct {
|
|
Policies struct {
|
|
DisableAppUpdate bool `json:"DisableAppUpdate"`
|
|
ExtensionSettings map[string]struct {
|
|
Mode string `json:"installation_mode"`
|
|
URL string `json:"install_url"`
|
|
Disabled bool `json:"updates_disabled"`
|
|
} `json:"ExtensionSettings"`
|
|
Preferences map[string]struct {
|
|
Value any `json:"Value"`
|
|
Status string `json:"Status"`
|
|
} `json:"Preferences"`
|
|
} `json:"policies"`
|
|
}
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return "it is not JSON: " + err.Error()
|
|
}
|
|
owl, ok := p.Policies.ExtensionSettings[owlID]
|
|
switch {
|
|
case !p.Policies.DisableAppUpdate:
|
|
return "it does not turn Betterbird's updater off (DisableAppUpdate)"
|
|
case !ok || owl.Mode != "force_installed":
|
|
return "it does not install Owl into every profile (force_installed)"
|
|
case owl.URL != "file://"+owlFile:
|
|
return "it installs Owl from " + owl.URL + ", not from the mesh's xpi"
|
|
case !owl.Disabled:
|
|
return "it leaves Owl's updates on"
|
|
}
|
|
if pr, ok := p.Policies.Preferences[defaultClientPref]; !ok || pr.Value != false || pr.Status != "locked" {
|
|
return "it does not lock Betterbird's ask to be the default mail client off (Preferences " + defaultClientPref + ": false, locked)"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// defaultClientAsk says why Betterbird asks at its start to be the default mail client, or "" when it does
|
|
// not: the module's policy 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.
|
|
func (m *Machine) defaultClientAsk(profile string) string {
|
|
raw, err := readBounded(m.path(policyFile))
|
|
if err == nil && !strings.Contains(policyProblem(raw), defaultClientPref) {
|
|
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 := readPrefs(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
|
|
}
|