508 lines
20 KiB
Python
Executable File
508 lines
20 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
"""Online accounts, through GNOME Online Accounts.
|
|
|
|
GOA is a daemon plus a D-Bus API, and it is already running in this session --
|
|
gvfs activates it, and the four accounts on this machine work without
|
|
gnome-shell involved anywhere. What GNOME owns is only the *panel*; the accounts
|
|
themselves are ordinary D-Bus objects that anything may read and modify.
|
|
|
|
So everything except the initial sign-in is available to us: listing accounts,
|
|
turning individual services on and off, and removing an account. That is the
|
|
whole Online Accounts panel apart from one OAuth handshake.
|
|
|
|
Adding an account is the part that splits. The daemon's AddAccount takes
|
|
credentials as an ARGUMENT -- it stores them, it does not obtain them, and every
|
|
part of that storing happens inside goa-daemon, which does have the backend.
|
|
For password-based providers (Nextcloud, IMAP) that argument is a username and a
|
|
password, which a settings app can reasonably collect, so those are added here.
|
|
For Google it is an OAuth token, and the code that runs that exchange lives in
|
|
libgoa-backend, which Fedora ships without a GIR binding -- reachable from C
|
|
only. Reimplementing it would mean our own Google client credentials. So OAuth
|
|
sign-in is handed to GNOME's panel, and only the sign-in.
|
|
|
|
A password reaches this script on stdin and nowhere else. argv is world-readable
|
|
through /proc, so a password passed as an argument is published to every process
|
|
on the machine.
|
|
|
|
Usage:
|
|
panama-accounts list | snapshot
|
|
panama-accounts set <object-path> <service> <true|false>
|
|
panama-accounts remove <object-path>
|
|
panama-accounts add-nextcloud SERVER USERNAME (password on stdin)
|
|
panama-accounts add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME (password on stdin)
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import urllib.parse
|
|
|
|
|
|
class BoundaryError(RuntimeError):
|
|
"""A user-visible validation or sign-in failure."""
|
|
|
|
|
|
# A canned GOA, for contract runs: a JSON file in the shape `list` prints. With
|
|
# it set, nothing here imports gi or touches the session bus, and every change a
|
|
# verb would have made is appended to PANAMA_ACCOUNTS_LOG instead -- with the
|
|
# password replaced by its length, so a contract can prove it was read from
|
|
# stdin without a secret ever reaching a file.
|
|
FIXTURE_ENV = "PANAMA_ACCOUNTS_FIXTURE"
|
|
LOG_ENV = "PANAMA_ACCOUNTS_LOG"
|
|
|
|
# Every service GOA models. The account object carries one interface per service
|
|
# it supports, so presence of the interface is what "this account can do mail"
|
|
# means -- there is no capability list to read.
|
|
SERVICES = [
|
|
("mail", "Mail", "get_mail", "mail_disabled"),
|
|
("calendar", "Calendar", "get_calendar", "calendar_disabled"),
|
|
("contacts", "Contacts", "get_contacts", "contacts_disabled"),
|
|
("files", "Files", "get_files", "files_disabled"),
|
|
("photos", "Photos", "get_photos", "photos_disabled"),
|
|
("music", "Music", "get_music", "music_disabled"),
|
|
("chat", "Chat", "get_chat", "chat_disabled"),
|
|
]
|
|
|
|
|
|
def load_client():
|
|
import gi
|
|
|
|
gi.require_version("Goa", "1.0")
|
|
from gi.repository import Goa
|
|
|
|
return Goa.Client.new_sync(None)
|
|
|
|
|
|
def describe(obj):
|
|
account = obj.get_account()
|
|
services = []
|
|
for key, label, getter, disabled_prop in SERVICES:
|
|
if getattr(obj, getter)() is None:
|
|
continue
|
|
services.append({
|
|
"key": key,
|
|
"label": label,
|
|
"enabled": not getattr(account.props, disabled_prop),
|
|
})
|
|
|
|
return {
|
|
"path": obj.get_object_path(),
|
|
"provider": account.props.provider_type,
|
|
"providerName": account.props.provider_name,
|
|
# GOA hands back a serialised GThemedIcon: ". GThemedIcon name1 name2 …",
|
|
# a preference-ordered fallback chain. Passed on as that list rather than
|
|
# resolved here, because which of those names exists is a property of the
|
|
# icon theme in use, which this has no business deciding.
|
|
"providerIcons": themed_icon_names(account.props.provider_icon),
|
|
# PresentationIdentity is the human one (an email address); Identity is
|
|
# the internal handle and is not always readable.
|
|
"identity": account.props.presentation_identity or account.props.identity,
|
|
# GOA raises this when stored credentials stop working -- an expired
|
|
# token, a changed password. It is the one piece of state a user must
|
|
# act on, and nothing else surfaces it.
|
|
"needsAttention": bool(account.props.attention_needed),
|
|
"services": services,
|
|
}
|
|
|
|
|
|
def themed_icon_names(icon) -> list[str]:
|
|
"""The icon names out of a serialised GThemedIcon, best first.
|
|
|
|
The string form is ". GThemedIcon mail-unread-symbolic mail-symbolic mail",
|
|
where the leading "." and the type name are structure rather than content.
|
|
Anything that is not that shape yields nothing, so a caller gets an empty
|
|
list rather than a name that will never resolve.
|
|
"""
|
|
if icon is None:
|
|
return []
|
|
try:
|
|
text = icon.to_string()
|
|
except Exception:
|
|
text = str(icon)
|
|
parts = str(text or "").split()
|
|
if len(parts) < 3 or parts[0] != "." or parts[1] != "GThemedIcon":
|
|
return []
|
|
return [name for name in parts[2:] if name]
|
|
|
|
|
|
def find(client, path):
|
|
for obj in client.get_accounts():
|
|
if obj.get_object_path() == path:
|
|
return obj
|
|
return None
|
|
|
|
|
|
# ── Adding a password account ────────────────────────────────────────────────
|
|
#
|
|
# The keys below are not invented. They are the keys goa-daemon writes into
|
|
# ~/.config/goa-1.0/accounts.conf, which is to say the ones each provider's
|
|
# build_object reads back -- taken from GOA 3.58's goaowncloudprovider.c and
|
|
# goaimapsmtpprovider.c, and confirmed against the accounts already on this
|
|
# machine. A key GOA does not recognize is silently ignored, so getting one
|
|
# wrong produces an account that exists and does nothing.
|
|
|
|
|
|
def read_password() -> str:
|
|
"""The password, from stdin. Never an argument, at any point in the chain."""
|
|
secret = sys.stdin.buffer.read()
|
|
# A trailing newline from a pipe is not part of the password.
|
|
if secret.endswith(b"\n"):
|
|
secret = secret[:-1]
|
|
if not secret:
|
|
raise BoundaryError("No password was provided.")
|
|
return secret.decode("utf-8", "surrogateescape")
|
|
|
|
|
|
# RFC 1123 hostname (or a dotted IPv4). These strings end up in accounts.conf
|
|
# and in URIs other processes fetch, so a shell metacharacter or a space is a
|
|
# malformed address whichever way it got here -- refuse it at the boundary.
|
|
HOSTNAME = re.compile(
|
|
r"^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
|
|
r"(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$")
|
|
|
|
|
|
def valid_host(text: str) -> bool:
|
|
return bool(text) and len(text) <= 253 and bool(HOSTNAME.match(text))
|
|
|
|
|
|
def nextcloud_uris(server: str) -> tuple[str, str, str]:
|
|
"""(WebDAV URI, DAV URI, host) for a Nextcloud address.
|
|
|
|
`remote.php/webdav` and `remote.php/dav` are Nextcloud's fixed layout, and
|
|
are exactly what GOA's own provider derives from the server it was given.
|
|
"""
|
|
text = (server or "").strip()
|
|
if not text:
|
|
raise BoundaryError("Enter the address of your Nextcloud server.")
|
|
if "://" not in text:
|
|
text = "https://" + text
|
|
|
|
parsed = urllib.parse.urlsplit(text)
|
|
if parsed.scheme not in ("http", "https"):
|
|
raise BoundaryError("A server address starts with https://.")
|
|
if not parsed.hostname or not valid_host(parsed.hostname):
|
|
raise BoundaryError("That is not a server address.")
|
|
# urlsplit is permissive about what follows the host; a path is fine, but
|
|
# spaces or shell punctuation anywhere in the address are not an URL that
|
|
# a Nextcloud server has.
|
|
if re.search(r"[\s;|&`$<>\"']", text):
|
|
raise BoundaryError("That is not a server address.")
|
|
|
|
base = f"{parsed.scheme}://{parsed.netloc}{parsed.path.rstrip('/')}"
|
|
return base + "/remote.php/webdav/", base + "/remote.php/dav/", parsed.hostname
|
|
|
|
|
|
def nextcloud_account(server: str, username: str) -> tuple[str, str, dict]:
|
|
"""(identity, presentation identity, details). Validation and nothing else,
|
|
so the same refusals run whether or not there is a GOA to talk to."""
|
|
identity = (username or "").strip()
|
|
if not identity:
|
|
raise BoundaryError("Enter the user name for that server.")
|
|
webdav, dav, host = nextcloud_uris(server)
|
|
|
|
return (
|
|
identity,
|
|
# What the account is shown as. A bare user name says nothing about
|
|
# which server it is on, and people keep more than one.
|
|
identity if "@" in identity else f"{identity}@{host}",
|
|
{
|
|
"Uri": webdav,
|
|
"FilesEnabled": "true",
|
|
"CalendarEnabled": "true",
|
|
"CalDavUri": dav,
|
|
"ContactsEnabled": "true",
|
|
"CardDavUri": dav,
|
|
"AcceptSslErrors": "false",
|
|
},
|
|
)
|
|
|
|
|
|
def add_nextcloud(server: str, username: str, password: str) -> str:
|
|
from gi.repository import GLib
|
|
|
|
identity, presentation, details = nextcloud_account(server, username)
|
|
return add_account("owncloud", identity, presentation,
|
|
{"password": GLib.Variant("s", password)},
|
|
details, "Nextcloud")
|
|
|
|
|
|
def imap_account(email: str, imap_host: str, smtp_host: str,
|
|
username: str) -> tuple[str, str, dict]:
|
|
address = (email or "").strip()
|
|
if "@" not in address or address.startswith("@") or address.endswith("@"):
|
|
raise BoundaryError("Enter the email address for this account.")
|
|
imap = (imap_host or "").strip()
|
|
smtp = (smtp_host or "").strip()
|
|
if not imap or not smtp:
|
|
raise BoundaryError("Enter both the incoming and outgoing server.")
|
|
if not valid_host(imap) or not valid_host(smtp):
|
|
raise BoundaryError("Mail servers are host names, like imap.example.org.")
|
|
identity = (username or "").strip() or address
|
|
|
|
# The modern defaults, and the ones every mail provider this is likely to
|
|
# meet uses: IMAP over TLS on 993, submission with STARTTLS on 587. GOA
|
|
# offers a dropdown for the other combinations; a settings page that asked
|
|
# four encryption questions to add a mailbox would be worse than one that
|
|
# gets it right and lets GNOME's panel handle the unusual server.
|
|
return (
|
|
address, address,
|
|
{
|
|
"Enabled": "true",
|
|
"EmailAddress": address,
|
|
"Name": address,
|
|
"ImapHost": imap,
|
|
"ImapUserName": identity,
|
|
"ImapUseSsl": "true",
|
|
"ImapUseTls": "false",
|
|
"ImapAcceptSslErrors": "false",
|
|
"SmtpHost": smtp,
|
|
"SmtpUseAuth": "true",
|
|
"SmtpUserName": identity,
|
|
"SmtpAuthLogin": "false",
|
|
"SmtpAuthPlain": "true",
|
|
"SmtpUseSsl": "false",
|
|
"SmtpUseTls": "true",
|
|
"SmtpAcceptSslErrors": "false",
|
|
},
|
|
)
|
|
|
|
|
|
def add_imap(email: str, imap_host: str, smtp_host: str, username: str,
|
|
password: str) -> str:
|
|
from gi.repository import GLib
|
|
|
|
identity, presentation, details = imap_account(email, imap_host, smtp_host, username)
|
|
# One password for both servers: submission almost always takes the same
|
|
# credentials as the mailbox, and asking twice for the same secret is how a
|
|
# form gets abandoned. GOA stores them under separate keys regardless.
|
|
return add_account("imap_smtp", identity, presentation,
|
|
{"imap-password": GLib.Variant("s", password),
|
|
"smtp-password": GLib.Variant("s", password)},
|
|
details, "mail")
|
|
|
|
|
|
def add_account(provider: str, identity: str, presentation: str,
|
|
credentials: dict, details: dict, label: str) -> str:
|
|
"""Hand GOA a new account, then make it prove the credentials work.
|
|
|
|
The daemon does not check what it is given -- it writes the account out and
|
|
stores the password. Without the second step a mistyped password produces an
|
|
account that exists, looks fine, and never syncs, which is the failure this
|
|
page is supposed to be able to explain.
|
|
"""
|
|
from gi.repository import GLib
|
|
|
|
client = load_client()
|
|
manager = client.get_manager()
|
|
if manager is None:
|
|
raise BoundaryError("GNOME Online Accounts is not answering.")
|
|
try:
|
|
if not manager.call_is_supported_provider_sync(provider, None):
|
|
raise BoundaryError(f"This system cannot add {label} accounts.")
|
|
path = manager.call_add_account_sync(
|
|
provider, identity, presentation,
|
|
GLib.Variant("a{sv}", credentials),
|
|
GLib.Variant("a{ss}", details), None)
|
|
except GLib.Error as failure:
|
|
raise BoundaryError(clean(failure.message)) from failure
|
|
|
|
verify(path, label)
|
|
return path
|
|
|
|
|
|
def verify(path: str, label: str) -> None:
|
|
"""Sign in once, and undo the account if that fails."""
|
|
from gi.repository import GLib
|
|
|
|
# A fresh client: the one that made the call has not necessarily seen the
|
|
# object appear yet, and this is the cheapest way to wait for it properly.
|
|
obj = find(load_client(), path)
|
|
if obj is None:
|
|
# It was added; this process simply cannot see it yet. Reporting a
|
|
# failure here would be a lie, and the account list will show it.
|
|
return
|
|
|
|
account = obj.get_account()
|
|
try:
|
|
account.set_default_timeout(60000)
|
|
account.call_ensure_credentials_sync(None)
|
|
except GLib.Error as failure:
|
|
try:
|
|
account.call_remove_sync(None)
|
|
except GLib.Error:
|
|
pass
|
|
raise BoundaryError(
|
|
f"Those {label} details were not accepted: {clean(failure.message)}"
|
|
) from failure
|
|
|
|
|
|
def clean(message: str) -> str:
|
|
"""The useful sentence of a GOA error, without the D-Bus type prefix."""
|
|
trimmed = re.sub(r"^GDBus\.Error:[^:]+:\s*", "", str(message or "")).strip()
|
|
return trimmed.splitlines()[0][:200] if trimmed else "That did not work."
|
|
|
|
|
|
# ── The canned GOA ───────────────────────────────────────────────────────────
|
|
|
|
|
|
def fixture() -> dict | None:
|
|
path = os.environ.get(FIXTURE_ENV)
|
|
if not path:
|
|
return None
|
|
try:
|
|
with open(path, encoding="utf-8") as handle:
|
|
listing = json.load(handle)
|
|
except (OSError, ValueError) as failure:
|
|
return {"accounts": [], "error": f"The accounts fixture could not be read: {failure}"}
|
|
listing.setdefault("accounts", [])
|
|
listing.setdefault("error", "")
|
|
return listing
|
|
|
|
|
|
def record(verb: str, arguments: list, password: str | None = None) -> None:
|
|
path = os.environ.get(LOG_ENV) or (os.environ.get(FIXTURE_ENV, "") + ".log")
|
|
if not path:
|
|
return
|
|
entry = {"verb": verb, "arguments": arguments}
|
|
if password is not None:
|
|
# Its length, never the thing itself: the point of the log is to prove
|
|
# the password came in on stdin, not to keep a copy of it.
|
|
entry["passwordBytes"] = len(password)
|
|
try:
|
|
with open(path, "a", encoding="utf-8") as handle:
|
|
handle.write(json.dumps(entry, separators=(",", ":")) + "\n")
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def added(operation) -> int:
|
|
"""Run an add, and answer with the fresh account list either way.
|
|
|
|
Errors come back inside that list rather than on stderr, so the page has
|
|
both facts -- what went wrong and what is there now -- from one read.
|
|
"""
|
|
error = ""
|
|
try:
|
|
operation()
|
|
except BoundaryError as failure:
|
|
error = str(failure)
|
|
|
|
try:
|
|
accounts = [describe(obj) for obj in load_client().get_accounts()]
|
|
except Exception: # noqa: BLE001 - the error above is the one worth saying
|
|
accounts = []
|
|
print(json.dumps({"accounts": accounts, "error": error}))
|
|
return 0
|
|
|
|
|
|
def replay(action: str, arguments: list, canned: dict) -> int:
|
|
"""Every verb, against the canned GOA. No bus, no gi, no account changed."""
|
|
error = ""
|
|
try:
|
|
if action in ("list", "snapshot"):
|
|
pass
|
|
elif action in ("set", "remove"):
|
|
record(action, arguments[1:])
|
|
elif action == "add-nextcloud":
|
|
if len(arguments) != 3:
|
|
raise BoundaryError("Usage: add-nextcloud SERVER USERNAME")
|
|
password = read_password()
|
|
nextcloud_account(arguments[1], arguments[2])
|
|
record(action, arguments[1:], password)
|
|
elif action == "add-imap":
|
|
if len(arguments) != 5:
|
|
raise BoundaryError("Usage: add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME")
|
|
password = read_password()
|
|
imap_account(arguments[1], arguments[2], arguments[3], arguments[4])
|
|
record(action, arguments[1:], password)
|
|
else:
|
|
raise BoundaryError(f"Unknown command {action!r}.")
|
|
except BoundaryError as failure:
|
|
error = str(failure)
|
|
|
|
print(json.dumps({**canned, "error": error or canned.get("error", "")}))
|
|
return 0
|
|
|
|
|
|
def main():
|
|
arguments = sys.argv[1:]
|
|
action = arguments[0] if arguments else "list"
|
|
|
|
canned = fixture()
|
|
if canned is not None:
|
|
return replay(action, arguments, canned)
|
|
|
|
if action == "add-nextcloud":
|
|
if len(arguments) != 3:
|
|
print("usage: panama-accounts add-nextcloud SERVER USERNAME", file=sys.stderr)
|
|
return 2
|
|
return added(lambda: add_nextcloud(arguments[1], arguments[2], read_password()))
|
|
|
|
if action == "add-imap":
|
|
if len(arguments) != 5:
|
|
print("usage: panama-accounts add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME",
|
|
file=sys.stderr)
|
|
return 2
|
|
return added(lambda: add_imap(arguments[1], arguments[2], arguments[3],
|
|
arguments[4], read_password()))
|
|
|
|
try:
|
|
client = load_client()
|
|
except Exception as error: # noqa: BLE001 - any failure here means "no GOA"
|
|
# A machine without GOA is a legitimate state, not a crash.
|
|
print(json.dumps({
|
|
"accounts": [],
|
|
"error": f"GNOME Online Accounts is not available: {error}",
|
|
}))
|
|
return 0
|
|
|
|
# "snapshot" is what every other Panama helper calls this, and what the
|
|
# service asks for; "list" is what this one has always been called.
|
|
if action in ("list", "snapshot"):
|
|
print(json.dumps({
|
|
"accounts": [describe(obj) for obj in client.get_accounts()],
|
|
"error": "",
|
|
}))
|
|
return 0
|
|
|
|
if action == "set":
|
|
if len(sys.argv) != 5:
|
|
print("usage: panama-accounts set <path> <service> <true|false>", file=sys.stderr)
|
|
return 2
|
|
path, service, value = sys.argv[2], sys.argv[3], sys.argv[4]
|
|
obj = find(client, path)
|
|
if obj is None:
|
|
print(f"panama-accounts: no account at {path}", file=sys.stderr)
|
|
return 1
|
|
match = next((s for s in SERVICES if s[0] == service), None)
|
|
if match is None:
|
|
print(f"panama-accounts: unknown service {service!r}", file=sys.stderr)
|
|
return 2
|
|
if getattr(obj, match[2])() is None:
|
|
print(f"panama-accounts: this account does not support {service}", file=sys.stderr)
|
|
return 1
|
|
# The property is "disabled", so enabling a service clears it.
|
|
setattr(obj.get_account().props, match[3], value.lower() not in ("true", "1", "yes"))
|
|
return 0
|
|
|
|
if action == "remove":
|
|
if len(sys.argv) != 3:
|
|
print("usage: panama-accounts remove <path>", file=sys.stderr)
|
|
return 2
|
|
obj = find(client, sys.argv[2])
|
|
if obj is None:
|
|
print(f"panama-accounts: no account at {sys.argv[2]}", file=sys.stderr)
|
|
return 1
|
|
obj.get_account().call_remove_sync(None)
|
|
return 0
|
|
|
|
print("usage: panama-accounts [list|snapshot|set|remove|add-nextcloud|add-imap]",
|
|
file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|