diff --git a/README.md b/README.md index 388ff82..e4635ee 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ configure It covers: -- **Your Federated Computer Core.** Give it your Superpanel's address (e.g. https://superpanel.example.com), your Core email, name and password, and it adds Superpanel to your applications, adds your Nextcloud (calendar, contacts and files) and email to GNOME's Online Accounts, and mounts your Nextcloud files at /mnt/example.com ("example.com" in the Files sidebar), and points Element at your Core's Matrix server. For Nextcloud and the files it creates a Nextcloud app password, which you can revoke in Nextcloud under Settings > Security. You can add more Cores: run `configure core` again and choose "Add a Core". Each gets its own Superpanel, Nextcloud, email and files; Element, Tailscale and backups use your main Core (the first one, unless you choose another). "Remove a Core" takes it off this computer and revokes its app password, and leaves everything on the Core itself as it is. +- **Your Federated Computer Core.** Give it your Superpanel's address (e.g. https://superpanel.example.com), your Core email, name and password, and it adds Superpanel to your applications, adds your Nextcloud (calendar, contacts and files) and email to GNOME's Online Accounts, and mounts your Nextcloud files at /mnt/example.com ("example.com" in the Files sidebar), and points Element at your Core's Matrix server. For Nextcloud and the files it creates a Nextcloud app password, which you can revoke in Nextcloud under Settings > Security. You can add more Cores: run `configure core` again and choose "Add a Core". Each gets its own Superpanel, Nextcloud, email and files; Element, Tailscale and backups use your main Core (the first one, unless you choose another). "Remove a Core" takes it off this computer and revokes its app password, and leaves everything on the Core itself as it is. If you change your Core password (e.g. in Superpanel), run `configure core` and choose "Your Core password changed": email keeps your Core password, so it saves the new one there, and it signs in to Nextcloud with it, which turns the app passwords for Nextcloud, files and backups back on (Nextcloud turns them off when your password changes), then checks each of them. - **Your Core's Tailscale network.** Joins this computer to it through headscale.example.com, so you can reach your other machines on it. Set up your Core first. You sign in through a link it shows you; if this computer is on another Tailscale network, it moves it to your Core's. To leave your Core's network, run `configure tailscale` again. - **Daily backups of your home folder** to your Nextcloud files, in a .backup-{machine name} folder, with restic. Set up your Core first. The backup is encrypted with a passphrase that `configure` shows you once: save it somewhere other than this computer, such as Bitwarden. It keeps 7 daily, 4 weekly and 6 monthly backups, and leaves out caches, things that can be reinstalled, and WinBoat's Windows VM (~/winboat). To leave out more, list paths in ~/.config/coredesktop/backup-exclude, one per line. restic reaches Nextcloud directly through rclone with its own Nextcloud app password, so the backup doesn't depend on the /mnt mount. See "Restoring from a backup" below. To turn backups off, run `configure backup` again; it also offers to delete the backups from your Nextcloud files (they go to Nextcloud's trash bin). - Signing in to Claude Code, opencode and Hermes Agent and choosing their model providers. diff --git a/configure.d/10-core.sh b/configure.d/10-core.sh index d01c868..1dc8535 100755 --- a/configure.d/10-core.sh +++ b/configure.d/10-core.sh @@ -348,6 +348,69 @@ setup_core() { return $STATUS } +# Check an app password this computer keeps for the Core +app_password_works() { + local title="$1" app_password="$2" + [ -n "$app_password" ] || return 0 + case "$(nextcloud_sign_in "$app_password"; echo $?)" in + 0) echo " $title: working" ;; + 2) echo -e " \e[31m$title: Nextcloud doesn't accept its app password.\e[0m" + echo " It may have been revoked in Nextcloud under Settings > Security." + return 1 ;; + *) echo " $title: couldn't reach Nextcloud to check" ;; + esac +} + +# After your Core password changes, e.g. in Superpanel. Email keeps your Core +# password, so it gets the new one. Nextcloud, files and backups use app +# passwords, which stay the same; Nextcloud turns them off until you sign in +# with the new password, and checking the password here does that. +change_password() { + local tries reached=1 + load_core "$1" + STATUS=0 + for tries in 1 2 3; do + read -rsp "Your new Core password: " PASSWORD + echo + nextcloud_sign_in + case $? in + 0) break ;; + 2) echo "Nextcloud says that password is wrong."; PASSWORD="" ;; + *) echo "Couldn't reach Nextcloud at $NEXTCLOUD; trying the password for email anyway." + reached=0; break ;; + esac + done + [ -n "$PASSWORD" ] || return 1 + + echo + if email_done; then + jq -n --arg password "$PASSWORD" '{"imap-password": $password, "smtp-password": $password}' \ + | "$GOA_ACCOUNT" update imap_smtp "$CORE_EMAIL" 2> /dev/null + case $? in + 0) echo " Email: has your new password" ;; + 3) echo -e " \e[31mEmail: saved your new password, but the mail server didn't accept it.\e[0m" + STATUS=1 ;; + *) echo -e " \e[31mEmail: couldn't save your new password.\e[0m"; STATUS=1 ;; + esac + fi + unset PASSWORD + [ $reached = 1 ] || return 1 + + if nextcloud_done; then + if "$GOA_ACCOUNT" check owncloud "$CORE_USER@nextcloud.$CORE_DOMAIN" 2> /dev/null; then + echo " Nextcloud in Online Accounts: working" + else + echo -e " \e[31mNextcloud in Online Accounts: can't sign in.\e[0m" + echo " Remove it in Settings > Online Accounts, then run: configure core" + STATUS=1 + fi + fi + files_done && { app_password_works "Files at $MOUNT" "$(files_app_password)" || STATUS=1; } + app_password_works "Backups" \ + "$(secret-tool lookup coredesktop backup item nextcloud url "$WEBDAV" 2> /dev/null)" || STATUS=1 + return $STATUS +} + # Tailscale and Backup point at one Core, which can't be removed while they do tailscale_on() { [ "$(source "$TAILSCALE_CONF" 2> /dev/null && echo "$TAILSCALE_DOMAIN")" = "$1" ] \ @@ -473,6 +536,7 @@ run_configure() { echo " Choose a number to finish or change its setup, or:" echo " a) Add a Core" [ ${#DOMAINS[@]} -gt 1 ] && echo " m) Make another Core the main one" + echo " p) Your Core password changed" echo " r) Remove a Core" echo " q) Leave it as it is" echo @@ -485,6 +549,9 @@ run_configure() { [ ${#DOMAINS[@]} -gt 1 ] || return 0 pick_core "Which should be the main one?" "$MAIN_DOMAIN" || { echo "Choose a number from the list."; return 1; } make_main "$PICKED" && main_changed ;; + p|P) + pick_core "Which Core's password changed?" || { echo "Choose a number from the list."; return 1; } + change_password "$PICKED" ;; r|R) pick_core "Which Core should be removed?" || { echo "Choose a number from the list."; return 1; } remove_core "$PICKED" ;; diff --git a/configure.d/lib/goa-account b/configure.d/lib/goa-account index b49213a..dbfac08 100755 --- a/configure.d/lib/goa-account +++ b/configure.d/lib/goa-account @@ -1,19 +1,27 @@ #!/usr/bin/env python3 -"""Add or remove an account in GNOME Online Accounts, the way Settings does. +"""Add, change or remove an account in GNOME Online Accounts, the way Settings does. Settings adds accounts by calling AddAccount on the Online Accounts service, which saves the account and keeps its password in the GNOME keyring. This -calls the same method. The account comes on stdin rather than as arguments, -so its password doesn't show up in the process list. +calls the same method. The service can't change a password, so when you sign +in again, Settings saves the new one in the keyring itself and asks the +service to check it (EnsureCredentials); update does the same. Passwords come +on stdin rather than as arguments, so they don't show up in the process list. Usage: goa-account exists goa-account add < account.json + goa-account update < credentials.json + goa-account check goa-account remove account.json: {"provider": "owncloud", "identity": "...", "presentation_identity": "...", "credentials": {"password": "..."}, "details": {"Uri": "...", ...}} + +credentials.json is the account's "credentials", e.g. {"password": "..."}. +check signs in with what's saved, and fails if the server says no. update +exits with 3 if it saved the password but the server didn't accept it. """ import json @@ -22,10 +30,16 @@ import sys import gi gi.require_version("Gio", "2.0") -from gi.repository import Gio, GLib # noqa: E402 +gi.require_version("Secret", "1") +from gi.repository import Gio, GLib, Secret # noqa: E402 BUS_NAME = "org.gnome.OnlineAccounts" +# How Online Accounts keeps passwords in the keyring: the credentials printed +# as a GVariant, under ":gen:" +SCHEMA = Secret.Schema.new("org.gnome.OnlineAccounts", Secret.SchemaFlags.DONT_MATCH_NAME, + {"goa-identity": Secret.SchemaAttributeType.STRING}) + def call(bus, path, interface, method, parameters, reply_type): return bus.call_sync(BUS_NAME, path, interface, method, parameters, @@ -43,12 +57,16 @@ def accounts(bus): yield path, account -def find(bus, provider, presentation_identity): - return [path for path, account in accounts(bus) +def find_accounts(bus, provider, presentation_identity): + return [(path, account) for path, account in accounts(bus) if account.get("ProviderType") == provider and account.get("PresentationIdentity") == presentation_identity] +def find(bus, provider, presentation_identity): + return [path for path, _ in find_accounts(bus, provider, presentation_identity)] + + def exists(bus, provider, presentation_identity): return bool(find(bus, provider, presentation_identity)) @@ -63,6 +81,52 @@ def add(bus, account): "org.gnome.OnlineAccounts.Manager", "AddAccount", parameters, "(o)") +def ensure_credentials(bus, path): + call(bus, path, "org.gnome.OnlineAccounts.Account", "EnsureCredentials", None, "(i)") + + +# N in the keyring's name comes from the provider, so this saves over the +# account's own item rather than making one +def store_credentials(provider, account_id, credentials): + items = Secret.password_search_sync( + None, {"xdg:schema": "org.gnome.OnlineAccounts"}, + Secret.SearchFlags.ALL | Secret.SearchFlags.UNLOCK, None) + identities = [item.get_attributes().get("goa-identity", "") for item in items] + identities = [identity for identity in identities + if identity.startswith(f"{provider}:gen") and identity.endswith(f":{account_id}")] + if not identities: + raise LookupError(f"there's no saved password for {account_id} in the keyring") + secret = GLib.Variant("a{sv}", {key: GLib.Variant("s", value) + for key, value in credentials.items()}).print_(True) + for identity in identities: + Secret.password_store_sync(SCHEMA, {"goa-identity": identity}, Secret.COLLECTION_DEFAULT, + f"GOA {provider} credentials for identity {account_id}", + secret, None) + + +def update(bus, provider, presentation_identity, credentials): + found = find_accounts(bus, provider, presentation_identity) + if not found: + raise LookupError(f"there's no {provider} account for {presentation_identity}") + for path, account in found: + store_credentials(provider, account["Id"], credentials) + try: + for path, _ in found: + ensure_credentials(bus, path) + except GLib.Error as error: + print(f"GNOME Online Accounts: {error.message}", file=sys.stderr) + return 3 + return 0 + + +def check(bus, provider, presentation_identity): + found = find(bus, provider, presentation_identity) + if not found: + raise LookupError(f"there's no {provider} account for {presentation_identity}") + for path in found: + ensure_credentials(bus, path) + + def remove(bus, provider, presentation_identity): for path in find(bus, provider, presentation_identity): call(bus, path, "org.gnome.OnlineAccounts.Account", "Remove", None, "()") @@ -78,6 +142,11 @@ def main(): return 0 add(bus, account) return 0 + if len(sys.argv) == 4 and sys.argv[1] == "update": + return update(bus, sys.argv[2], sys.argv[3], json.load(sys.stdin)) + if len(sys.argv) == 4 and sys.argv[1] == "check": + check(bus, sys.argv[2], sys.argv[3]) + return 0 if len(sys.argv) == 4 and sys.argv[1] == "remove": remove(bus, sys.argv[2], sys.argv[3]) return 0 @@ -91,3 +160,6 @@ if __name__ == "__main__": except GLib.Error as error: print(f"GNOME Online Accounts: {error.message}", file=sys.stderr) sys.exit(1) + except LookupError as error: + print(f"GNOME Online Accounts: {error}", file=sys.stderr) + sys.exit(1) diff --git a/configure.d/lib/nextcloud.sh b/configure.d/lib/nextcloud.sh index f5ffd8b..8e13a76 100644 --- a/configure.d/lib/nextcloud.sh +++ b/configure.d/lib/nextcloud.sh @@ -31,3 +31,18 @@ nextcloud_revoke_app_password() { auth_header "$CORE_USER" "$1" | curl -sf -o /dev/null -X DELETE -H @- \ -H 'OCS-APIRequest: true' "$NEXTCLOUD/ocs/v2.php/core/apppassword" } + +# Sign in to Nextcloud as CORE_USER with a password ($1), or PASSWORD if not +# given. Signing in with your Core password (not an app password) also turns +# back on the app passwords Nextcloud turned off when the Core password +# changed. Uses NEXTCLOUD. Returns 2 if Nextcloud says the password is wrong. +nextcloud_sign_in() { + local code + code="$(auth_header "$CORE_USER" "${1:-$PASSWORD}" | curl -s -o /dev/null -w '%{http_code}' -H @- \ + -H 'OCS-APIRequest: true' "$NEXTCLOUD/ocs/v2.php/cloud/user?format=json")" + case "$code" in + 200) return 0 ;; + 401) return 2 ;; + *) return 1 ;; + esac +}