Files
mesh-catalog/modules/betterbird/cmd/betterbird-tools/install.go
T
jochen 1ca26c944a
mesh/merge-gate fail: builds betterbird → shanks; no bus step; a manifest the change touches fails the module check: modules/betterbird/module.json: this i…
mesh/repo-check fail: its merge-check.sh failed: files asking for a setting without saying whether it is trusted: 24
mesh/delivery-group group fix/345-reminder-test rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
betterbird: withhold account values in the user.js answer, never write a shared outgoing server, and say a newer Owl as a fact (review 34, hq issue 345, ADR 0317)
The user.js answer carried declared addresses and hosts (ADR 0316 §16). An
adopted identity's outgoing server may be shared with an account the module
does not own, whose sending the declared values would change at every start:
refused with the remedy, since ADR 0317 §2 leaves the identity's server and
the lists as they are; a recorded adoption follows the identity's current
server. Owl installed by policy cannot be removed, so a newer Owl than the
pin is a fact whose remedy is moving the pin.
2026-10-12 13:19:49 +02:00

254 lines
8.6 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"
// 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"
}
// 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"`
} `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"
}
return ""
}
// 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
}