1
0
Fork 0
mirror of https://git.sr.ht/~rjarry/aerc synced 2025-10-28 22:44:07 +01:00
aerc/config/accounts.go
Robin Jarry 51fd25c0f1 reload: fix crash when reloading via IPC
When reloading the configuration with :reload, global variables in the
config package are reset to their startup values and then, the config is
parsed from disk. While the parsing is done, these variables are
temporarily in an inconsistent and possibly invalid state.

When commands are executed interactively from aerc, they are handled by
the main goroutine which also deals with UI rendering. No UI render will
be done while :reload is in progress.

However, the IPC socket handler runs in an independent goroutine. This
has the unfortunate side effect to let the UI goroutine to run while
config parsing is in progress and causes crashes:

[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x6bb142]

goroutine 1 [running]:
git.sr.ht/~rjarry/aerc/lib/log.PanicHandler()
	lib/log/panic-logger.go:51 +0x6cf
panic({0xc1d960?, 0x134a6e0?})
	/usr/lib/go/src/runtime/panic.go:783 +0x132
git.sr.ht/~rjarry/aerc/config.(*StyleConf).getStyle(0xc00038b908?, 0x4206b7?)
	config/style.go:386 +0x42
git.sr.ht/~rjarry/aerc/config.StyleSet.Get({0x0, 0x0, 0x0, {0x0, 0x0, 0x0}}, 0x421a65?, 0x0)
	config/style.go:408 +0x8b
git.sr.ht/~rjarry/aerc/config.(*UIConfig).GetStyle(...)
	config/ui.go:379
git.sr.ht/~rjarry/aerc/lib/ui.(*TabStrip).Draw(0xc000314700, 0xc000192230)
	lib/ui/tab.go:378 +0x15b
git.sr.ht/~rjarry/aerc/lib/ui.(*Grid).Draw(0xc000186fc0, 0xc0002c25f0)
	lib/ui/grid.go:126 +0x28e
git.sr.ht/~rjarry/aerc/app.(*Aerc).Draw(0x14b9f00, 0xc0002c25f0)
	app/aerc.go:192 +0x1fe
git.sr.ht/~rjarry/aerc/lib/ui.Render()
	lib/ui/ui.go:155 +0x16b
main.main()
	main.go:310 +0x997

Make the reload operation safe by changing how config objects are
exposed and updated. Change all objects to be atomic pointers. Expose
public functions to access their value atomically. Only update their
value after a complete and successful config parse. This way the UI
thread will always have access to a valid configuration.

NB: The account configuration is not included in this change since it
cannot be reloaded.

Fixes: https://todo.sr.ht/~rjarry/aerc/319
Reported-by: Anachron <gith@cron.world>
Signed-off-by: Robin Jarry <robin@jarry.cc>
2025-09-23 14:02:37 +02:00

378 lines
9.7 KiB
Go

