From 61c3fa22aaf40b3bd063845bd446c80f43e519ee Mon Sep 17 00:00:00 2001 From: Fabio Scotto di Santolo Date: Wed, 18 Mar 2026 14:08:40 +0100 Subject: [PATCH] Move mail bootstrap out of Ansible Disable automatic iCloud keyring initialization by default and add a repo-local bootstrap script that reads .mbsyncrc, stores mail secrets in GNOME Keyring, guides Proton Bridge certificate export, and initializes mail sync/indexing. --- ansible/inventory/group_vars/desktop.yml | 2 + .../roles/profile_desktop_i3/tasks/main.yml | 197 ++++---- scripts/bootstrap_mail.sh | 432 ++++++++++++++++++ scripts/update_protonmail_bridge_secret.sh | 19 - 4 files changed, 534 insertions(+), 116 deletions(-) create mode 100755 scripts/bootstrap_mail.sh delete mode 100755 scripts/update_protonmail_bridge_secret.sh diff --git a/ansible/inventory/group_vars/desktop.yml b/ansible/inventory/group_vars/desktop.yml index 37c7674..8e055e1 100644 --- a/ansible/inventory/group_vars/desktop.yml +++ b/ansible/inventory/group_vars/desktop.yml @@ -1,4 +1,6 @@ --- +desktop_manage_icloud_keyring: false + profile_packages: - i3 - i3blocks diff --git a/ansible/roles/profile_desktop_i3/tasks/main.yml b/ansible/roles/profile_desktop_i3/tasks/main.yml index 163df0d..0272fa0 100644 --- a/ansible/roles/profile_desktop_i3/tasks/main.yml +++ b/ansible/roles/profile_desktop_i3/tasks/main.yml @@ -87,111 +87,114 @@ - path: "{{ user_home }}/.local/src" mode: "0755" -- name: Store iCloud mail password in GNOME Keyring - ansible.builtin.getent: - database: passwd - key: "{{ username }}" +- name: Bootstrap iCloud keyring secret from Ansible vault + when: desktop_manage_icloud_keyring | default(false) + block: + - name: Store iCloud mail password in GNOME Keyring + ansible.builtin.getent: + database: passwd + key: "{{ username }}" -- name: Set desktop user runtime UID - ansible.builtin.set_fact: - desktop_user_uid: "{{ ansible_facts.getent_passwd[username][1] }}" + - name: Set desktop user runtime UID + ansible.builtin.set_fact: + desktop_user_uid: "{{ ansible_facts.getent_passwd[username][1] }}" -- name: Check whether desktop user DBus session address file exists - ansible.builtin.stat: - path: "{{ user_home }}/.dbus-session-bus-address" - register: desktop_user_bus_address_file + - name: Check whether desktop user DBus session address file exists + ansible.builtin.stat: + path: "{{ user_home }}/.dbus-session-bus-address" + register: desktop_user_bus_address_file -- name: Read desktop user DBus session address - ansible.builtin.slurp: - src: "{{ user_home }}/.dbus-session-bus-address" - register: desktop_user_bus_address_raw - when: - - (vault_icloud_mail_password | default('')) | length > 0 - - desktop_user_bus_address_file.stat.exists + - name: Read desktop user DBus session address + ansible.builtin.slurp: + src: "{{ user_home }}/.dbus-session-bus-address" + register: desktop_user_bus_address_raw + when: + - (vault_icloud_mail_password | default('')) | length > 0 + - desktop_user_bus_address_file.stat.exists -- name: Set desktop user DBus session address - ansible.builtin.set_fact: - desktop_user_bus_address: >- - {{ desktop_user_bus_address_raw.content | b64decode | trim }} - when: - - (vault_icloud_mail_password | default('')) | length > 0 - - desktop_user_bus_address_file.stat.exists + - name: Set desktop user DBus session address + ansible.builtin.set_fact: + desktop_user_bus_address: >- + {{ desktop_user_bus_address_raw.content | b64decode | trim }} + when: + - (vault_icloud_mail_password | default('')) | length > 0 + - desktop_user_bus_address_file.stat.exists -- name: Check whether GNOME Keyring default collection is available - ansible.builtin.command: - cmd: >- - gdbus call --session - --dest org.freedesktop.secrets - --object-path /org/freedesktop/secrets - --method org.freedesktop.Secret.Service.ReadAlias default - become: true - become_user: "{{ username }}" - environment: - HOME: "{{ user_home }}" - XDG_RUNTIME_DIR: "/run/user/{{ desktop_user_uid }}" - DBUS_SESSION_BUS_ADDRESS: "{{ desktop_user_bus_address }}" - register: icloud_keyring_default_alias - failed_when: false - changed_when: false - when: - - (vault_icloud_mail_password | default('')) | length > 0 - - desktop_user_bus_address | default('') | length > 0 + - name: Check whether GNOME Keyring default collection is available + ansible.builtin.command: + cmd: >- + gdbus call --session + --dest org.freedesktop.secrets + --object-path /org/freedesktop/secrets + --method org.freedesktop.Secret.Service.ReadAlias default + become: true + become_user: "{{ username }}" + environment: + HOME: "{{ user_home }}" + XDG_RUNTIME_DIR: "/run/user/{{ desktop_user_uid }}" + DBUS_SESSION_BUS_ADDRESS: "{{ desktop_user_bus_address }}" + register: icloud_keyring_default_alias + failed_when: false + changed_when: false + when: + - (vault_icloud_mail_password | default('')) | length > 0 + - desktop_user_bus_address | default('') | length > 0 -- name: Set GNOME Keyring default collection path - ansible.builtin.set_fact: - icloud_keyring_default_alias_path: >- - {{ - ( - icloud_keyring_default_alias.stdout - | default('') - | regex_findall("objectpath '([^']+)'") - | first - ) - | default('') - }} - when: - - (vault_icloud_mail_password | default('')) | length > 0 - - desktop_user_bus_address | default('') | length > 0 - - icloud_keyring_default_alias.rc | default(1) == 0 + - name: Set GNOME Keyring default collection path + ansible.builtin.set_fact: + icloud_keyring_default_alias_path: >- + {{ + ( + icloud_keyring_default_alias.stdout + | default('') + | regex_findall("objectpath '([^']+)'") + | first + ) + | default('') + }} + when: + - (vault_icloud_mail_password | default('')) | length > 0 + - desktop_user_bus_address | default('') | length > 0 + - icloud_keyring_default_alias.rc | default(1) == 0 -- name: Store iCloud mail password in GNOME Keyring - ansible.builtin.command: - cmd: secret-tool store --label="iCloud Mail" icloud-mail icloud - stdin: "{{ vault_icloud_mail_password }}" - stdin_add_newline: false - become: true - become_user: "{{ username }}" - environment: - HOME: "{{ user_home }}" - XDG_RUNTIME_DIR: "/run/user/{{ desktop_user_uid }}" - DBUS_SESSION_BUS_ADDRESS: "{{ desktop_user_bus_address }}" - register: icloud_keyring_store - failed_when: false - changed_when: icloud_keyring_store.rc == 0 - no_log: true - when: - - (vault_icloud_mail_password | default('')) | length > 0 - - desktop_user_bus_address | default('') | length > 0 - - icloud_keyring_default_alias.rc | default(1) == 0 - - (icloud_keyring_default_alias_path | default('')) | length > 0 - - (icloud_keyring_default_alias_path | default('')) != '/' + - name: Store iCloud mail password in GNOME Keyring + ansible.builtin.command: + cmd: secret-tool store --label="iCloud Mail" icloud-mail icloud + stdin: "{{ vault_icloud_mail_password }}" + stdin_add_newline: false + become: true + become_user: "{{ username }}" + environment: + HOME: "{{ user_home }}" + XDG_RUNTIME_DIR: "/run/user/{{ desktop_user_uid }}" + DBUS_SESSION_BUS_ADDRESS: "{{ desktop_user_bus_address }}" + register: icloud_keyring_store + failed_when: false + changed_when: icloud_keyring_store.rc == 0 + no_log: true + when: + - (vault_icloud_mail_password | default('')) | length > 0 + - desktop_user_bus_address | default('') | length > 0 + - icloud_keyring_default_alias.rc | default(1) == 0 + - (icloud_keyring_default_alias_path | default('')) | length > 0 + - (icloud_keyring_default_alias_path | default('')) != '/' -- name: Warn when iCloud keyring storage is skipped - ansible.builtin.debug: - msg: >- - Unable to store iCloud password in GNOME Keyring automatically. - {% if (desktop_user_bus_address | default('')) | length == 0 %} - No saved DBus session address was found in {{ user_home }}/.dbus-session-bus-address. - {% elif icloud_keyring_default_alias.rc | default(1) != 0 %} - The Secret Service default alias could not be queried for {{ username }}. - {% elif (icloud_keyring_default_alias_path | default('')) == '/' %} - The Secret Service default alias is unset, so the login keyring is not initialized. - {% endif %} - Ensure a graphical user session is active, the login keyring exists and is unlocked, then run: - secret-tool store --label="iCloud Mail" icloud-mail icloud - when: - - (vault_icloud_mail_password | default('')) | length > 0 - - icloud_keyring_store.rc | default(1) != 0 + - name: Warn when iCloud keyring storage is skipped + ansible.builtin.debug: + msg: >- + Unable to store iCloud password in GNOME Keyring automatically. + {% if (desktop_user_bus_address | default('')) | length == 0 %} + No saved DBus session address was found in {{ user_home }}/.dbus-session-bus-address. + {% elif icloud_keyring_default_alias.rc | default(1) != 0 %} + The Secret Service default alias could not be queried for {{ username }}. + {% elif (icloud_keyring_default_alias_path | default('')) == '/' %} + The Secret Service default alias is unset, so the login keyring is not initialized. + {% endif %} + Ensure a graphical user session is active, the login keyring exists and is unlocked, then run: + secret-tool store --label="iCloud Mail" icloud-mail icloud + when: + - (vault_icloud_mail_password | default('')) | length > 0 + - icloud_keyring_store.rc | default(1) != 0 - name: Clone st repository ansible.builtin.git: diff --git a/scripts/bootstrap_mail.sh b/scripts/bootstrap_mail.sh new file mode 100755 index 0000000..286f71d --- /dev/null +++ b/scripts/bootstrap_mail.sh @@ -0,0 +1,432 @@ +#!/usr/bin/env sh + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd) +REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +MBSYNCRC="$REPO_ROOT/dotfiles/desktop/.mbsyncrc" +VAULT_FILE="$REPO_ROOT/secrets/vault.yml" + +ACCOUNTS_FILE=$(mktemp) + +cleanup() { + rm -f "$ACCOUNTS_FILE" +} + +trap cleanup EXIT INT TERM HUP + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + printf 'Error: required command not found: %s\n' "$1" >&2 + exit 1 + fi +} + +expand_path() { + case "$1" in + '~') + printf '%s\n' "$HOME" + ;; + '~/'*) + printf '%s/%s\n' "$HOME" "${1#~/}" + ;; + *) + printf '%s\n' "$1" + ;; + esac +} + +read_secret() { + prompt=$1 + value='' + + if [ -t 0 ]; then + printf '%s' "$prompt" >&2 + stty -echo + IFS= read -r value + stty echo + printf '\n' >&2 + else + printf '%s' "$prompt" >/dev/tty + stty -echo /dev/tty + fi + + printf '%s' "$value" +} + +get_vault_icloud_password() { + if [ ! -f "$VAULT_FILE" ]; then + return 1 + fi + + first_line=$(awk 'NR == 1 { print; exit }' "$VAULT_FILE") + + if [ "$first_line" = "\$ANSIBLE_VAULT" ] || printf '%s' "$first_line" | grep -q '^\$ANSIBLE_VAULT'; then + if ! command -v ansible-vault >/dev/null 2>&1; then + return 1 + fi + vault_content=$(ansible-vault view "$VAULT_FILE") || return 1 + else + vault_content=$(cat "$VAULT_FILE") + fi + + printf '%s\n' "$vault_content" | awk ' + $1 == "vault_icloud_mail_password:" { + sub(/^[^:]*:[[:space:]]*/, "", $0) + value = $0 + sub(/^"/, "", value) + sub(/"$/, "", value) + print value + exit + } + ' +} + +parse_mbsyncrc() { + awk ' + function trim(value) { + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value + } + + function dequote(value) { + value = trim(value) + if (value ~ /^".*"$/) { + sub(/^"/, "", value) + sub(/"$/, "", value) + } + return value + } + + $1 == "IMAPStore" { + section = "imap" + name = $2 + next + } + + $1 == "MaildirStore" { + section = "maildir" + name = $2 + next + } + + $1 == "Channel" { + section = "channel" + name = $2 + channel_order[++channel_count] = name + next + } + + section == "imap" && $1 == "User" { + imap_user[name] = trim(substr($0, index($0, $2))) + next + } + + section == "imap" && $1 == "Host" { + imap_host[name] = trim(substr($0, index($0, $2))) + next + } + + section == "imap" && $1 == "Port" { + imap_port[name] = trim(substr($0, index($0, $2))) + next + } + + section == "imap" && $1 == "PassCmd" { + imap_passcmd[name] = dequote(substr($0, index($0, $2))) + next + } + + section == "imap" && $1 == "CertificateFile" { + imap_certificate[name] = trim(substr($0, index($0, $2))) + next + } + + section == "maildir" && $1 == "Path" { + maildir_path[name] = trim(substr($0, index($0, $2))) + next + } + + section == "channel" && $1 == "Far" { + remote = $2 + gsub(/^:/, "", remote) + gsub(/:$/, "", remote) + channel_far[name] = remote + next + } + + section == "channel" && $1 == "Near" { + local_store = $2 + gsub(/^:/, "", local_store) + gsub(/:$/, "", local_store) + channel_near[name] = local_store + next + } + + END { + for (i = 1; i <= channel_count; i++) { + channel = channel_order[i] + remote = channel_far[channel] + local_store = channel_near[channel] + printf( + "ACCOUNT|%s|%s|%s|%s|%s|%s|%s\n", + channel, + imap_passcmd[remote], + imap_certificate[remote], + maildir_path[local_store], + imap_user[remote], + imap_host[remote], + imap_port[remote] + ) + } + } + ' "$MBSYNCRC" >"$ACCOUNTS_FILE" +} + +parse_secret_lookup_args() { + passcmd=$1 + prefix='secret-tool lookup ' + + case "$passcmd" in + "$prefix"*) + printf '%s\n' "${passcmd#${prefix}}" + ;; + *) + return 1 + ;; + esac +} + +clear_secret() { + lookup_args=$1 + # shellcheck disable=SC2086 + set -- $lookup_args + secret-tool clear "$@" >/dev/null 2>&1 || true +} + +store_secret() { + label=$1 + lookup_args=$2 + secret=$3 + + # shellcheck disable=SC2086 + set -- $lookup_args + + if [ "$#" -eq 0 ] || [ $(( $# % 2 )) -ne 0 ]; then + printf 'Error: invalid secret-tool lookup arguments: %s\n' "$lookup_args" >&2 + exit 1 + fi + + clear_secret "$lookup_args" + printf '%s' "$secret" | secret-tool store --label="$label" "$@" + + if ! secret-tool lookup "$@" >/dev/null 2>&1; then + printf 'Error: failed to verify secret for %s\n' "$label" >&2 + exit 1 + fi +} + +ensure_keyring_ready() { + require_command gdbus + require_command secret-tool + + if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]; then + printf 'Error: DBUS_SESSION_BUS_ADDRESS is not set. Run this from an active graphical session.\n' >&2 + exit 1 + fi + + alias_output=$(gdbus call --session \ + --dest org.freedesktop.secrets \ + --object-path /org/freedesktop/secrets \ + --method org.freedesktop.Secret.Service.ReadAlias default 2>/dev/null) || { + printf 'Error: could not query GNOME Keyring. Ensure the keyring service is running.\n' >&2 + exit 1 + } + + alias_path=$(printf '%s\n' "$alias_output" | awk "match(\$0, /objectpath '([^']+)'/, parts) { print parts[1]; exit }") + + if [ -z "$alias_path" ] || [ "$alias_path" = "/" ]; then + printf 'Error: the default Secret Service collection is unset. Unlock or initialize the login keyring first.\n' >&2 + exit 1 + fi +} + +bridge_cli_command() { + if command -v flatpak >/dev/null 2>&1 && flatpak info ch.protonmail.protonmail-bridge >/dev/null 2>&1; then + printf '%s\n' 'flatpak run ch.protonmail.protonmail-bridge --cli' + return 0 + fi + + if command -v protonmail-bridge >/dev/null 2>&1; then + printf '%s\n' 'protonmail-bridge --cli' + return 0 + fi + + if command -v bridge >/dev/null 2>&1; then + printf '%s\n' 'bridge --cli' + return 0 + fi + + return 1 +} + +ensure_certificate_file() { + account_name=$1 + certificate_file=$2 + + if [ -z "$certificate_file" ]; then + return 0 + fi + + expanded_certificate_file=$(expand_path "$certificate_file") + + if [ -f "$expanded_certificate_file" ]; then + return 0 + fi + + certificate_dir=$(dirname "$expanded_certificate_file") + mkdir -p "$certificate_dir" + + bridge_cli=$(bridge_cli_command) || { + printf 'Error: %s requires %s, but Proton Mail Bridge CLI was not found.\n' \ + "$account_name" "$expanded_certificate_file" >&2 + exit 1 + } + + printf 'Certificate for %s not found at %s\n' "$account_name" "$expanded_certificate_file" + printf 'Opening Proton Mail Bridge CLI. Export the TLS certificate into: %s\n' "$certificate_dir" + printf 'Inside Bridge CLI, use `cert export`, choose `%s`, then exit to continue.\n' "$certificate_dir" + + sh -c "$bridge_cli" + + if [ ! -f "$expanded_certificate_file" ]; then + printf 'Error: certificate still missing at %s after Bridge export.\n' "$expanded_certificate_file" >&2 + exit 1 + fi +} + +sync_channels() { + while IFS='|' read -r record_type channel_name passcmd certificate_file maildir_path email_address host port; do + [ "$record_type" = 'ACCOUNT' ] || continue + [ -n "$passcmd" ] || continue + + printf 'Running mbsync for channel %s\n' "$channel_name" + mbsync "$channel_name" + done <"$ACCOUNTS_FILE" +} + +init_mu() { + mail_root='' + mu_addresses='' + + while IFS='|' read -r record_type channel_name passcmd certificate_file maildir_path email_address host port; do + [ "$record_type" = 'ACCOUNT' ] || continue + [ -n "$maildir_path" ] || continue + + expanded_maildir=$(expand_path "$maildir_path") + expanded_maildir=${expanded_maildir%/} + candidate_root=${expanded_maildir%/*} + + if [ -z "$mail_root" ]; then + mail_root=$candidate_root + elif [ "$mail_root" != "$candidate_root" ]; then + printf 'Error: inconsistent Maildir roots in %s (%s vs %s).\n' "$MBSYNCRC" "$mail_root" "$candidate_root" >&2 + exit 1 + fi + + case " $mu_addresses " in + *" $email_address "*) + ;; + *) + mu_addresses="$mu_addresses $email_address" + ;; + esac + done <"$ACCOUNTS_FILE" + + if [ -z "$mail_root" ]; then + printf 'Error: no Maildir paths found in %s\n' "$MBSYNCRC" >&2 + exit 1 + fi + + if ! mu info >/dev/null 2>&1; then + set -- + for email_address in $mu_addresses; do + set -- "$@" --my-address="$email_address" + done + mu init --maildir="$mail_root" "$@" + fi + + mu index +} + +bootstrap_secrets() { + while IFS='|' read -r record_type channel_name passcmd certificate_file maildir_path email_address host port; do + [ "$record_type" = 'ACCOUNT' ] || continue + [ -n "$passcmd" ] || continue + + lookup_args=$(parse_secret_lookup_args "$passcmd") || continue + + case "$lookup_args" in + 'icloud-mail icloud') + if icloud_password=$(get_vault_icloud_password); then + : + else + icloud_password=$(read_secret 'iCloud app password: ') + fi + + if [ -z "$icloud_password" ]; then + printf 'Error: empty iCloud password.\n' >&2 + exit 1 + fi + + store_secret "$channel_name" "$lookup_args" "$icloud_password" + ;; + 'protonmail-bridge protonmail') + proton_password=$(read_secret 'Proton Bridge password: ') + + if [ -z "$proton_password" ]; then + printf 'Error: empty Proton Bridge password.\n' >&2 + exit 1 + fi + + store_secret "$channel_name" "$lookup_args" "$proton_password" + ensure_certificate_file "$channel_name" "$certificate_file" + ;; + *) + generic_password=$(read_secret "$channel_name password: ") + + if [ -z "$generic_password" ]; then + printf 'Error: empty password for %s.\n' "$channel_name" >&2 + exit 1 + fi + + store_secret "$channel_name" "$lookup_args" "$generic_password" + [ -n "$certificate_file" ] && ensure_certificate_file "$channel_name" "$certificate_file" + ;; + esac + done <"$ACCOUNTS_FILE" +} + +main() { + require_command awk + require_command dirname + require_command mbsync + require_command mu + + if [ ! -f "$MBSYNCRC" ]; then + printf 'Error: mbsync config not found: %s\n' "$MBSYNCRC" >&2 + exit 1 + fi + + parse_mbsyncrc + ensure_keyring_ready + bootstrap_secrets + sync_channels + init_mu +} + +main "$@" diff --git a/scripts/update_protonmail_bridge_secret.sh b/scripts/update_protonmail_bridge_secret.sh deleted file mode 100755 index a08bc60..0000000 --- a/scripts/update_protonmail_bridge_secret.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env sh - -set -eu - -printf "Proton Bridge password: " -stty -echo -IFS= read -r proton_bridge_password -stty echo -printf "\n" - -if [ -z "$proton_bridge_password" ]; then - printf "Error: empty password, nothing stored.\n" >&2 - exit 1 -fi - -printf "%s" "$proton_bridge_password" \ - | secret-tool store --label="ProtonMail Bridge" protonmail-bridge protonmail - -printf "ProtonMail Bridge secret updated in GNOME Keyring.\n"