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.
940 lines
36 KiB
Go
940 lines
36 KiB
Go
package main
|
|
|
|
// Betterbird as the tools see it. Betterbird is the mail client's one process tree: the main process
|
|
// (comm betterbird-bin, no -contentproc) owns every window — the mail window, the calendar's reminder
|
|
// dialog, the composer — and its children (-contentproc) render add-ons and media. The tools read only:
|
|
// - the account's processes, and of the main process where its output goes (fd 1 and 2) and which
|
|
// profile it holds open;
|
|
// - profiles.ini, to find the profile, and the profile's prefs.js and user.js, of which only the
|
|
// preferences asked for are answered and never one that can hold a credential or names an account
|
|
// (passwordSecret, accountPrefix);
|
|
// - the profile's extensions.json, for the add-ons' ids and whether each is on;
|
|
// - the module's declared user.js (~/.config/betterbird/user.js), which the node-engine writes from
|
|
// the module's setting `prefs`, and its declared accounts (~/.config/betterbird/accounts.json, the
|
|
// setting `accounts`, accounts.go);
|
|
// - of logins.json, only the server name each kept sign-in is for, which Betterbird keeps in the clear,
|
|
// to count the declared accounts that have not signed in yet (novox/hq ADR 0316 §16).
|
|
//
|
|
// Never read: the mail store, the address books, a user name or password in logins.json, key4.db,
|
|
// cert9.db, cookies, the calendar's caches. Nothing of the operator's mail or calendar is answered, and of
|
|
// their accounts only counts and kinds.
|
|
//
|
|
// The files a tool writes in the profile are user.js, and only the module's own (the declared file and
|
|
// the declared accounts' preferences, betterbird_user_js with apply, betterbird_restart, and the start
|
|
// from the launcher), and the module's own keys in the account lists of prefs.js, only while Betterbird is
|
|
// not running (accounts.go). A user.js that is not the module's is never overwritten.
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
mainComm = "betterbird-bin"
|
|
// startBin is Betterbird itself, where the mesh's release and the community package both put it. The
|
|
// module's start (/usr/local/bin/betterbird) runs the preparation first; a tool that starts Betterbird
|
|
// prepares itself and starts this.
|
|
startBin = installDir + "/betterbird"
|
|
// packageFor is the community package the mesh's release replaces (novox/hq ADR 0316 §4).
|
|
packageFor = "betterbird-bin"
|
|
appIni = "/opt/betterbird/application.ini"
|
|
restartAs = "mesh-betterbird"
|
|
testUnit = "mesh-betterbird-reminder-test"
|
|
// profilesDir is where Betterbird keeps its profiles: Thunderbird's place, which Betterbird shares.
|
|
profilesDir = ".thunderbird"
|
|
// declared is the module's user.js, written by the node-engine from the setting prefs.
|
|
declared = ".config/betterbird/user.js"
|
|
// mark is the first line of the module's user.js: how a user.js is known to be the module's.
|
|
mark = "// Betterbird's user.js (module betterbird"
|
|
)
|
|
|
|
// readLink is how a process's file descriptors are read; tests replace it, so no test makes a link.
|
|
var readLink = os.Readlink
|
|
|
|
// mainProcs are Betterbird's main processes: comm betterbird-bin without -contentproc.
|
|
func (m *Machine) mainProcs() []Proc {
|
|
var out []Proc
|
|
for _, p := range m.procs(mainComm) {
|
|
// The reminder test's Betterbird is never the operator's: its command line names its own profile.
|
|
if !strings.Contains(p.Command, "-contentproc") && !strings.Contains(p.Command, testUnit) {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Stream is where a process's standard output or error goes.
|
|
type Stream struct {
|
|
FD int `json:"fd"`
|
|
Goes string `json:"goes"`
|
|
// Dead is a pipe no process of the account reads: a write there fails.
|
|
Dead bool `json:"dead,omitempty"`
|
|
}
|
|
|
|
// streams says where fd 1 and 2 of a process go.
|
|
func (m *Machine) streams(pid int) []Stream {
|
|
var out []Stream
|
|
for _, fd := range []int{1, 2} {
|
|
target, err := readLink(m.path(fmt.Sprintf("/proc/%d/fd/%d", pid, fd)))
|
|
s := Stream{FD: fd}
|
|
switch {
|
|
case err != nil:
|
|
s.Goes = "unreadable"
|
|
case target == "/dev/null":
|
|
s.Goes = "discarded (/dev/null)"
|
|
case strings.HasPrefix(target, "socket:"):
|
|
s.Goes = "a socket (the journal, when started by the module)"
|
|
case strings.HasPrefix(target, "pipe:"):
|
|
if r := m.pipeReaders(target, pid); len(r) > 0 {
|
|
s.Goes = "a pipe read by " + strings.Join(r, ", ")
|
|
} else {
|
|
s.Goes, s.Dead = "a pipe nobody reads", true
|
|
}
|
|
default:
|
|
s.Goes = m.tilde(target)
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pipeReaders are the account's processes holding the pipe open for reading (O_RDONLY in fdinfo).
|
|
func (m *Machine) pipeReaders(pipe string, except int) []string {
|
|
entries, _ := os.ReadDir(m.path("/proc"))
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, e := range entries {
|
|
pid, err := strconv.Atoi(e.Name())
|
|
if err != nil || pid == except {
|
|
continue
|
|
}
|
|
dir := m.path(filepath.Join("/proc", e.Name()))
|
|
if m.uidOf(dir) != m.UID {
|
|
continue
|
|
}
|
|
fds, _ := os.ReadDir(filepath.Join(dir, "fd"))
|
|
for _, fd := range fds {
|
|
if t, err := readLink(filepath.Join(dir, "fd", fd.Name())); err != nil || t != pipe {
|
|
continue
|
|
}
|
|
if readOnly(readTrimmed(filepath.Join(dir, "fdinfo", fd.Name()))) {
|
|
name := fmt.Sprintf("%s (pid %d)", readTrimmed(filepath.Join(dir, "comm")), pid)
|
|
if !seen[name] {
|
|
seen[name] = true
|
|
out = append(out, name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// readOnly reads an fdinfo's flags (octal): the access mode is its low two bits, 0 for reading.
|
|
func readOnly(fdinfo string) bool {
|
|
for _, l := range strings.Split(fdinfo, "\n") {
|
|
if f := strings.Fields(l); len(f) == 2 && f[0] == "flags:" {
|
|
n, err := strconv.ParseInt(f[1], 8, 64)
|
|
return err == nil && n&3 == 0
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// iniSections reads an INI file into its sections, in order.
|
|
func iniSections(raw []byte) (names []string, sections map[string]map[string]string) {
|
|
sections = map[string]map[string]string{}
|
|
cur := ""
|
|
s := bufio.NewScanner(bytes.NewReader(raw))
|
|
for s.Scan() {
|
|
l := strings.TrimSpace(s.Text())
|
|
switch {
|
|
case l == "" || strings.HasPrefix(l, ";") || strings.HasPrefix(l, "#"):
|
|
case strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]"):
|
|
cur = l[1 : len(l)-1]
|
|
if _, ok := sections[cur]; !ok {
|
|
names = append(names, cur)
|
|
sections[cur] = map[string]string{}
|
|
}
|
|
default:
|
|
if i := strings.IndexByte(l, '='); i > 0 && cur != "" {
|
|
sections[cur][strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
|
|
}
|
|
}
|
|
}
|
|
return names, sections
|
|
}
|
|
|
|
// Profile is the profile the tools act on, and how it was found.
|
|
type Profile struct {
|
|
Dir string `json:"dir"`
|
|
FoundBy string `json:"found_by"`
|
|
path string
|
|
}
|
|
|
|
// profile finds the profile: the one the running Betterbird holds open, else the installation's
|
|
// default in profiles.ini ([Install…] Default), else the profile marked Default=1.
|
|
func (m *Machine) profile() (Profile, error) {
|
|
base := m.home(profilesDir)
|
|
if ps := m.mainProcs(); len(ps) > 0 {
|
|
counts := map[string]int{}
|
|
fds, _ := os.ReadDir(m.path(fmt.Sprintf("/proc/%d/fd", ps[0].PID)))
|
|
for _, fd := range fds {
|
|
t, err := readLink(m.path(fmt.Sprintf("/proc/%d/fd/%s", ps[0].PID, fd.Name())))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
rel, err := filepath.Rel(filepath.Join(m.Home, profilesDir), t)
|
|
if err != nil || strings.HasPrefix(rel, "..") || !strings.Contains(rel, "/") {
|
|
continue
|
|
}
|
|
counts[strings.SplitN(rel, "/", 2)[0]]++
|
|
}
|
|
best, n := "", 0
|
|
for d, c := range counts {
|
|
if c > n || (c == n && d < best) {
|
|
best, n = d, c
|
|
}
|
|
}
|
|
if best != "" {
|
|
return Profile{Dir: m.tilde(filepath.Join(m.Home, profilesDir, best)), path: filepath.Join(base, best),
|
|
FoundBy: fmt.Sprintf("held open by the running Betterbird (pid %d, %d files)", ps[0].PID, n)}, nil
|
|
}
|
|
}
|
|
raw, err := readBounded(filepath.Join(base, "profiles.ini"))
|
|
if err != nil {
|
|
return Profile{}, fmt.Errorf("no profile: Betterbird is not running and ~/%s/profiles.ini is unreadable (%v)", profilesDir, err)
|
|
}
|
|
names, sections := iniSections(raw)
|
|
pick := func(rel string, by string) (Profile, error) {
|
|
dir := filepath.Join(base, filepath.FromSlash(rel))
|
|
if filepath.IsAbs(rel) {
|
|
dir = m.path(rel)
|
|
}
|
|
if !strings.HasPrefix(filepath.Clean(dir), filepath.Clean(base)+string(filepath.Separator)) {
|
|
return Profile{}, fmt.Errorf("profiles.ini names a profile outside ~/%s: %s", profilesDir, rel)
|
|
}
|
|
return Profile{Dir: m.tilde(strings.TrimPrefix(dir, m.Root)), path: dir, FoundBy: by}, nil
|
|
}
|
|
var installs []string
|
|
for _, n := range names {
|
|
if strings.HasPrefix(n, "Install") && sections[n]["Default"] != "" {
|
|
installs = append(installs, n)
|
|
}
|
|
}
|
|
if len(installs) == 1 {
|
|
return pick(sections[installs[0]]["Default"], "profiles.ini: the installation's default ["+installs[0]+"]")
|
|
}
|
|
for _, n := range names {
|
|
if strings.HasPrefix(n, "Profile") && sections[n]["Default"] == "1" && sections[n]["Path"] != "" {
|
|
return pick(sections[n]["Path"], "profiles.ini: the profile marked Default=1 ["+n+"]")
|
|
}
|
|
}
|
|
return Profile{}, fmt.Errorf("profiles.ini names %d installation defaults and no profile marked default: start Betterbird once, then ask again", len(installs))
|
|
}
|
|
|
|
// prefLine is one user_pref(…) or pref(…) line of prefs.js or user.js.
|
|
var prefLine = regexp.MustCompile(`^\s*(?:user_)?pref\(\s*"((?:[^"\\]|\\.)*)"\s*,\s*(.*?)\s*\)\s*;\s*$`)
|
|
|
|
// readPrefs reads the preferences of a prefs.js or user.js: name to its value, as written.
|
|
func readPrefs(raw []byte) map[string]string {
|
|
out := map[string]string{}
|
|
s := bufio.NewScanner(bytes.NewReader(raw))
|
|
s.Buffer(make([]byte, 64<<10), 4<<20)
|
|
for s.Scan() {
|
|
if mm := prefLine.FindStringSubmatch(s.Text()); mm != nil {
|
|
out[mm[1]] = mm[2]
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// passwordSecret and accountPrefix say which preferences are never answered with their value: one
|
|
// whose name can hold a credential, and one that names an account, a server or a person.
|
|
var (
|
|
passwordSecret = regexp.MustCompile(`(?i)(password|passwd|secret|token|oauth|auth|cookie|credential|signon|apikey|api_key|privatekey|private_key|\.key$)`)
|
|
accountPrefix = []string{"mail.server.", "mail.identity.", "mail.smtpserver.", "mail.smtp.", "mail.account.",
|
|
"mail.accountmanager.", "ldap_2.", "extensions.owl.", "calendar.registry.", "mail.root.", "mailnews.",
|
|
"services.sync.", "mail.displayname", "mail.default_", "mail.last_msg", "messenger.account.", "mail.cloud_files."}
|
|
// allowedExtensions are the only extensions.* preferences answered: the add-ons' own preferences can
|
|
// hold anything, and a theme's id is what a window's look depends on.
|
|
allowedExtensions = map[string]bool{"extensions.activeThemeID": true}
|
|
)
|
|
|
|
// accountOwned are the preferences of an account and of the account lists: the setting accounts' alone, so
|
|
// the setting prefs refuses them and an account has one owner (novox/hq ADR 0316 §11).
|
|
var accountOwned = []string{"mail.account.", "mail.identity.", "mail.server.", "mail.smtpserver.",
|
|
"mail.accountmanager.", "mail.smtpservers", "mail.smtp.defaultserver"}
|
|
|
|
// withheld says why a preference's value is never answered, or "" when it may be.
|
|
func withheld(name string) string {
|
|
if strings.HasPrefix(name, "extensions.") && !allowedExtensions[name] {
|
|
return "an add-on's own preference: only the theme's id (extensions.activeThemeID) is answered"
|
|
}
|
|
if passwordSecret.MatchString(name) {
|
|
return "its name says it can hold a credential"
|
|
}
|
|
for _, p := range accountPrefix {
|
|
if strings.HasPrefix(name, p) {
|
|
return "it names an account, a server, a calendar or a person"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Pref is one preference as the profile holds it.
|
|
type Pref struct {
|
|
Name string `json:"name"`
|
|
// Value is what Betterbird uses: user.js's at the start, else prefs.js's. Absent: the default.
|
|
Value string `json:"value,omitempty"`
|
|
From string `json:"from,omitempty"`
|
|
Withheld string `json:"withheld,omitempty"`
|
|
}
|
|
|
|
// mostPrefs bounds how many preferences one answer carries.
|
|
const mostPrefs = 200
|
|
|
|
// Prefs answers the named preferences (a name, or a prefix ending in . or *) from the profile.
|
|
func (m *Machine) Prefs(names []string) (map[string]any, error) {
|
|
if len(names) == 0 {
|
|
return nil, errors.New("names is required: preferences by name, or a prefix ending in . or *, e.g. calendar.alarms.")
|
|
}
|
|
for _, n := range names {
|
|
if strings.Trim(strings.TrimSpace(n), ".*") == "" {
|
|
return nil, fmt.Errorf("%q asks for every preference: name one, or a prefix such as calendar.alarms.", n)
|
|
}
|
|
}
|
|
p, err := m.profile()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
prefsJS, _ := readBounded(filepath.Join(p.path, "prefs.js"))
|
|
userJS, _ := readBounded(filepath.Join(p.path, "user.js"))
|
|
fromPrefs, fromUser := readPrefs(prefsJS), readPrefs(userJS)
|
|
all := map[string]bool{}
|
|
for n := range fromPrefs {
|
|
all[n] = true
|
|
}
|
|
for n := range fromUser {
|
|
all[n] = true
|
|
}
|
|
var out []Pref
|
|
cut := false
|
|
seen := map[string]bool{}
|
|
for _, want := range names {
|
|
prefix := strings.HasSuffix(want, ".") || strings.HasSuffix(want, "*")
|
|
want = strings.TrimSuffix(want, "*")
|
|
var matched []string
|
|
if prefix {
|
|
for n := range all {
|
|
if strings.HasPrefix(n, want) {
|
|
matched = append(matched, n)
|
|
}
|
|
}
|
|
sort.Strings(matched)
|
|
} else {
|
|
matched = []string{want}
|
|
}
|
|
for _, n := range matched {
|
|
if seen[n] {
|
|
continue
|
|
}
|
|
seen[n] = true
|
|
if len(out) == mostPrefs {
|
|
cut = true
|
|
break
|
|
}
|
|
pr := Pref{Name: n}
|
|
v, from := fromPrefs[n], "prefs.js"
|
|
if u, ok := fromUser[n]; ok {
|
|
v, from = u, "user.js (wins at the start)"
|
|
} else if _, ok := fromPrefs[n]; !ok {
|
|
from = "not set: Betterbird's default"
|
|
v = ""
|
|
}
|
|
pr.From = from
|
|
if why := withheld(n); why != "" {
|
|
pr.Withheld = why
|
|
} else if allowedExtensions[n] {
|
|
pr.Value = v
|
|
} else {
|
|
pr.Value = m.maskValue(v)
|
|
}
|
|
out = append(out, pr)
|
|
}
|
|
}
|
|
answer := map[string]any{"profile": p, "prefs": out}
|
|
if cut {
|
|
answer["cut"] = fmt.Sprintf("the first %d; ask for a narrower prefix", mostPrefs)
|
|
}
|
|
return answer, nil
|
|
}
|
|
|
|
// statement is the one thing a line of the module's user.js may be, beside a comment.
|
|
var statement = regexp.MustCompile(`^user_pref\("[A-Za-z0-9_.\-\[\]]+",\s*(true|false|-?[0-9]+|"(?:[^"\\]|\\.)*")\);$`)
|
|
|
|
// validUserJS says what is wrong with a user.js the module would place, or nil. It holds comments and
|
|
// user_pref(…) statements only — the setting is one line, so several statements may share one — and
|
|
// sets no preference whose name could hold a credential.
|
|
func validUserJS(text string) error {
|
|
if !strings.HasPrefix(text, mark) {
|
|
return fmt.Errorf("it does not begin with the module's mark (%q)", mark)
|
|
}
|
|
for i, line := range strings.Split(text, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "//") {
|
|
continue
|
|
}
|
|
for _, st := range splitStatements(line) {
|
|
if st == "" || strings.HasPrefix(st, "//") {
|
|
continue
|
|
}
|
|
if !statement.MatchString(st) {
|
|
return fmt.Errorf("line %d holds %q, which is not user_pref(\"name\", value);", i+1, st)
|
|
}
|
|
name := st[len(`user_pref("`):strings.Index(st, `",`)]
|
|
if passwordSecret.MatchString(name) {
|
|
return fmt.Errorf("line %d sets %s, a preference that can hold a credential: not through the mesh", i+1, name)
|
|
}
|
|
for _, p := range accountOwned {
|
|
if strings.HasPrefix(name, p) {
|
|
return fmt.Errorf("line %d sets %s, an account's preference: an account is declared in the setting "+
|
|
"accounts, never in prefs (novox/hq ADR 0316 §11)", i+1, name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// splitStatements cuts a line after each `);`, outside a string.
|
|
func splitStatements(line string) []string {
|
|
var out []string
|
|
in, esc, start := false, false, 0
|
|
for i := 0; i < len(line); i++ {
|
|
c := line[i]
|
|
switch {
|
|
case esc:
|
|
esc = false
|
|
case c == '\\' && in:
|
|
esc = true
|
|
case c == '"':
|
|
in = !in
|
|
case c == ';' && !in:
|
|
out = append(out, strings.TrimSpace(line[start:i+1]))
|
|
start = i + 1
|
|
}
|
|
}
|
|
if rest := strings.TrimSpace(line[start:]); rest != "" {
|
|
out = append(out, rest)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// UserJS is the module's user.js against the profile's.
|
|
type UserJS struct {
|
|
Declared string `json:"declared"`
|
|
Present bool `json:"declared_present"`
|
|
Valid string `json:"declared_problem,omitempty"`
|
|
Profile string `json:"profile_user_js"`
|
|
Placed string `json:"placed"`
|
|
Statements int `json:"statements"`
|
|
// Effective says whether the running Betterbird started after the file was placed.
|
|
Effective string `json:"effective,omitempty"`
|
|
Content string `json:"content,omitempty"`
|
|
Applied bool `json:"applied,omitempty"`
|
|
// Accounts are how many accounts the setting accounts declares, and what is wrong with them.
|
|
Accounts int `json:"declared_accounts"`
|
|
AccountsProblem string `json:"accounts_problem,omitempty"`
|
|
// Each is every declared account by its place: adopted, created or refused (with why), its keys, and the
|
|
// preferences an apply changed for it, by name (novox/hq ADR 0317 §3, §4).
|
|
Each []resolvedAccount `json:"accounts,omitempty"`
|
|
// Lists says whether the keys of the accounts the module created were written into the account lists.
|
|
Lists string `json:"account_lists,omitempty"`
|
|
}
|
|
|
|
// refusedAccounts are the reasons of the declared accounts that were refused.
|
|
func (u UserJS) refusedAccounts() []string {
|
|
var out []string
|
|
for _, r := range u.Each {
|
|
if r.How == refused {
|
|
out = append(out, r.Why)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// userJS compares the user.js the module places — the declared file, then the declared accounts' fields under
|
|
// the keys each was adopted or created under (adoption.go) — with the profile's; apply places it when the
|
|
// profile has none or the module's, records what was adopted or created, and writes the keys of the accounts
|
|
// it created into the profile's account lists when Betterbird is not running. A refused account gets nothing;
|
|
// the others go ahead (novox/hq ADR 0317 §7).
|
|
func (m *Machine) userJS(apply bool) (UserJS, error) {
|
|
a := UserJS{Declared: "~/" + declared}
|
|
declaredText, err := readBounded(m.home(declared))
|
|
a.Present = err == nil
|
|
p, perr := m.profile()
|
|
if perr != nil {
|
|
return a, perr
|
|
}
|
|
target := filepath.Join(p.path, "user.js")
|
|
a.Profile = m.tilde(strings.TrimPrefix(target, m.Root))
|
|
accounts, _, aerr := m.readAccounts()
|
|
if aerr != nil {
|
|
a.AccountsProblem = aerr.Error()
|
|
accounts = nil
|
|
} else {
|
|
a.Accounts = len(accounts)
|
|
a.Each = resolveAccounts(accounts, mergedPrefs(p.path), m.readRecord(p.Dir))
|
|
}
|
|
var want []byte
|
|
if a.Present {
|
|
if verr := validUserJS(string(declaredText)); verr != nil {
|
|
a.Valid = verr.Error()
|
|
}
|
|
want = []byte(composeUserJS(string(declaredText), accounts, a.Each))
|
|
a.Content = m.answeredUserJS(string(want))
|
|
for _, line := range strings.Split(string(want), "\n") {
|
|
for _, st := range splitStatements(strings.TrimSpace(line)) {
|
|
if statement.MatchString(st) {
|
|
a.Statements++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
have, herr := readBounded(target)
|
|
switch {
|
|
case herr != nil:
|
|
a.Placed = "none: the profile has no user.js"
|
|
case a.Present && bytes.Equal(have, want):
|
|
a.Placed = "the module's, as declared"
|
|
case bytes.HasPrefix(have, []byte(mark)):
|
|
a.Placed = "the module's, from an earlier declaration"
|
|
default:
|
|
a.Placed = "not the module's: a user.js of the operator's, which the module never overwrites"
|
|
}
|
|
if apply {
|
|
switch {
|
|
case !a.Present:
|
|
return a, fmt.Errorf("the module's user.js is not on this machine (~/%s): the module is not pushed here yet", declared)
|
|
case a.Valid != "":
|
|
return a, fmt.Errorf("the module's user.js is refused: %s; change the setting prefs", a.Valid)
|
|
case a.AccountsProblem != "":
|
|
return a, fmt.Errorf("nothing was placed: %s", a.AccountsProblem)
|
|
}
|
|
saved, _ := readBounded(filepath.Join(p.path, "prefs.js"))
|
|
savedPrefs := readPrefs(saved)
|
|
for i, r := range a.Each {
|
|
if r.How != refused {
|
|
a.Each[i].Changed = changedPrefs(r, accounts[r.name], savedPrefs)
|
|
}
|
|
}
|
|
switch {
|
|
case herr == nil && !bytes.HasPrefix(have, []byte(mark)):
|
|
return a, fmt.Errorf("%s is not the module's: move it aside (its preferences belong in the setting prefs), then apply again", a.Profile)
|
|
}
|
|
if herr != nil || !bytes.Equal(have, want) {
|
|
tmp := target + ".mesh-new"
|
|
if err := os.WriteFile(tmp, want, 0o644); err != nil {
|
|
return a, err
|
|
}
|
|
if err := os.Rename(tmp, target); err != nil {
|
|
os.Remove(tmp)
|
|
return a, err
|
|
}
|
|
}
|
|
a.Applied, a.Placed = true, "the module's, as declared"
|
|
if err := m.writeRecord(p.Dir, a.Each); err != nil {
|
|
return a, fmt.Errorf("the user.js is placed, and what was adopted or created could not be recorded: %v", err)
|
|
}
|
|
if lists, err := m.writeAccountLists(p.path, a.Each); err != nil {
|
|
a.Lists = "not written: " + err.Error()
|
|
} else {
|
|
a.Lists = lists
|
|
}
|
|
}
|
|
if ps := m.mainProcs(); len(ps) > 0 {
|
|
if st, err := os.Stat(target); err == nil {
|
|
if t, err := time.Parse(time.RFC3339, ps[0].Since); err == nil && st.ModTime().After(t) {
|
|
a.Effective = "not yet: Betterbird reads user.js when it starts, and it started before the file was placed (betterbird_restart)"
|
|
} else if err == nil {
|
|
a.Effective = "yes: the running Betterbird started after the file was placed"
|
|
}
|
|
}
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// answeredUserJS is the user.js as a tool answers it (novox/hq ADR 0316 §16, ADR 0317 §9): an account's
|
|
// preferences with their values withheld, every other value masked as betterbird_prefs masks it.
|
|
func (m *Machine) answeredUserJS(text string) string {
|
|
lines := strings.Split(text, "\n")
|
|
for i, line := range lines {
|
|
for _, st := range splitStatements(strings.TrimSpace(line)) {
|
|
mm := prefLine.FindStringSubmatch(st)
|
|
if mm == nil {
|
|
continue
|
|
}
|
|
value := m.maskValue(mm[2])
|
|
if why := withheld(mm[1]); why != "" {
|
|
value = `"<withheld>"`
|
|
}
|
|
lines[i] = fmt.Sprintf("user_pref(%s, %s);", quoted(mm[1]), value)
|
|
}
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// Prepare is what the module's start runs before Betterbird (`betterbird-tools prepare`): with no Betterbird
|
|
// of this account running, the user.js placed and the declared accounts written into the account lists. With
|
|
// one running it does nothing, since the start then only hands over to it. Its error is said by the start,
|
|
// which starts Betterbird either way.
|
|
func (m *Machine) Prepare() error {
|
|
if len(m.mainProcs()) > 0 {
|
|
return nil
|
|
}
|
|
u, err := m.userJS(true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var problems []string
|
|
if strings.HasPrefix(u.Lists, "not written") {
|
|
problems = append(problems, "the account lists were "+u.Lists)
|
|
}
|
|
problems = append(problems, u.refusedAccounts()...)
|
|
if len(problems) > 0 {
|
|
return fmt.Errorf("%s", strings.Join(problems, "; "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Status is what betterbird_status answers.
|
|
type Status struct {
|
|
Running []Proc `json:"running"`
|
|
Children int `json:"children"`
|
|
// Installation is what is installed and from where, the policy and Owl (novox/hq ADR 0316 §16).
|
|
Installation Installation `json:"installation"`
|
|
// Installed is the community package's version, while it is still there.
|
|
Installed string `json:"community_package,omitempty"`
|
|
Foreign bool `json:"from_the_aur,omitempty"`
|
|
Profile *Profile `json:"profile,omitempty"`
|
|
Problem string `json:"profile_problem,omitempty"`
|
|
Output []Stream `json:"output,omitempty"`
|
|
UserJS *UserJS `json:"user_js,omitempty"`
|
|
// Accounts are counts: the declared, those in the profile's list, the others, those without a sign-in.
|
|
Accounts *AccountCount `json:"accounts,omitempty"`
|
|
AddOns []AddOn `json:"add_ons,omitempty"`
|
|
// Display are the preferences that decide how its windows and notifications look.
|
|
Display []Pref `json:"display_prefs,omitempty"`
|
|
}
|
|
|
|
// AddOn is one add-on the operator installed: its id, and whether it is on.
|
|
type AddOn struct {
|
|
ID string `json:"id"`
|
|
Version string `json:"version,omitempty"`
|
|
Active bool `json:"active"`
|
|
}
|
|
|
|
// displayPrefs are the preferences betterbird_status answers: the reminder dialog, the theme, the
|
|
// renderer, and whether notifications go to the desktop's notifier.
|
|
var displayPrefs = []string{"calendar.alarms.playsound", "calendar.alarms.show", "calendar.alarms.showmissed",
|
|
"calendar.alarms.indicator.show", "alerts.useSystemBackend", "mail.biff.use_system_alert",
|
|
"extensions.activeThemeID", "browser.display.use_system_colors", "ui.systemUsesDarkTheme",
|
|
"gfx.webrender.software", "gfx.webrender.all", "layers.acceleration.disabled", "widget.gtk.overlay-scrollbars.enabled",
|
|
"mozilla.widget.use-argb-visuals"}
|
|
|
|
func (m *Machine) foreign(pkg string) bool {
|
|
o := m.cmd(0, nil, "pacman", "-Qmq", pkg)
|
|
return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == pkg
|
|
}
|
|
|
|
// Status answers whether Betterbird runs, what is installed, its profile and the module's user.js.
|
|
func (m *Machine) Status() (Status, error) {
|
|
s := Status{Running: m.mainProcs()}
|
|
if s.Running == nil {
|
|
s.Running = []Proc{}
|
|
}
|
|
s.Children = len(m.procs(mainComm)) - len(s.Running)
|
|
if v, err := m.installed(packageFor); err == nil && v != "" {
|
|
s.Installed, s.Foreign = v, m.foreign(packageFor)
|
|
}
|
|
if len(s.Running) > 0 {
|
|
s.Output = m.streams(s.Running[0].PID)
|
|
}
|
|
p, err := m.profile()
|
|
if err != nil {
|
|
s.Installation = m.installation("")
|
|
s.Problem = err.Error()
|
|
return s, nil
|
|
}
|
|
s.Profile = &p
|
|
s.Installation = m.installation(p.path)
|
|
if u, err := m.userJS(false); err == nil {
|
|
u.Content = ""
|
|
s.UserJS = &u
|
|
}
|
|
c := m.countAccounts(p.path, p.Dir)
|
|
s.Accounts = &c
|
|
s.AddOns = addOns(p.path)
|
|
if got, err := m.Prefs(displayPrefs); err == nil {
|
|
s.Display = got["prefs"].([]Pref)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// emailAddress and credentialWord are masked in what Betterbird printed.
|
|
var (
|
|
emailAddress = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
|
|
credentialWord = regexp.MustCompile(`(?i)((?:password|passwd|token|secret|authorization|cookie)\S*\s*[=:]\s*)\S+`)
|
|
)
|
|
|
|
// userAtHost and hostName are what a preference's value may name a server by.
|
|
var (
|
|
userAtHost = regexp.MustCompile(`[A-Za-z0-9._%+\-]+%40[A-Za-z0-9.\-]+@[A-Za-z0-9.\-]+`)
|
|
// A host is a name of three labels or more, or of two whose last is a common top-level domain: a
|
|
// file's name (a.pdf) is not one.
|
|
hostName = regexp.MustCompile(`(?i)\b(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.){2,}[a-z]{2,}|[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.(?:com|org|net|be|nl|de|fr|eu|io|uk|us|info|biz|dev|app|cloud|local|lan|internal|home|corp))\b`)
|
|
bearerOrBasic = regexp.MustCompile(`(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=\-]+`)
|
|
)
|
|
|
|
func mask(s string) string {
|
|
s = userAtHost.ReplaceAllString(s, "<hidden>")
|
|
s = emailAddress.ReplaceAllString(s, "<hidden>")
|
|
s = bearerOrBasic.ReplaceAllString(s, "$1 <hidden>")
|
|
return credentialWord.ReplaceAllString(s, "${1}<hidden>")
|
|
}
|
|
|
|
// maskValue is a preference's value as answered: the home as ~, and no address, user or host.
|
|
func (m *Machine) maskValue(v string) string {
|
|
if m.Home != "" && m.Home != "/" {
|
|
v = strings.ReplaceAll(v, strings.TrimSuffix(m.Home, "/")+"/", "~/")
|
|
}
|
|
v = mask(v)
|
|
return hostName.ReplaceAllString(v, "<host>")
|
|
}
|
|
|
|
// mostAnswer bounds a log answer.
|
|
const mostAnswer = 128 << 10
|
|
|
|
// Log answers the last n lines Betterbird printed, from the journal of the unit the module starts it
|
|
// as; problems keeps the lines that say error, warning, crash or assertion. Addresses and credentials
|
|
// are masked.
|
|
func (m *Machine) Log(n int, problems bool, unit string) (map[string]any, error) {
|
|
if unit == "" {
|
|
unit = restartAs
|
|
}
|
|
if unit != restartAs && unit != testUnit {
|
|
return nil, fmt.Errorf("unit is %s or %s", restartAs, testUnit)
|
|
}
|
|
o := m.cmd(0, m.bus(), "journalctl", "--user", "--unit", unit+".service", "--no-pager", "--output", "short-iso", "-n", strconv.Itoa(n))
|
|
if err := failed(o, "journalctl"); err != nil {
|
|
return nil, err
|
|
}
|
|
var lines []string
|
|
size := 0
|
|
keep := regexp.MustCompile(`(?i)(error|warn|crash|assert|fail|exception)`)
|
|
for _, l := range strings.Split(strings.TrimRight(o.Stdout, "\n"), "\n") {
|
|
if l == "" || (problems && !keep.MatchString(l)) {
|
|
continue
|
|
}
|
|
l = mask(l)
|
|
if size+len(l) > mostAnswer {
|
|
break
|
|
}
|
|
size += len(l) + 1
|
|
lines = append(lines, l)
|
|
}
|
|
if lines == nil {
|
|
lines = []string{}
|
|
}
|
|
a := map[string]any{"unit": unit + ".service", "lines": lines}
|
|
if ps := m.mainProcs(); len(ps) > 0 && !strings.HasPrefix(ps[0].StartedIn, restartAs) && unit == restartAs {
|
|
a["note"] = fmt.Sprintf("the running Betterbird (pid %d) was not started by the module: it runs in %s, "+
|
|
"and its output goes where status says (output), not to this journal; betterbird_restart starts it as %s",
|
|
ps[0].PID, ps[0].StartedIn, restartAs)
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// RestartAnswer is what betterbird_restart answers.
|
|
type RestartAnswer struct {
|
|
// Composers are the message windows that were open: what was unsent and is gone.
|
|
Composers int `json:"composers_discarded,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
Ended []int `json:"ended"`
|
|
Killed []int `json:"killed,omitempty"`
|
|
UserJS string `json:"user_js"`
|
|
Running []Proc `json:"running"`
|
|
Session Session `json:"session"`
|
|
Unit string `json:"unit"`
|
|
}
|
|
|
|
// composerClass is the X instance of a message being written, whatever the class beside it.
|
|
var composerClass = regexp.MustCompile(`\("Msgcompose" "[^"]*"\)`)
|
|
|
|
// composers counts the open message windows, from the X window tree.
|
|
func (m *Machine) composers(s Session) (int, error) {
|
|
o := m.cmd(3*time.Second, s.Env(), "xwininfo", "-root", "-tree")
|
|
if err := failed(o, "xwininfo", "-root", "-tree"); err != nil {
|
|
return 0, err
|
|
}
|
|
return len(composerClass.FindAllString(o.Stdout, -1)), nil
|
|
}
|
|
|
|
// Restart ends Betterbird (asked to quit, forced after 10 s), places the module's user.js when it can,
|
|
// and starts Betterbird again in the operator's session under the account's service manager, its output
|
|
// in the journal. While a message is being written it refuses, unless discard says the unsent may go.
|
|
// It answers within the runtime's 30 s: a start that has not shown after 4 s is said, and
|
|
// betterbird_status sees it come.
|
|
func (m *Machine) Restart(discard bool) (RestartAnswer, error) {
|
|
s, err := m.session()
|
|
if err != nil {
|
|
return RestartAnswer{}, err
|
|
}
|
|
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
|
|
n, err := m.composers(s)
|
|
switch {
|
|
case err != nil && !discard:
|
|
return a, fmt.Errorf("whether a message is being written cannot be told (%v): restart with discard_unsent only if none is", err)
|
|
case n > 0 && !discard:
|
|
return a, fmt.Errorf("%d composer window(s) are open: an unsent message would be lost. Send or save it, or ask the operator, then call again with discard_unsent", n)
|
|
}
|
|
a.Composers = n
|
|
a.Ended, a.Killed = m.stopProcs(8*time.Second, m.mainProcs())
|
|
if u, err := m.userJS(true); err != nil {
|
|
a.UserJS = "not placed: " + err.Error()
|
|
} else {
|
|
a.UserJS = fmt.Sprintf("placed: %d preference(s) from ~/%s, %d declared account(s); account lists %s",
|
|
u.Statements, declared, u.Accounts, u.Lists)
|
|
}
|
|
// As detach does, with shorter bounds: the worst case of the whole call (3 s for the window tree,
|
|
// 8 s to quit, 3 + 5 s to start, 4 s to see it) stays under the runtime's 30 s.
|
|
_ = m.cmd(3*time.Second, s.Env(), "systemctl", "--user", "stop", restartAs+".service")
|
|
call := []string{"--user", "--collect", "--quiet", "--unit=" + restartAs}
|
|
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
|
|
if kv[1] != "" {
|
|
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
|
}
|
|
}
|
|
call = append(call, "--", startBin)
|
|
if err := failed(m.cmd(5*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...); err != nil {
|
|
return a, err
|
|
}
|
|
for waited := time.Duration(0); waited < 4*time.Second && len(m.mainProcs()) == 0; waited += 250 * time.Millisecond {
|
|
m.Sleep(250 * time.Millisecond)
|
|
}
|
|
a.Running = m.mainProcs()
|
|
if a.Running == nil {
|
|
a.Running = []Proc{}
|
|
}
|
|
if len(a.Running) == 0 {
|
|
a.Note = "started as " + a.Unit + "; no main process yet after 4 s: betterbird_status shows it once it runs, betterbird_log says why if it does not"
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// CheckAnswer is what betterbird_check answers.
|
|
type CheckAnswer struct {
|
|
OK bool `json:"ok"`
|
|
Findings []Finding `json:"findings"`
|
|
}
|
|
|
|
// Check verifies what the module promises: Betterbird installed, one running in the session, its
|
|
// profile found, the module's user.js declared, valid, placed and in effect, and its output read.
|
|
func (m *Machine) Check() (CheckAnswer, error) {
|
|
a := CheckAnswer{Findings: []Finding{}}
|
|
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
|
|
if _, err := m.installed(packageFor); err != nil {
|
|
return a, err
|
|
}
|
|
p, perr := m.profile()
|
|
profilePath := ""
|
|
if perr == nil {
|
|
profilePath = p.path
|
|
}
|
|
in := m.installation(profilePath)
|
|
switch {
|
|
case in.From == "not installed":
|
|
add("Betterbird is not installed in "+installDir, "the module's push places the mesh's release; it waits for the operator's push (upgrade policy record)")
|
|
case in.From != "the mesh":
|
|
add("Betterbird is "+in.From, "quit Betterbird, then push the module to this node: the push removes the community package and places the mesh's release")
|
|
}
|
|
if in.Behind {
|
|
add("Betterbird "+in.Version+" (build "+in.Build+") is behind the newest published build "+in.Newest,
|
|
"move the module's pin in a reviewed change (novox/hq ADR 0316 §5)")
|
|
}
|
|
if in.From == "the mesh" && !strings.HasPrefix(in.Policy, "placed:") {
|
|
add("the enterprise policy is "+in.Policy, "push the module to this node")
|
|
}
|
|
switch {
|
|
case in.From != "the mesh":
|
|
case in.Owl == nil || in.Owl.Pinned == "":
|
|
add("Owl's xpi is not placed ("+owlFile+")", "push the module to this node")
|
|
case in.Owl.InProfile == "":
|
|
add("Owl is not installed in the profile yet", "Betterbird installs it from the policy at its next start")
|
|
case !in.Owl.AtThePin && !newerVersion(in.Owl.InProfile, in.Owl.Pinned):
|
|
add("Owl "+in.Owl.InProfile+" runs in the profile, older than the mesh's pin "+in.Owl.Pinned,
|
|
"Owl is installed by policy and cannot be removed in Betterbird: move the module's Owl pin in a reviewed change (novox/hq ADR 0316 §6)")
|
|
case !in.Owl.Active:
|
|
add("Owl is installed and not active", "turn it on in Betterbird's add-ons")
|
|
}
|
|
if perr == nil {
|
|
c := m.countAccounts(p.path, p.Dir)
|
|
for _, r := range c.Each {
|
|
if r.How == refused {
|
|
add("refused: "+r.Why, "as it says; the other declared accounts go ahead")
|
|
}
|
|
}
|
|
switch {
|
|
case c.InList < c.Declared:
|
|
add(fmt.Sprintf("%d of %d declared account(s) are not in the profile's account list yet", c.Declared-c.InList, c.Declared),
|
|
"quit Betterbird and start it from the launcher, or betterbird_restart")
|
|
}
|
|
if c.WithoutSignIn > 0 {
|
|
add(fmt.Sprintf("%d declared account(s) have not signed in yet", c.WithoutSignIn),
|
|
"a person signs in once, in Betterbird's own window, when it asks (novox/hq ADR 0316 §14)")
|
|
}
|
|
}
|
|
ps := m.mainProcs()
|
|
switch {
|
|
case len(ps) == 0:
|
|
add("Betterbird is not running", "start it from the launcher, or betterbird_restart")
|
|
case len(ps) > 1:
|
|
add(fmt.Sprintf("%d Betterbird main processes run", len(ps)), "one profile is held by one process: betterbird_restart")
|
|
}
|
|
u, err := m.userJS(false)
|
|
switch {
|
|
case err != nil:
|
|
add("no profile: "+err.Error(), "start Betterbird once")
|
|
case !u.Present:
|
|
add("the module's user.js is not on this machine (~/"+declared+")", "the module is not pushed here: the controller's push")
|
|
case u.Valid != "":
|
|
add("the module's user.js is refused: "+u.Valid, "change the module's setting prefs")
|
|
case u.AccountsProblem != "":
|
|
add("the module's user.js is not placed: "+u.AccountsProblem, "change the module's setting accounts at the controller's terminal, or propose it (betterbird_accounts)")
|
|
case !strings.HasPrefix(u.Placed, "the module's, as declared"):
|
|
add("the profile's user.js is "+u.Placed, "betterbird_user_js with apply, then betterbird_restart")
|
|
case strings.HasPrefix(u.Effective, "not yet"):
|
|
add("the module's user.js is placed but not in effect: "+u.Effective, "betterbird_restart")
|
|
}
|
|
if len(ps) > 0 {
|
|
for _, s := range m.streams(ps[0].PID) {
|
|
if s.Dead {
|
|
add(fmt.Sprintf("Betterbird's fd %d is a pipe nobody reads (a session started before its output went to the journal): what it prints is lost", s.FD),
|
|
"betterbird_restart, which starts it with its output in the journal")
|
|
}
|
|
}
|
|
}
|
|
a.OK = len(a.Findings) == 0
|
|
return a, nil
|
|
}
|