package config
import (
"bytes"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"path"
"reflect"
"regexp"
"sort"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/lib/log"
"github.com/emersion/go-message/mail"
"github.com/go-ini/ini"
)
var (
EnablePinentry func()
DisablePinentry func()
SetPinentryEnv func(*exec.Cmd)
)
type RemoteConfig struct {
Value string
PasswordCmd string
CacheCmd bool
cache string
}
func (c *RemoteConfig) parseValue() (*url.URL, error) {
return url.Parse(c.Value)
}
func (c *RemoteConfig) ConnectionString() (string, error) {
if c.Value == "" || c.PasswordCmd == "" {
return c.Value, nil
}
u, err := c.parseValue()
if err != nil {
return "", err
}
// ignore the command if a password is specified
if _, exists := u.User.Password(); exists {
return c.Value, nil
}
// don't attempt to parse the command if the url is a path (ie /usr/bin/sendmail)
if !u.IsAbs() {
return c.Value, nil
}
pw := c.cache
if pw == "" {
usePinentry := EnablePinentry != nil &&
DisablePinentry != nil &&
SetPinentryEnv != nil
cmd := exec.Command("sh", "-c", c.PasswordCmd)
cmd.Stdin = os.Stdin
buf := new(bytes.Buffer)
cmd.Stderr = buf
if usePinentry {
EnablePinentry()
defer DisablePinentry()
SetPinentryEnv(cmd)
}
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to read password: %v: %w",
buf.String(), err)
}
pw = strings.TrimSpace(string(output))
}
u.User = url.UserPassword(u.User.Username(), pw)
if c.CacheCmd {
c.cache = pw
}
return u.String(), nil
}
type AccountConfig struct {
Name string
Backend string
// backend specific
Params map[string]string
Archive string `ini:"archive" default:"Archive"`
CopyTo []string `ini:"copy-to" delim:","`
CopyToReplied bool `ini:"copy-to-replied" default:"false"`
StripBcc bool `ini:"strip-bcc" default:"true"`
Default string `ini:"default" default:"INBOX"`
Postpone string `ini:"postpone" default:"Drafts"`
From *mail.Address `ini:"from"`
UseEnvelopeFrom bool `ini:"use-envelope-from" default:"false"`
OriginalToHeader string `ini:"original-to-header"`
Aliases []*mail.Address `ini:"aliases"`
Source string `ini:"source" parse:"ParseSource"`
Folders []string `ini:"folders" delim:","`
FoldersExclude []string `ini:"folders-exclude" delim:","`
Headers []string `ini:"headers" delim:","`
HeadersExclude []string `ini:"headers-exclude" delim:","`
Outgoing RemoteConfig `ini:"outgoing" parse:"ParseOutgoing"`
SignatureFile string `ini:"signature-file"`
SignatureCmd string `ini:"signature-cmd"`
EnableFoldersSort bool `ini:"enable-folders-sort" default:"true"`
FoldersSort []string `ini:"folders-sort" delim:","`
AddressBookCmd string `ini:"address-book-cmd"`
SendAsUTC bool `ini:"send-as-utc" default:"false"`
SendWithHostname bool `ini:"send-with-hostname" default:"false"`
LocalizedRe *regexp.Regexp `ini:"subject-re-pattern" default:"(?i)^((AW|RE|SV|VS|ODP|R): ?)+"`
RestrictedDeletes bool `ini:"restrict-delete" default:"false"`
// CheckMail
CheckMail time.Duration `ini:"check-mail"`
CheckMailCmd string `ini:"check-mail-cmd"`
CheckMailTimeout time.Duration `ini:"check-mail-timeout" default:"10s"`
CheckMailInclude []string `ini:"check-mail-include"`
CheckMailExclude []string `ini:"check-mail-exclude"`
// PGP Config
PgpKeyId string `ini:"pgp-key-id"`
PgpAutoSign bool `ini:"pgp-auto-sign"`
PgpAttachKey bool `ini:"pgp-attach-key"`
PgpOpportunisticEncrypt bool `ini:"pgp-opportunistic-encrypt"`
PgpErrorLevel int `ini:"pgp-error-level" parse:"ParsePgpErrorLevel" default:"warn"`
PgpSelfEncrypt bool `ini:"pgp-self-encrypt"`
// AuthRes
TrustedAuthRes []string `ini:"trusted-authres" delim:","`
}
const (
PgpErrorLevelNone = iota
PgpErrorLevelWarn
PgpErrorLevelError
)
var Accounts []*AccountConfig
func parseAccountsFromFile(root string, accts []string, filename string) error {
log.Debugf("Parsing accounts configuration from %s", filename)
file, err := ini.LoadSources(ini.LoadOptions{
KeyValueDelimiters: "=",
}, filename)
if err != nil {
return err
}
starttls_warned := false
var globals *ini.Section
for _, _sec := range file.SectionStrings() {
if _sec == "DEFAULT" {
globals = file.Section(_sec)
continue
}
if len(accts) > 0 && !contains(accts, _sec) {
continue
}
sec := file.Section(_sec)
for key, val := range globals.KeysHash() {
if !sec.HasKey(key) {
_, _ = sec.NewKey(key, val)
}
}
account, err := ParseAccountConfig(_sec, sec)
if err != nil {
log.Errorf("failed to load account [%s]: %s", _sec, err)
Warnings = append(Warnings, Warning{
Title: "accounts.conf: error",
Body: fmt.Sprintf(
"Failed to load account [%s]:\n\n%s",
_sec, err,
),
})
continue
}
if _, ok := account.Params["smtp-starttls"]; ok && !starttls_warned {
Warnings = append(Warnings, Warning{
Title: "accounts.conf: smtp-starttls is deprecated",
Body: `
SMTP connections now use STARTTLS by default and the smtp-starttls setting is ignored.
If you want to disable STARTTLS, append +insecure to the schema.
`,
})
starttls_warned = true
}
log.Debugf("accounts.conf: [%s] from = %s", account.Name, account.From)
Accounts = append(Accounts, account)
}
if len(accts) > 0 {
// Sort accounts struct to match the specified order, if we
// have one
var acctnames []string
for _, acc := range Accounts {
acctnames = append(acctnames, acc.Name)
}
var sortaccts []string
for _, acc := range accts {
if contains(acctnames, acc) {
sortaccts = append(sortaccts, acc)
} else {
log.Errorf("account [%s] not found", acc)
}
}
idx := make(map[string]int)
for i, acct := range sortaccts {
idx[acct] = i
}
sort.Slice(Accounts, func(i, j int) bool {
return idx[Accounts[i].Name] < idx[Accounts[j].Name]
})
}
return nil
}
func parseAccounts(root string, accts []string, filename string) error {
if filename == "" {
filename = path.Join(root, "accounts.conf")
err := checkConfigPerms(filename)
if errors.Is(err, os.ErrNotExist) {
// No config triggers account configuration wizard
return nil
} else if err != nil {
return err
}
}
if err := parseAccountsFromFile(root, accts, filename); err != nil {
return fmt.Errorf("%s: %w", filename, err)
}
return nil
}
func ParseAccountConfig(name string, section *ini.Section) (*AccountConfig, error) {
account := AccountConfig{
Name: name,
Params: make(map[string]string),
}
if err := MapToStruct(section, &account, true); err != nil {
return nil, err
}
for key, val := range section.KeysHash() {
backendSpecific := true
typ := reflect.TypeOf(account)
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
if field.Tag.Get("ini") == key {
backendSpecific = false
break
}
}
if backendSpecific {
account.Params[key] = val
}
}
if account.Source == "" {
return nil, fmt.Errorf("missing 'source' parameter")
}
account.Backend = parseBackend(account.Source)
if account.From == nil {
return nil, fmt.Errorf("missing 'from' parameter")
}
if len(account.Headers) > 0 {
defaults := []string{
"date",
"subject",
"from",
"sender",
"reply-to",
"to",
"cc",
"bcc",
"in-reply-to",
"message-id",
"references",
}
account.Headers = append(account.Headers, defaults...)
}
return &account, nil
}
func parseBackend(source string) string {
u, err := url.Parse(source)
if err != nil {
return ""
}
if strings.HasPrefix(u.Scheme, "imap") {
return "imap"
}
if strings.HasPrefix(u.Scheme, "maildir") {
return "maildir"
}
if strings.HasPrefix(u.Scheme, "jmap") {
return "jmap"
}
return u.Scheme
}
func (a *AccountConfig) ParseSource(sec *ini.Section, key *ini.Key) (string, error) {
var remote RemoteConfig
remote.Value = key.String()
if k, err := sec.GetKey("source-cred-cmd"); err == nil {
remote.PasswordCmd = k.String()
}
return remote.ConnectionString()
}
func (a *AccountConfig) ParseOutgoing(sec *ini.Section, key *ini.Key) (RemoteConfig, error) {
var remote RemoteConfig
remote.Value = key.String()
if k, err := sec.GetKey("outgoing-cred-cmd"); err == nil {
remote.PasswordCmd = k.String()
}
if k, err := sec.GetKey("outgoing-cred-cmd-cache"); err == nil {
cache, err := k.Bool()
if err != nil {
return remote, err
}
remote.CacheCmd = cache
}
_, err := remote.parseValue()
return remote, err
}
func (a *AccountConfig) ParsePgpErrorLevel(sec *ini.Section, key *ini.Key) (int, error) {
var level int
var err error
switch strings.ToLower(key.String()) {
case "none":
level = PgpErrorLevelNone
case "warn":
level = PgpErrorLevelWarn
case "error":
level = PgpErrorLevelError
default:
err = fmt.Errorf("unknown level: %s", key.String())
}
return level, err
}
// checkConfigPerms checks for too open permissions
// printing the fix on stdout and returning an error
func checkConfigPerms(filename string) error {
info, err := os.Stat(filename)
if err != nil {
return err
}
perms := info.Mode().Perm()
if perms&0o44 != 0 && !General().UnsafeAccountsConf {
// group or others have read access
fmt.Fprintf(os.Stderr, "The file %v has too open permissions.\n", filename)
fmt.Fprintln(os.Stderr, "This is a security issue (it contains passwords).")
fmt.Fprintf(os.Stderr, "To fix it, run `chmod 600 %v`\n", filename)
return errors.New("account.conf permissions too lax")
}
return nil
}