#!/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 `envs`), 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 (`envc 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 env* 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 (token) / defaults. _env_token="${OP_SERVICE_ACCOUNT_TOKEN:-}" _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)" # ask — 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}" } if [ -z "$OP_SERVICE_ACCOUNT_TOKEN" ]; then if [ -f "$SECRETS_ENV" ]; then say "secrets: no service-account token; keeping existing $SECRETS_ENV." exit 0 fi # Probe in a subshell: in POSIX sh a failed redirect on a special builtin # like `:` exits the whole script, which would skip the message below. if ! ( : < /dev/tty ) 2>/dev/null; then say "secrets: no OP_SERVICE_ACCOUNT_TOKEN and no TTY to prompt." say " re-run as: OP_SERVICE_ACCOUNT_TOKEN=ops_... $0" exit 1 fi 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."; exit 1; } [ -n "$OP_VAULT" ] || OP_VAULT="$(ask 'Vault' "$_user")" [ -n "$OP_ITEM" ] || OP_ITEM="$(ask 'Item' "$_user")" fi OP_VAULT="${OP_VAULT:-$_user}" OP_ITEM="${OP_ITEM:-$_user}" export OP_SERVICE_ACCOUNT_TOKEN # --- 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: envs (change the vault with: envc NAME / envd 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)." # 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