Files
operation-bin/gopass_bernina_setup
lemke_h 224c68e90b bin: consistent naming, pixi_pgroup_install, git cleanup
- Commands lose their .sh extension (create_personal_dir,
  gopass_bernina_setup, gopass_bernina_service_setup,
  kerberos_ticket_renew, migrate_to_network_drive, pixi_local_sync,
  pixi_rebuild_local, sync_personal_config); .sh now only marks files
  that are sourced. kerberos_ticket_renew.sh and pixi_local_sync.sh stay
  as links, since they are started at login.
- Sourced files gain .sh (bpy-env, anaconda_env, anaconda_bernina_env,
  mm_bernina); the old names stay as links.
- All references in scripts, the .desktop file and the docs updated;
  cheatsheet.md regenerated via list_bin_commands.
- New pixi_pgroup_install: installs the shared bernina pixi env into a
  pgroup work folder (group-shared, with its own package cache) and
  makes it visible in JupyterHub as bernina-<exp>-<env>.
- Fix '#/bin/bash' first lines (missing '!') in 11 scripts.
- Stop tracking clutter (.nfs placeholders, __pycache__, vim swap file,
  .bak/_bak/~ copies, delme_*, empty 'me'); add .gitignore.
2026-09-25 15:48:11 +02:00

392 lines
17 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# gopass_bernina_setup — one-shot onboarding for a *human* PSI account
# joining the shared "bernina" gopass secrets store. See gopass.md (same
# directory) for the full picture; this automates the "Onboarding someone
# new" section.
#
# Storage backend: prefers ~/network_drives (SMB, Partially Shared Home) -
# the account's GPG keyring, gopass config, and the store clone all end up
# there, so they're already the same on every host without any separate
# provisioning step. Falls back to /sf/bernina/config/personal/<you> (NFS4)
# if network_drives isn't reachable on this host - see gopass.md
# "Store location" for the trade-off.
#
# THE WORKAROUND THIS SCRIPT USES for network_drives (confirmed by hand -
# see gopass.md): network_drives (SMB) rejects the chmod calls that
# `git init`/`git clone`/`git config`/`gpg --gen-key` make internally, so
# those must never run directly there ("Operation not supported"). An
# ALREADY-initialized repo or keyring, copied on with `cp -r` (rsync also
# works on some shares but confirmed to fail outright on at least one -
# see copy_tree() below), keeps working normally afterward:
# add/commit/push/pull, encrypt/decrypt, and `gopass insert`/`show`/`sync`
# all confirmed. So: do the actual init/clone/gen-key/config in a local
# scratch directory first, then copy the result onto network_drives and
# use it from there.
#
# What this script CANNOT do for you — gitea has no API for these, and
# they're each an action only another human/account can take:
# 1. Registering your SSH public key with gitea.psi.ch
# (Settings -> SSH/GPG Keys -> Add Key)
# 2. Getting added as a Collaborator (Write access) on the
# Bernina/bernina-secrets repo
# 3. Getting your GPG key authorized as a recipient - an existing
# recipient (currently: lemke_h) has to run `gopass recipients add`
# for you (see gopass.md) after you send them your exported
# public key
# The script detects each of these and tells you exactly what's missing
# and what to do, then stops - re-run it after fixing that one thing.
#
# Usage: gopass_bernina_setup [your-psi-email]
# Defaults to <your-username>@psi.ch.
#
# Safe to re-run: every step checks current state first and skips work
# that's already done. If you already have a working GPG key/store in an
# old location (e.g. $HOME/.gnupg, or a previous /sf/bernina-only setup),
# this OFFERS to copy it into the new location rather than generating a
# fresh one - keeps the same recipient registration, no re-authorization
# needed.
#
set -euo pipefail
GITEA_REMOTE="git@gitea.psi.ch:Bernina/bernina-secrets.git"
STORE_ALIAS="bernina"
PERSONAL_ROOT="/sf/bernina/config/personal"
BIN_DIR="/sf/bernina/bin"
log() { echo "==> $*"; }
warn() { echo "!! $*" >&2; }
ask() {
local reply
read -r -p "$1 [y/N] " reply
[[ "$reply" =~ ^[Yy]$ ]]
}
copy_tree() {
# `rsync -rlt` was the first choice here (no -p/-a - permission
# preservation doesn't work on network_drives) and works fine on some
# network_drives/SMB shares, but confirmed to fail on at least one
# (gac-bernina's: exit 3, "Errors selecting input/output files, dirs",
# with -v producing zero output even with stderr combined - looks
# like an rsync/gvfs-fuse incompatibility specific to that share).
# `cp -r` confirmed to work where that rsync failed, so use it
# everywhere instead for consistency.
mkdir -p "$2"
cp -r "$1/." "$2/"
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
cat <<'USAGE'
gopass_bernina_setup [your-psi-email]
For: any HUMAN PSI account that needs to read/write the shared "bernina"
gopass secrets store - this is the SAME script and procedure whether
you're the admin (the one who also runs `gopass recipients add` for
others) or a normal group user. There is no separate "admin mode".
NOT for the gac-bernina service account - see
gopass_bernina_service_setup for that (different: passphrase-less key,
deploy key instead of a personal SSH key, non-interactive).
Defaults to <your-username>@psi.ch. Safe to re-run.
USAGE
exit 0
fi
USERNAME="$(whoami)"
EMAIL="${1:-${USERNAME}@psi.ch}"
# --- 0. pick a storage backend ----------------------------------------------
if [[ -d "${HOME}/network_drives/home" ]]; then
BACKEND="network_drives"
ROOT_DIR="${HOME}/network_drives/home/gopass"
NEEDS_SCRATCH=1
else
BACKEND="sf_bernina"
ROOT_DIR="${PERSONAL_ROOT}/${USERNAME}"
NEEDS_SCRATCH=0
log "~/network_drives/home not reachable on this host - falling back to ${ROOT_DIR}"
fi
GNUPG_DIR="${ROOT_DIR}/gnupg"
GOPASS_HOME_DIR="${ROOT_DIR}/gopass-home"
STORE_DIR="${ROOT_DIR}/gopass-${STORE_ALIAS}"
ENV_FILE="${ROOT_DIR}/env.sh"
SCRATCH="$(mktemp -d)"
cleanup() { rm -rf "${SCRATCH}"; }
trap cleanup EXIT
echo "Setting up gopass access to the '${STORE_ALIAS}' store for ${EMAIL}"
echo "Backend: ${BACKEND} (everything will live under ${ROOT_DIR})"
echo
# --- 1. target directory -----------------------------------------------------
log "Step 1/6: target directory (${ROOT_DIR})"
if [[ "${BACKEND}" == "sf_bernina" ]]; then
"${BIN_DIR}/create_personal_dir"
fi
mkdir -p "${GOPASS_HOME_DIR}"
mkdir -m 700 -p "${GNUPG_DIR}"
# --- 2. SSH key + gitea reachability -----------------------------------------
log "Step 2/6: SSH key for gitea.psi.ch"
if [[ ! -f ~/.ssh/id_ed25519 && ! -f ~/.ssh/id_rsa ]]; then
if ask " No SSH key found. Generate one now (ssh-keygen -t ed25519)?"; then
ssh-keygen -t ed25519 -C "${EMAIL}"
else
warn " Can't continue without an SSH key - see gopass.md"
exit 1
fi
fi
if ssh -T -o BatchMode=yes -o ConnectTimeout=8 git@gitea.psi.ch </dev/null 2>&1 | grep -qi "successfully authenticated"; then
log " SSH to gitea.psi.ch: OK"
else
warn " SSH to gitea.psi.ch did not authenticate."
warn " Add your public key (~/.ssh/id_ed25519.pub or ~/.ssh/id_rsa.pub) at:"
warn " https://gitea.psi.ch -> Settings -> SSH/GPG Keys -> Add Key"
warn " Then re-run this script."
exit 1
fi
# --- 3. GPG key: already in place / migrate from an old location / generate -
# NOTE: we detect "is there a usable key" by presence, not by matching
# ${EMAIL} - your real PSI email often isn't <username>@psi.ch (e.g.
# lemke_h's is henrik.lemke@psi.ch), so a literal-match check would miss
# an existing key and offer to generate a redundant, unauthorized one.
# Once a key is found/generated, EMAIL is corrected to that key's own
# uid email for every step from here on (gopass init, the final
# instructions, etc).
log "Step 3/6: GPG key"
key_email_in() {
GNUPGHOME="$1" gpg --list-secret-keys --with-colons 2>/dev/null \
| awk -F: '/^uid/{print $10; exit}' | sed -n 's/.*<\(.*\)>.*/\1/p'
}
has_secret_key_in() {
GNUPGHOME="$1" gpg --list-secret-keys --with-colons 2>/dev/null | grep -q '^sec'
}
if has_secret_key_in "${GNUPG_DIR}"; then
EMAIL="$(key_email_in "${GNUPG_DIR}")"
log " already present under ${GNUPG_DIR} (${EMAIL})"
else
OLD_GNUPGHOME=""
for candidate in "${GNUPGHOME:-}" "${HOME}/.gnupg" "${PERSONAL_ROOT}/${USERNAME}/gnupg"; do
[[ -z "${candidate}" || "${candidate}" == "${GNUPG_DIR}" ]] && continue
if has_secret_key_in "${candidate}"; then
OLD_GNUPGHOME="${candidate}"
break
fi
done
if [[ -n "${OLD_GNUPGHOME}" ]]; then
FOUND_EMAIL="$(key_email_in "${OLD_GNUPGHOME}")"
log " found an existing key (${FOUND_EMAIL}) under ${OLD_GNUPGHOME}"
if ask " Copy it to ${GNUPG_DIR} (keeps the SAME key/recipient - no re-authorization needed)?"; then
copy_tree "${OLD_GNUPGHOME}" "${GNUPG_DIR}"
EMAIL="${FOUND_EMAIL}"
log " copied - using ${EMAIL} as your identity from here on"
else
warn " without this, decrypt calls will fail until a key exists under ${GNUPG_DIR}"
fi
else
warn " no GPG key found anywhere (checked ${GNUPG_DIR}, \$GNUPGHOME, ~/.gnupg, ${PERSONAL_ROOT}/${USERNAME}/gnupg)"
if ask " Generate one now (gpg --full-generate-key - use your REAL psi.ch email, set a real passphrase)?"; then
if [[ "${NEEDS_SCRATCH}" -eq 1 ]]; then
# gpg --gen-key does chmods internally that fail directly on
# network_drives - generate locally, then copy the result.
mkdir -m 700 -p "${SCRATCH}/gnupg"
GNUPGHOME="${SCRATCH}/gnupg" gpg --full-generate-key
copy_tree "${SCRATCH}/gnupg" "${GNUPG_DIR}"
else
GNUPGHOME="${GNUPG_DIR}" gpg --full-generate-key
# safety net in case this gpg version generated into the
# default homedir despite the GNUPGHOME override above
if ! has_secret_key_in "${GNUPG_DIR}" && has_secret_key_in "${HOME}/.gnupg"; then
copy_tree "${HOME}/.gnupg" "${GNUPG_DIR}"
fi
fi
if has_secret_key_in "${GNUPG_DIR}"; then
EMAIL="$(key_email_in "${GNUPG_DIR}")"
log " generated - using ${EMAIL} as your identity from here on"
fi
else
warn " Can't continue without a GPG key"
exit 1
fi
fi
fi
if ! has_secret_key_in "${GNUPG_DIR}"; then
warn " no usable key under ${GNUPG_DIR} - can't continue. Re-run and"
warn " agree to the copy/generate step above, or place a key there by hand first."
exit 1
fi
log " using GPG identity: ${EMAIL}"
export GNUPGHOME="${GNUPG_DIR}"
# --- 4. root store -------------------------------------------------------------
log "Step 4/6: gopass root store"
export GOPASS_HOMEDIR="${GOPASS_HOME_DIR}"
if gopass mounts >/dev/null 2>&1; then
log " already initialized"
elif [[ "${NEEDS_SCRATCH}" -eq 1 ]]; then
GNUPGHOME="${GNUPG_DIR}" GOPASS_HOMEDIR="${SCRATCH}/gopass-home" gopass init "${EMAIL}"
(cd "${SCRATCH}/gopass-home/.local/share/gopass/stores/root" && git config core.fileMode false)
copy_tree "${SCRATCH}/gopass-home" "${GOPASS_HOME_DIR}"
sed -i "s|${SCRATCH}/gopass-home|${GOPASS_HOME_DIR}|g" "${GOPASS_HOME_DIR}/.config/gopass/config"
else
gopass init "${EMAIL}"
fi
# --- 5. clone/mount the bernina store ----------------------------------------
log "Step 5/6: '${STORE_ALIAS}' store at ${STORE_DIR}"
if gopass mounts 2>/dev/null | grep -q "${STORE_ALIAS} ("; then
log " already mounted"
elif [[ -d "${STORE_DIR}/.git" ]]; then
log " directory already looks cloned, mounting it"
gopass mounts add "${STORE_ALIAS}" "${STORE_DIR}"
elif [[ -d "${STORE_DIR}" ]]; then
warn " ${STORE_DIR} exists but has no .git - a previous attempt likely"
warn " failed partway. See gopass.md Troubleshooting before removing"
warn " it by hand (confirm it really lacks .git first)."
exit 1
else
# Plain `git clone` + `gopass mounts add`, deliberately NOT `gopass
# clone`: confirmed by hand that `gopass clone` can hang indefinitely
# (Go-level deadlock, wchan futex_do_wait, zero subprocesses/network
# activity - not a slow/big-repo thing, reproduced even against the
# tiny real bernina-secrets repo) at its post-clone "Gathering
# information for the git repository" bookkeeping step. A plain git
# clone plus `gopass mounts add` (the same call already used just
# above when a store directory is pre-existing) reaches the same end
# state without going through that codepath - confirmed instant
# against the real repo.
CLONE_DEST="${STORE_DIR}"
if [[ "${NEEDS_SCRATCH}" -eq 1 ]]; then
CLONE_DEST="${SCRATCH}/${STORE_ALIAS}"
fi
CLONE_ERR="$(mktemp)"
if ! git clone "${GITEA_REMOTE}" "${CLONE_DEST}" 2>"${CLONE_ERR}"; then
cat "${CLONE_ERR}" >&2
if grep -qi "not authorized\|access rights\|could not read from remote\|permission denied" "${CLONE_ERR}"; then
warn " Repo access not granted yet. Ask the admin (lemke_h) to add you"
warn " as a Collaborator (Write access) on Bernina/bernina-secrets:"
warn " https://gitea.psi.ch/Bernina/bernina-secrets/settings/collaboration"
warn " Then re-run this script."
fi
rm -f "${CLONE_ERR}"
exit 1
fi
rm -f "${CLONE_ERR}"
(cd "${CLONE_DEST}" && git config core.fileMode false)
if [[ "${NEEDS_SCRATCH}" -eq 1 ]]; then
copy_tree "${CLONE_DEST}" "${STORE_DIR}"
fi
gopass mounts add "${STORE_ALIAS}" "${STORE_DIR}"
fi
# verify (don't skip this, a broken mount fails confusingly later)
if ! gopass mounts 2>/dev/null | grep -q "${STORE_ALIAS} ("; then
warn " mount verification failed - see gopass.md Troubleshooting"
exit 1
fi
if [[ ! -d "${STORE_DIR}/.git" ]]; then
warn " ${STORE_DIR} has no .git - clone did not fully succeed"
exit 1
fi
log " mounted and verified OK"
# --- 6. env.sh + export pubkey + tell them what's left -----------------------
log "Step 6/6: writing ${ENV_FILE} and exporting your GPG public key"
if [[ "${BACKEND}" == "network_drives" ]]; then
cat > "${ENV_FILE}" <<EOF
# Source this from ~/.bashrc (LOCAL to each host) to make your gopass
# access to '${STORE_ALIAS}' work the same on every host where ${ROOT_DIR}
# is reachable. Generated by gopass_bernina_setup - see gopass.md.
#
# ~/network_drives (SMB, "Partially Shared Home") is mounted per login
# session, asynchronously - on a freshly opened terminal/session it can
# take a moment to appear. Without this wait, the first gopass command of
# a new session can race the automount and silently fall back to an
# empty LOCAL store (this host's own \$HOME, not the shared one) - looks
# like "not a recipient" or "password-store is not initialized" for no
# obvious reason, and typically "fixes itself" on retry a moment later.
# Wait briefly (up to 5s) here instead, once per shell, so the first real
# command doesn't lose that race. See gopass.md Troubleshooting.
__gopass_wait_n=0
while [ ! -d "${GNUPG_DIR}" ] && [ "\${__gopass_wait_n}" -lt 20 ]; do
sleep 0.25
__gopass_wait_n=\$((__gopass_wait_n + 1))
done
unset __gopass_wait_n
export GNUPGHOME="${GNUPG_DIR}"
export GOPASS_HOMEDIR="${GOPASS_HOME_DIR}"
EOF
else
cat > "${ENV_FILE}" <<EOF
# Source this from ~/.bashrc (LOCAL to each host) to make your gopass
# access to '${STORE_ALIAS}' work the same on every host where ${ROOT_DIR}
# is reachable. Generated by gopass_bernina_setup - see gopass.md.
export GNUPGHOME="${GNUPG_DIR}"
export GOPASS_HOMEDIR="${GOPASS_HOME_DIR}"
EOF
fi
PUBKEY_FILE="${ROOT_DIR}/${USERNAME}-gopass-pubkey.asc"
gpg --armor --export "${EMAIL}" > "${PUBKEY_FILE}"
log " wrote ${PUBKEY_FILE}"
# is this identity already an authorized recipient? (true for anyone
# re-running this script after migrating an already-authorized key to a
# new location - e.g. the admin's own re-run - not just for brand-new
# people)
ALREADY_RECIPIENT=0
if gopass recipients 2>/dev/null | grep -qi "<${EMAIL}>"; then
ALREADY_RECIPIENT=1
fi
echo
echo "======================================================================"
# ~/.bashrc itself may be force-copied (not symlinked) from this file on
# every login, with no diff check, per PSI's Partially Shared Home
# 'copy:' mechanism (~/network_drives/home/linux_home_links.yaml) -
# confirmed 2026-09-18. If so, an edit to ~/.bashrc directly would be
# silently lost at the next login, so point at the real source instead.
BASHRC_TEMPLATE="${HOME}/network_drives/home/linux_config/bashrc"
if [[ -f "${BASHRC_TEMPLATE}" ]]; then
echo "Add ONE line to ${BASHRC_TEMPLATE} - NOT to ~/.bashrc"
echo "directly: your ~/.bashrc here gets force-copied from that file on"
echo "every login with no diff check (see the 'copy:' section of"
echo "~/network_drives/home/linux_home_links.yaml), so a direct edit to"
echo "~/.bashrc would silently vanish at your next login."
else
echo "Add ONE line to this host's LOCAL ~/.bashrc (not shared - do this once"
echo "per host, or replace any older GNUPGHOME/GOPASS_HOMEDIR exports there)."
fi
echo "Put it NEAR THE TOP - before any '[are we interactive?] || return'"
echo "guard - so it also reaches non-interactive shells (VS Code Remote-SSH,"
echo "cron, some Jupyter kernels), which would otherwise silently fall back"
echo "to this host's own empty local gopass store instead of erroring:"
echo
echo " source ${ENV_FILE}"
echo
if [[ "${ALREADY_RECIPIENT}" -eq 1 ]]; then
echo "You're already an authorized recipient of '${STORE_ALIAS}' (${EMAIL}) -"
echo "nothing else to do. Open a fresh shell (so the line above takes"
echo "effect), then 'gopass show ${STORE_ALIAS}/...' should work (it'll"
echo "ask for your GPG passphrase once, then cache it)."
else
echo "One step only an existing recipient can do for you:"
echo
echo " Send ${PUBKEY_FILE} to the store admin (lemke_h) - internal email,"
echo " Mattermost/Slack DM, or in person. They will run:"
echo
echo " gpg --import ${USERNAME}-gopass-pubkey.asc"
echo " gopass recipients add --store ${STORE_ALIAS} ${EMAIL}"
echo
echo "Until then, 'gopass show ${STORE_ALIAS}/...' will fail with a"
echo "'not a recipient' error - that's expected, not a bug (see gopass.md)."
fi
echo "======================================================================"