Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
225 lines
9.8 KiB
Bash
Executable File
225 lines
9.8 KiB
Bash
Executable File
#!/usr/bin/env sh
|
|
# secrets/install.sh — write this user's 1Password vault item to a local,
|
|
# 0600 env file that the managed ~/.zshrc sources.
|
|
#
|
|
# WHY: secrets used to be pulled live in ~/.zshrc.local, one `op read` per
|
|
# field on EVERY new shell. That made each terminal wait on 1Password, broke
|
|
# every shell when the network or the service account was down, and meant each
|
|
# box carried its own hand-written copy of the loop. Now the vault is read once
|
|
# here (at bootstrap, or on demand with `ops`), and shells read a local file.
|
|
#
|
|
# Layout: one vault per user (default: the login name), holding one item
|
|
# (default: also the login name) whose field LABELS are the variable names —
|
|
# GITEA_KEY, LITELLM_KEY, PROXMOX_TOKEN_SECRET, ... Every such field is
|
|
# exported. Add a secret by adding a field (`opc NAME`), not by editing
|
|
# this repo.
|
|
#
|
|
# Files, all under ~/.config/dotfiles (0700), none in the repo:
|
|
# op.env OP_SERVICE_ACCOUNT_TOKEN, OP_VAULT, OP_ITEM (0600). Kept so a
|
|
# refresh needs no prompt. Deliberately NOT sourced by shells:
|
|
# the token stays out of every process's environment, and only
|
|
# the op* helpers in zsh/zshrc load it, in a subshell.
|
|
# secrets.env the exported fields (0600), replaced atomically, so a failed
|
|
# fetch leaves the previous copy in place.
|
|
#
|
|
# Prompts for the token on the controlling TTY the first time (bootstrap is
|
|
# often piped into sh, so stdin is not the terminal). Without a TTY, pass
|
|
# OP_SERVICE_ACCOUNT_TOKEN (and optionally OP_VAULT / OP_ITEM) in the env.
|
|
# Needs op and jq, which bin/ installs. No sudo.
|
|
set -eu
|
|
|
|
say() { printf '%s\n' "$*" >&2; }
|
|
|
|
CONF_DIR="${DOTFILES_CONFIG_DIR:-$HOME/.config/dotfiles}"
|
|
OP_ENV="$CONF_DIR/op.env"
|
|
SECRETS_ENV="$CONF_DIR/secrets.env"
|
|
MCP_SYNC_KEYFILE="$HOME/.config/mcp-sync/key"
|
|
|
|
umask 077
|
|
mkdir -p "$CONF_DIR"
|
|
chmod 0700 "$CONF_DIR"
|
|
|
|
PATH="$HOME/.local/bin:$PATH"
|
|
for _tool in op jq; do
|
|
if ! "$_tool" --version >/dev/null 2>&1; then
|
|
say "secrets: $_tool is not installed or does not run; run bin/install.sh first."
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
# --- 1. resolve the service account, vault and item -------------------------
|
|
# Precedence: the environment, then op.env, then a prompt / auto-detection.
|
|
#
|
|
# WHY each piece is checked on its own: the vault holds every key later
|
|
# modules need (LITELLM_KEY, GITEA_KEY, ...). A bad token paste used to fall
|
|
# through as a generic "could not read item", bootstrap carried on, and the
|
|
# first visible symptom was mcp-config/ asking for a LiteLLM key by hand —
|
|
# which hid the real problem. Now a rejected token, a wrong vault and a wrong
|
|
# item each get their own message, and on a TTY a re-prompt.
|
|
_env_token="${OP_SERVICE_ACCOUNT_TOKEN:-}"
|
|
_token_src="from OP_SERVICE_ACCOUNT_TOKEN"
|
|
[ -n "$_env_token" ] || _token_src="stored in $OP_ENV"
|
|
_env_vault="${OP_VAULT:-}"
|
|
_env_item="${OP_ITEM:-}"
|
|
if [ -f "$OP_ENV" ]; then
|
|
# shellcheck source=/dev/null
|
|
. "$OP_ENV"
|
|
fi
|
|
OP_SERVICE_ACCOUNT_TOKEN="${_env_token:-${OP_SERVICE_ACCOUNT_TOKEN:-}}"
|
|
OP_VAULT="${_env_vault:-${OP_VAULT:-}}"
|
|
OP_ITEM="${_env_item:-${OP_ITEM:-}}"
|
|
_user="$(id -un)"
|
|
|
|
# Probe in a subshell: in POSIX sh a failed redirect on a special builtin
|
|
# like `:` exits the whole script.
|
|
have_tty() { ( : < /dev/tty ) 2>/dev/null; }
|
|
|
|
# ask <prompt> <default> — read one line from the TTY; echoes the default on empty.
|
|
ask() {
|
|
printf '%s [%s]: ' "$1" "$2" >&2
|
|
IFS= read -r _ans < /dev/tty || _ans=""
|
|
printf '%s' "${_ans:-$2}"
|
|
}
|
|
|
|
# mask <secret> — first 10 and last 10 characters plus the length, so a
|
|
# truncated or doubled paste is visible without printing the whole token.
|
|
mask() {
|
|
printf '%s' "$1" | awk '{ n = length($0)
|
|
if (n <= 20) printf "%s (%d chars)", "(too short)", n
|
|
else printf "%s**********%s (first 10 + last 10 of %d chars)", substr($0, 1, 10), substr($0, n - 9), n }'
|
|
}
|
|
|
|
if [ -z "$OP_SERVICE_ACCOUNT_TOKEN" ] && [ -f "$SECRETS_ENV" ]; then
|
|
say "secrets: no service-account token; keeping existing $SECRETS_ENV."
|
|
exit 0
|
|
fi
|
|
|
|
# The token: `op whoami` is the cheapest call that fails on a bad token and
|
|
# nothing else. Up to three pastes before giving up.
|
|
_tries=0
|
|
while :; do
|
|
if [ -n "$OP_SERVICE_ACCOUNT_TOKEN" ]; then
|
|
export OP_SERVICE_ACCOUNT_TOKEN
|
|
op whoami >/dev/null 2>&1 && break
|
|
if [ "$_tries" -eq 0 ]; then
|
|
say "secrets: 1Password REJECTED the service-account token $_token_src (bad paste, revoked or expired?)."
|
|
say " token: $(mask "$OP_SERVICE_ACCOUNT_TOKEN")"
|
|
else
|
|
say "secrets: 1Password REJECTED that token — most likely a truncated or bad paste."
|
|
say " token: $(mask "$OP_SERVICE_ACCOUNT_TOKEN")"
|
|
fi
|
|
fi
|
|
if ! have_tty; then
|
|
say "secrets: no valid OP_SERVICE_ACCOUNT_TOKEN and no TTY to prompt."
|
|
say " re-run as: OP_SERVICE_ACCOUNT_TOKEN=ops_... $0"
|
|
exit 1
|
|
fi
|
|
if [ "$_tries" -ge 3 ]; then
|
|
say "secrets: giving up after $_tries attempts. Every key in the vault (LITELLM_KEY,"
|
|
say " GITEA_KEY, ...) is missing until this works: re-run $0"
|
|
exit 1
|
|
fi
|
|
_tries=$((_tries + 1))
|
|
printf '1Password service-account token (ops_..., input hidden): ' >&2
|
|
stty -echo < /dev/tty 2>/dev/null || true
|
|
IFS= read -r OP_SERVICE_ACCOUNT_TOKEN < /dev/tty || true
|
|
stty echo < /dev/tty 2>/dev/null || true
|
|
printf '\n' >&2
|
|
OP_SERVICE_ACCOUNT_TOKEN="$(printf '%s' "$OP_SERVICE_ACCOUNT_TOKEN" | tr -d ' \t\r\n')"
|
|
[ -n "$OP_SERVICE_ACCOUNT_TOKEN" ] || say "secrets: no token entered."
|
|
done
|
|
say "secrets: service-account token OK: $(mask "$OP_SERVICE_ACCOUNT_TOKEN")"
|
|
|
|
# pick <kind> <current> <default> <check-fn> <list-cmd...>
|
|
# Resolve a vault or item name: keep <current> if it checks out; otherwise use
|
|
# the only one the token can see, or ask (TTY) showing what it can see.
|
|
pick() {
|
|
_kind="$1" _cur="$2" _def="$3"; shift 3
|
|
_check="$1"; shift
|
|
_names="$("$@" --format json 2>/dev/null | jq -r '.[] | .name // .title')"
|
|
if [ -n "$_cur" ]; then
|
|
$_check "$_cur" && { printf '%s' "$_cur"; return 0; }
|
|
say "secrets: $_kind '$_cur' not found. This token can see:"
|
|
printf '%s\n' "$_names" | sed 's/^/ - /' >&2
|
|
fi
|
|
if [ "$(printf '%s\n' "$_names" | grep -c .)" -eq 1 ]; then
|
|
say "secrets: using the only $_kind this token can see: $_names"
|
|
printf '%s' "$_names"; return 0
|
|
fi
|
|
have_tty || { say "secrets: set OP_$(printf '%s' "$_kind" | tr a-z A-Z) and re-run."; return 1; }
|
|
printf '%s\n' "$_names" | grep -Fxq -- "$_def" || _def="$(printf '%s\n' "$_names" | head -n 1)"
|
|
while :; do
|
|
_cur="$(ask "$_kind" "$_def")"
|
|
$_check "$_cur" && { printf '%s' "$_cur"; return 0; }
|
|
say "secrets: $_kind '$_cur' not found. This token can see:"
|
|
printf '%s\n' "$_names" | sed 's/^/ - /' >&2
|
|
done
|
|
}
|
|
vault_ok() { op vault get "$1" >/dev/null 2>&1; }
|
|
item_ok() { op item get "$1" --vault "$OP_VAULT" >/dev/null 2>&1; }
|
|
|
|
OP_VAULT="$(pick vault "$OP_VAULT" "$_user" vault_ok op vault list)" || exit 1
|
|
OP_ITEM="$(pick item "$OP_ITEM" "$_user" item_ok op item list --vault "$OP_VAULT")" || exit 1
|
|
|
|
# --- 2. fetch the item once and render it -----------------------------------
|
|
# One `op item get` instead of one `op read` per field. Labels that are not
|
|
# valid shell identifiers (and the item's built-in notes field) are skipped
|
|
# rather than eval'd; values go through jq's @sh, so quotes, $ and newlines in
|
|
# a secret cannot break or inject into the file.
|
|
if ! _json="$(op item get "$OP_ITEM" --vault "$OP_VAULT" --reveal --format json)"; then
|
|
say "secrets: could not read item '$OP_ITEM' in vault '$OP_VAULT'."
|
|
[ -f "$SECRETS_ENV" ] && say "secrets: keeping the previous $SECRETS_ENV."
|
|
exit 1
|
|
fi
|
|
|
|
_skipped="$(printf '%s' "$_json" | jq -r '
|
|
[.fields[] | select(.purpose != "NOTES")
|
|
| select((.label // "") | test("^[A-Za-z_][A-Za-z0-9_]*$") | not) | .label]
|
|
| join(", ")')"
|
|
[ -z "$_skipped" ] || say "secrets: skipped fields whose labels are not variable names: $_skipped"
|
|
|
|
_tmp="$(mktemp "$CONF_DIR/secrets.env.XXXXXX")"
|
|
trap 'rm -f "$_tmp"' EXIT
|
|
{
|
|
printf '# Generated by dotfiles secrets/install.sh from op://%s/%s — do not edit.\n' "$OP_VAULT" "$OP_ITEM"
|
|
printf '# Refresh with: ops (change the vault with: opc NAME / opd NAME)\n'
|
|
printf '%s' "$_json" | jq -r '
|
|
.fields[]
|
|
| select(.purpose != "NOTES")
|
|
| select((.label // "") | test("^[A-Za-z_][A-Za-z0-9_]*$"))
|
|
| select((.value // "") != "")
|
|
| "export \(.label)=\(.value | @sh)"'
|
|
} > "$_tmp"
|
|
chmod 0600 "$_tmp"
|
|
_count="$(grep -c '^export ' "$_tmp" || true)"
|
|
if [ "$_count" -eq 0 ]; then
|
|
say "secrets: item '$OP_ITEM' has no exportable fields; keeping the previous file."
|
|
exit 1
|
|
fi
|
|
mv -f "$_tmp" "$SECRETS_ENV"
|
|
trap - EXIT
|
|
say "secrets: wrote $_count variables to $SECRETS_ENV (0600)."
|
|
grep -q '^export LITELLM_KEY=' "$SECRETS_ENV" \
|
|
|| say "secrets: WARNING: op://$OP_VAULT/$OP_ITEM has no LITELLM_KEY field; mcp-config/ and hermes/ need it."
|
|
|
|
# Remember the account only after it has actually worked.
|
|
{
|
|
printf 'OP_SERVICE_ACCOUNT_TOKEN=%s\n' "$(printf '%s' "$OP_SERVICE_ACCOUNT_TOKEN" | jq -Rr @sh)"
|
|
printf 'OP_VAULT=%s\n' "$(printf '%s' "$OP_VAULT" | jq -Rr @sh)"
|
|
printf 'OP_ITEM=%s\n' "$(printf '%s' "$OP_ITEM" | jq -Rr @sh)"
|
|
} > "$OP_ENV"
|
|
chmod 0600 "$OP_ENV"
|
|
|
|
# --- 3. keep mcp-sync's copy of the gateway key in step ----------------------
|
|
# mcp-config/sync.sh exports LITELLM_KEY from its own key file on every shell,
|
|
# AFTER secrets.env is loaded, so a key rotated in the vault would otherwise be
|
|
# silently overridden by the stale copy. Only update a file that already exists:
|
|
# creating it is mcp-config/install.sh's job.
|
|
_litellm="$(printf '%s' "$_json" | jq -r '.fields[] | select(.label == "LITELLM_KEY") | .value // empty' | head -n 1)"
|
|
if [ -n "$_litellm" ] && [ -f "$MCP_SYNC_KEYFILE" ] \
|
|
&& [ "$(tr -d ' \t\r\n' < "$MCP_SYNC_KEYFILE")" != "$_litellm" ]; then
|
|
printf '%s' "$_litellm" > "$MCP_SYNC_KEYFILE"
|
|
chmod 0600 "$MCP_SYNC_KEYFILE"
|
|
say "secrets: LITELLM_KEY changed; updated $MCP_SYNC_KEYFILE (re-run hermes/install.sh for Hermes)."
|
|
fi
|