This commit is contained in:
Executable
+225
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pause a Core's email in Online Accounts when its password stops working.
|
||||
|
||||
When your Core password changes (e.g. in Superpanel), email in Online Accounts
|
||||
still has the old one. Online Accounts signs in with it each time the network
|
||||
changes, and Evolution each time it fetches mail, and after a few wrong
|
||||
passwords the mail server's fail2ban blocks this computer.
|
||||
|
||||
Online Accounts doesn't say when a mail server refuses a password (3.58 reports
|
||||
it as a general failure). It does say when Nextcloud refuses one, and Nextcloud
|
||||
turns off its app passwords when your Core password changes. So when the
|
||||
Core's Nextcloud in Online Accounts needs attention, this signs in to Nextcloud,
|
||||
once, with the password email has (your Core password). If Nextcloud says it's
|
||||
wrong, the mail server will too, so this pauses email
|
||||
(configure.d/lib/goa-account pause): nothing signs in with the old password
|
||||
again. It shows a notification; clicking it runs configure core, where "Your
|
||||
Core password changed" saves the new password and ends the pause. It never
|
||||
signs in to the mail server itself.
|
||||
|
||||
Run by coredesktop-email-watch.service (see system/email-watch.sh).
|
||||
|
||||
email-watch --check-password <email> checks the password email has with
|
||||
Nextcloud, as above, for configure core's tests: it exits 0 if it works, 2 if
|
||||
Nextcloud says it's wrong, 1 if it couldn't tell.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import glob
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gio", "2.0")
|
||||
from gi.repository import Gio, GLib # noqa: E402
|
||||
|
||||
COREDESKTOP = os.path.expanduser("~/.local/share/coredesktop")
|
||||
RUN_IN_TERMINAL = os.path.join(COREDESKTOP, "system/run-in-terminal")
|
||||
CORES = os.path.expanduser("~/.config/coredesktop/cores/*.conf")
|
||||
|
||||
|
||||
def load_goa_account():
|
||||
# No __pycache__ in the CoreDesktop checkout, which update pulls into
|
||||
sys.dont_write_bytecode = True
|
||||
path = os.path.join(COREDESKTOP, "configure.d/lib/goa-account")
|
||||
loader = importlib.machinery.SourceFileLoader("goa_account", path)
|
||||
module = importlib.util.module_from_spec(importlib.util.spec_from_loader("goa_account", loader))
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
goa = load_goa_account()
|
||||
|
||||
|
||||
# Each Core's email and domain, from configure core's cores/<domain>.conf
|
||||
def cores():
|
||||
found = {}
|
||||
for conf in glob.glob(CORES):
|
||||
result = subprocess.run(
|
||||
["bash", "-c", 'source "$1" && printf "%s\\n%s\\n" "$CORE_EMAIL" "$CORE_DOMAIN"', "-", conf],
|
||||
capture_output=True, text=True)
|
||||
lines = result.stdout.split("\n")
|
||||
if result.returncode == 0 and len(lines) >= 2 and lines[0] and lines[1]:
|
||||
found[lines[0]] = lines[1]
|
||||
return found
|
||||
|
||||
|
||||
def nextcloud_url(domain):
|
||||
return f"https://nextcloud.{domain}"
|
||||
|
||||
|
||||
# Sign in to the Core's Nextcloud with the password email has, the way
|
||||
# configure core checks a password (configure.d/lib/nextcloud.sh). True if it
|
||||
# works, False if Nextcloud says it's wrong, None if it couldn't tell.
|
||||
def nextcloud_takes(email, domain, password):
|
||||
user = email.rsplit("@", 1)[0]
|
||||
auth = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
request = urllib.request.Request(
|
||||
f"{nextcloud_url(domain)}/ocs/v2.php/cloud/user?format=json",
|
||||
headers={"Authorization": f"Basic {auth}", "OCS-APIRequest": "true"})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15):
|
||||
return True
|
||||
except urllib.error.HTTPError as error:
|
||||
return False if error.code == 401 else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def email_password(bus, email):
|
||||
for _, account in goa.find_accounts(bus, "imap_smtp", email):
|
||||
return goa.lookup_credentials("imap_smtp", account["Id"]).get("imap-password")
|
||||
return None
|
||||
|
||||
|
||||
def check_password(bus, email):
|
||||
domain = cores().get(email)
|
||||
if domain is None:
|
||||
return None
|
||||
password = email_password(bus, email)
|
||||
if not password:
|
||||
return None
|
||||
return nextcloud_takes(email, domain, password)
|
||||
|
||||
|
||||
# Waits for a click in its own thread, so the watching goes on meanwhile
|
||||
def notify(email, domain, paused_now):
|
||||
def run():
|
||||
if paused_now:
|
||||
body = (f"Your Core password seems to have changed: {email} has the old one. "
|
||||
"So the mail server doesn't block this computer for trying it, it's "
|
||||
"paused. Click to enter your new password.")
|
||||
else:
|
||||
body = (f"{email} is paused because it has an old password. "
|
||||
"Click to enter your new Core password.")
|
||||
result = subprocess.run(
|
||||
["notify-send", "--wait", "--app-name=CoreDesktop", "--icon=mail-unread",
|
||||
"--action=default=Fix", "--action=fix=Enter new password",
|
||||
f"Email for {domain} is paused", body],
|
||||
capture_output=True, text=True)
|
||||
if result.stdout.strip() in ("default", "fix"):
|
||||
subprocess.run([RUN_IN_TERMINAL, "configure core"])
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
|
||||
|
||||
# Emails already dealt with since they started failing, so each is checked and
|
||||
# notified about once until it works again
|
||||
handled = set()
|
||||
|
||||
|
||||
def check(bus):
|
||||
try:
|
||||
accounts = {(account.get("ProviderType"), account.get("PresentationIdentity")): account
|
||||
for _, account in goa.accounts(bus)}
|
||||
except GLib.Error:
|
||||
return
|
||||
for email, domain in cores().items():
|
||||
mail = accounts.get(("imap_smtp", email))
|
||||
if mail is None:
|
||||
continue
|
||||
nextcloud = accounts.get(("owncloud", f"{email.rsplit('@', 1)[0]}@nextcloud.{domain}"))
|
||||
mail_failing = bool(mail.get("AttentionNeeded"))
|
||||
nextcloud_failing = bool(nextcloud and nextcloud.get("AttentionNeeded"))
|
||||
if not mail_failing and not nextcloud_failing:
|
||||
handled.discard(email)
|
||||
continue
|
||||
if email in handled:
|
||||
continue
|
||||
|
||||
try:
|
||||
if goa.paused(bus, "imap_smtp", email):
|
||||
handled.add(email)
|
||||
notify(email, domain, False)
|
||||
continue
|
||||
# Nextcloud may only have had an app password revoked, and email may
|
||||
# still be marked from a pause whose new password hasn't been
|
||||
# checked yet, so check the password email has before pausing it
|
||||
password = email_password(bus, email)
|
||||
if not password:
|
||||
continue # e.g. the keyring is locked; try again later
|
||||
works = nextcloud_takes(email, domain, password)
|
||||
if works is None:
|
||||
continue # try again when the network changes
|
||||
if works:
|
||||
handled.add(email)
|
||||
continue
|
||||
goa.pause(bus, "imap_smtp", email)
|
||||
except (GLib.Error, LookupError):
|
||||
continue
|
||||
handled.add(email)
|
||||
notify(email, domain, True)
|
||||
|
||||
|
||||
def watch():
|
||||
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
pending = []
|
||||
|
||||
# Signals come in bursts; check once they've settled
|
||||
def queue_check(*_):
|
||||
if not pending:
|
||||
pending.append(GLib.timeout_add_seconds(2, run_check))
|
||||
|
||||
def run_check():
|
||||
pending.clear()
|
||||
check(bus)
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
account = "org.gnome.OnlineAccounts.Account"
|
||||
bus.signal_subscribe(goa.BUS_NAME, "org.freedesktop.DBus.Properties", "PropertiesChanged",
|
||||
None, account, Gio.DBusSignalFlags.NONE, queue_check)
|
||||
bus.signal_subscribe(goa.BUS_NAME, "org.freedesktop.DBus.ObjectManager", "InterfacesAdded",
|
||||
None, None, Gio.DBusSignalFlags.NONE, queue_check)
|
||||
# Online Accounts checks every account when it starts, e.g. at login, and
|
||||
# when the network changes
|
||||
bus.signal_subscribe("org.freedesktop.DBus", "org.freedesktop.DBus", "NameOwnerChanged",
|
||||
"/org/freedesktop/DBus", goa.BUS_NAME, Gio.DBusSignalFlags.NONE, queue_check)
|
||||
Gio.NetworkMonitor.get_default().connect("network-changed", queue_check)
|
||||
queue_check()
|
||||
GLib.MainLoop().run()
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) == 3 and sys.argv[1] == "--check-password":
|
||||
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
try:
|
||||
works = check_password(bus, sys.argv[2])
|
||||
except (GLib.Error, LookupError):
|
||||
works = None
|
||||
return {True: 0, False: 2, None: 1}[works]
|
||||
if len(sys.argv) == 1:
|
||||
watch()
|
||||
return 0
|
||||
print(__doc__.strip().split("\n\n")[-1], file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Purpose : Install and start the service that pauses a Core's email when the
|
||||
# mail server stops taking its password (system/email-watch).
|
||||
# Run by install.sh and on every update (updates.d/17-email-watch.sh),
|
||||
# so changes to it reach existing machines.
|
||||
|
||||
set -e
|
||||
|
||||
COREDESKTOP="$HOME/.local/share/coredesktop"
|
||||
UNITS="$HOME/.config/systemd/user"
|
||||
|
||||
mkdir -p "$UNITS"
|
||||
cp "$COREDESKTOP/system/systemd/coredesktop-email-watch.service" "$UNITS/"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable coredesktop-email-watch.service
|
||||
# Restart, so it runs the version update just pulled. It doesn't start without
|
||||
# a Core set up.
|
||||
systemctl --user restart coredesktop-email-watch.service
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Purpose : Run a command in a new terminal window, for notifications that
|
||||
# start something when clicked (system/update-check, system/email-watch).
|
||||
#
|
||||
# run-in-terminal <command>, e.g. run-in-terminal update
|
||||
#
|
||||
# bash -i reads ~/.bashrc, as if the user had typed the command themselves, and
|
||||
# the window stays open to show how it went.
|
||||
|
||||
run=(bash -ic "$1; echo; read -rp \"Press Enter to close this window.\"")
|
||||
if command -v xdg-terminal-exec &> /dev/null; then
|
||||
terminal=(xdg-terminal-exec)
|
||||
elif command -v kgx &> /dev/null; then
|
||||
terminal=(kgx --)
|
||||
elif command -v ghostty &> /dev/null; then
|
||||
terminal=(ghostty -e)
|
||||
elif command -v kitty &> /dev/null; then
|
||||
terminal=(kitty)
|
||||
else
|
||||
notify-send --app-name=CoreDesktop "No terminal found" "Open a terminal and run $1."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the terminal as its own unit: when the service that called this exits,
|
||||
# systemd stops everything still running in it, which would close the terminal
|
||||
exec systemd-run --user --quiet --collect -- "${terminal[@]}" "${run[@]}"
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Pause a Core's email when the mail server stops taking its password
|
||||
PartOf=graphical-session.target
|
||||
After=graphical-session.target
|
||||
# Only for those who've set up a Core (configure core starts it then)
|
||||
ConditionPathExistsGlob=%h/.config/coredesktop/cores/*.conf
|
||||
|
||||
[Service]
|
||||
ExecStart=%h/.local/share/coredesktop/system/email-watch
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
+1
-19
@@ -30,23 +30,5 @@ echo "$latest" > "$NOTIFIED"
|
||||
|
||||
[ "$action" = "default" ] || [ "$action" = "update" ] || exit 0
|
||||
|
||||
# Open the first terminal found. bash -i reads ~/.bashrc, as if the user had
|
||||
# typed update themselves, and the window stays open to show how it went.
|
||||
run_update=(bash -ic 'update; echo; read -rp "Press Enter to close this window."')
|
||||
if command -v xdg-terminal-exec &> /dev/null; then
|
||||
terminal=(xdg-terminal-exec)
|
||||
elif command -v kgx &> /dev/null; then
|
||||
terminal=(kgx --)
|
||||
elif command -v ghostty &> /dev/null; then
|
||||
terminal=(ghostty -e)
|
||||
elif command -v kitty &> /dev/null; then
|
||||
terminal=(kitty)
|
||||
else
|
||||
notify-send --app-name=CoreDesktop "No terminal found" "Open a terminal and run update."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the terminal as its own unit: when this service exits, systemd stops
|
||||
# everything still running in it, which would close the terminal mid-update
|
||||
exec systemd-run --user --quiet --collect -- "${terminal[@]}" "${run_update[@]}"
|
||||
exec "$HOME/.local/share/coredesktop/system/run-in-terminal" update
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user