Give identity its due: native enrollment, honest deletion, and sign-in that stays home
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -12,22 +12,45 @@ 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. For
|
||||
password-based providers (Nextcloud, IMAP, WebDAV) that is a username and a
|
||||
password, which a settings app can reasonably collect. 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 Google sign-in is handed to
|
||||
GNOME's panel, and only the sign-in.
|
||||
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
|
||||
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"
|
||||
@@ -111,8 +134,319 @@ def find(client, path):
|
||||
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():
|
||||
action = sys.argv[1] if len(sys.argv) > 1 else "list"
|
||||
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()
|
||||
@@ -124,7 +458,9 @@ def main():
|
||||
}))
|
||||
return 0
|
||||
|
||||
if action == "list":
|
||||
# "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": "",
|
||||
@@ -162,7 +498,8 @@ def main():
|
||||
obj.get_account().call_remove_sync(None)
|
||||
return 0
|
||||
|
||||
print("usage: panama-accounts [list|set|remove]", file=sys.stderr)
|
||||
print("usage: panama-accounts [list|snapshot|set|remove|add-nextcloud|add-imap]",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user