mesh/merge-gate head 73404a3c3f21 merged into trunk 5f491e0c097d — pass: builds betterbird, sent nowhere; no bus step; 2 wait(s) for a person; every machin…
mesh/repo-check head 73404a3c3f21 merged into trunk 5f491e0c097d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
With calendar.alarms.showmissed on, Betterbird's alarm service fires every reminder of the past month that carries no later acknowledgement at each load of a calendar. Owl never sends a dismissal to Exchange and keeps it only in Betterbird's local cache, so on g14 September's Exchange meetings came back about every ten minutes, Dismiss or not. The policy now locks the pass off whatever the setting or profile says, and betterbird_check says when the running Betterbird still has it on beside an Owl calendar.
373 lines
14 KiB
Go
373 lines
14 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"
|
|
"os"
|
|
"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, " +
|
|
"the start page and the reminders of meetings that have passed 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"},
|
|
{missedPref, "the reminders of meetings that have passed (novox/hq issue 640)"},
|
|
}
|
|
|
|
// missedPref is Betterbird's pass over missed reminders. With it on, the calendar's alarm service fires, at
|
|
// every load of a calendar (each start, each refresh, each time Owl registers its calendar after signing in),
|
|
// every reminder of the past month whose item carries no later acknowledgement (X-MOZ-LASTACK); with it off
|
|
// (Betterbird's own default), a past reminder fires only while its meeting has not ended. Owl never sends a
|
|
// dismissal to Exchange (its changes carry the reminder's offset alone) and keeps it only in Betterbird's local
|
|
// cache, which a resynchronisation from scratch empties: a passed meeting's reminder then comes back at every
|
|
// reload, Dismiss or not (novox/hq issue 640). The policy locks it off, so no setting and no profile can turn the
|
|
// pass back on; betterbird_check says when the running Betterbird still has it on beside an Owl calendar.
|
|
const missedPref = "calendar.alarms.showmissed"
|
|
|
|
// missedReminders says why the running Betterbird shows a passed meeting's reminder again at each reload of an
|
|
// Owl calendar, with the remedy, or two empty strings: Betterbird's pass over missed reminders on (the profile's
|
|
// user.js, which wins at the start, else its prefs.js; Betterbird's default is off) while the profile holds an
|
|
// Owl calendar and the placed policy did not lock it off before Betterbird started.
|
|
func (m *Machine) missedReminders(profile string) (string, string) {
|
|
if profile == "" {
|
|
return "", ""
|
|
}
|
|
prefs := mergedPrefs(profile)
|
|
owl := 0
|
|
for _, c := range profileCalendars(prefs) {
|
|
if c.Kind == "owl" {
|
|
owl++
|
|
}
|
|
}
|
|
if owl == 0 {
|
|
return "", ""
|
|
}
|
|
value, from := "false", "Betterbird's default"
|
|
for _, f := range []string{"user.js", "prefs.js"} {
|
|
b, err := readBounded(filepath.Join(profile, f))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if v, ok := statementsOf(b)[missedPref]; ok {
|
|
value, from = v, "the profile's "+f
|
|
break
|
|
}
|
|
}
|
|
locked := m.locks()[missedPref] == "false"
|
|
ps := m.mainProcs()
|
|
if locked {
|
|
if len(ps) == 0 {
|
|
return "", ""
|
|
}
|
|
st, err := os.Stat(m.path(policyFile))
|
|
t, terr := time.Parse(time.RFC3339, ps[0].Since)
|
|
if err != nil || terr != nil || !st.ModTime().After(t) {
|
|
return "", ""
|
|
}
|
|
}
|
|
if value != "true" || len(ps) == 0 {
|
|
return "", ""
|
|
}
|
|
what := fmt.Sprintf("Betterbird shows the reminders of meetings that have passed again at every reload of its %d Owl "+
|
|
"calendar(s): %s is true (%s), and Owl keeps a dismissal only in Betterbird's local cache, never on the Exchange "+
|
|
"server (novox/hq issue 640)", owl, missedPref, from)
|
|
if locked {
|
|
return what, "betterbird_restart: the module's policy locks " + missedPref + " off, and the running Betterbird started before it was placed"
|
|
}
|
|
return what, "push the module to this node: its policy locks " + missedPref + " off; then betterbird_restart"
|
|
}
|
|
|
|
// 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
|
|
}
|