179 lines
7.2 KiB
Bash
Executable File
179 lines
7.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Usage: tools/rotate-secret [-h] check <ssh-target> <KEY> [<sha256>]
|
|
# tools/rotate-secret [-h] set <ssh-target> <KEY> (the new value on stdin)
|
|
# tools/rotate-secret [-h] purge <ssh-target> <KEY> <sha256>
|
|
# tools/rotate-secret [-h] fetch <ssh-target> <KEY>
|
|
# tools/rotate-secret [-h] digest <KEY>
|
|
# tools/rotate-secret [-h] probe <KEY> [-] (with "-" the value on stdin)
|
|
#
|
|
# Handles one secret, e.g. GITEA_BOT_TOKEN, in ~/<repo>/.git/werkator/.werkator.yml of each repository
|
|
# on the Werkator host <ssh-target> that has it, and in the local secrets file
|
|
# $WERKREVISOR_SECRETS_ENV, by default ~/.config/ai-coding-agents/secrets.env.
|
|
# check tells per repository whether the value has the SHA-256 <sha256>, by default that of the secrets file,
|
|
# and how many backups hold that value.
|
|
# set replaces the value everywhere, after a backup <file>.bak-secret-<KEY>-<time>,
|
|
# and adds it to the secrets file if that lacks it;
|
|
# it refuses if the host holds another old value than the secrets file, or more than one.
|
|
# It prints the SHA-256 of the old value for purge, then checks the new one.
|
|
# purge removes the backups that hold the value with the SHA-256 <sha256>; it refuses while a file holds it.
|
|
# fetch adds the value that all machine configs hold to a secrets file without KEY, e.g. before set,
|
|
# so that its backup holds the old value for the probe after the revocation.
|
|
# digest prints the SHA-256 of the value in the secrets file.
|
|
# probe asks the service whether it accepts the value of the secrets file, or with "-" that on stdin,
|
|
# without a review and without costs (see werkrevisor/secretprobe.py).
|
|
# set and probe ask for a value on stdin without showing it, if stdin is a terminal.
|
|
# It never prints a value and never passes one as an argument; it opens one connection per command.
|
|
# Werkator reads a changed machine config at its next poll, without a restart.
|
|
# Exit code: 0 if all went well, 1 if check found another value or the service refused it, 2 on an error.
|
|
#
|
|
# Example:
|
|
# tools/rotate-secret set mih09-werkator@mih09.hostsharing.net OPENROUTER_API_KEY < new-key
|
|
set -euo pipefail
|
|
|
|
usage() {
|
|
awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "$0"
|
|
exit "$1"
|
|
}
|
|
[[ "${1:-}" =~ ^(-h|-\?|--help)$ ]] && usage 0
|
|
tools=$(cd "$(dirname "$0")" && pwd)
|
|
secrets_env=${WERKREVISOR_SECRETS_ENV:-$HOME/.config/ai-coding-agents/secrets.env}
|
|
command=${1:-}
|
|
case "$command" in
|
|
check) [[ $# -eq 3 || $# -eq 4 ]] || usage 2 ;;
|
|
set) [[ $# -eq 3 ]] || usage 2 ;;
|
|
purge) [[ $# -eq 4 ]] || usage 2 ;;
|
|
fetch) [[ $# -eq 3 ]] || usage 2 ;;
|
|
digest) [[ $# -eq 2 ]] || usage 2 ;;
|
|
probe) [[ $# -eq 2 || ( $# -eq 3 && $3 == - ) ]] || usage 2 ;;
|
|
*) usage 2 ;;
|
|
esac
|
|
if [[ $command == digest || $command == probe ]]; then
|
|
key=$2
|
|
else
|
|
target=$2
|
|
key=$3
|
|
fi
|
|
[[ "$key" =~ ^[A-Z][A-Z0-9_]*$ ]] || { echo "no valid KEY: $key" >&2; exit 2; }
|
|
|
|
# The value lives only in variables and pipes; printf is a builtin, so no process lists it as an argument.
|
|
json() {
|
|
python3 -c 'import json, sys; print(json.dumps({sys.argv[1]: sys.stdin.read()}))' "$1"
|
|
}
|
|
|
|
# Writes the value of KEY in the secrets file, or its SHA-256 with "sha256"; only ever into a pipe or a variable.
|
|
# With "optional", a file without KEY writes nothing; a file it cannot read or parse is an error all the same.
|
|
from_secrets_file() {
|
|
python3 -c '
|
|
import sys
|
|
sys.path.insert(0, sys.argv[1])
|
|
import host_secret
|
|
try:
|
|
value = host_secret.value_of(sys.argv[3], sys.argv[2], False)
|
|
except host_secret.SecretError as e:
|
|
print(e, file=sys.stderr)
|
|
sys.exit(2)
|
|
if value is None:
|
|
if "optional" in sys.argv[4:]:
|
|
sys.exit(0)
|
|
print(f"{sys.argv[3]} does not set {sys.argv[2]}", file=sys.stderr)
|
|
sys.exit(2)
|
|
print(host_secret.sha256(value) if "sha256" in sys.argv[4:] else value, end="")' "$tools" "$key" "$secrets_env" "$@"
|
|
}
|
|
|
|
digest() {
|
|
from_secrets_file sha256 "$@"
|
|
}
|
|
|
|
# As in sync-host-config: the host refuses connections that follow too quickly.
|
|
retrying() {
|
|
local attempt
|
|
for attempt in 1 2 3 4 5 6; do
|
|
"$@" && return 0
|
|
local status=$?
|
|
[[ $status -eq 255 ]] || return $status
|
|
[[ $attempt -lt 6 ]] || break
|
|
echo "connection refused, retry in $((30 * attempt)) s" >&2
|
|
sleep $((30 * attempt))
|
|
done
|
|
# 1 would mean a differing or refused value, but no file was read.
|
|
echo "the host refused six connections" >&2
|
|
return 2
|
|
}
|
|
|
|
# Each side gets the payload on stdin; a function, so that a retry sends it again.
|
|
on_host() {
|
|
ssh -o BatchMode=yes "$target" \
|
|
"python3 -c $(printf '%q' "$(cat "$tools/host_secret.py")") $1 $key" <<< "$payload"
|
|
}
|
|
|
|
locally() {
|
|
[[ -f "$secrets_env" ]] || { echo "no $secrets_env" >&2; return 2; }
|
|
python3 "$tools/host_secret.py" "$1" "$key" "$secrets_env" <<< "$payload"
|
|
}
|
|
|
|
check() {
|
|
payload=$(printf %s "$1" | json sha256)
|
|
local host=0 here=0
|
|
retrying on_host check || host=$?
|
|
locally check || here=$?
|
|
return $((host > here ? host : here))
|
|
}
|
|
|
|
case "$command" in
|
|
digest)
|
|
digest
|
|
;;
|
|
check)
|
|
check "${4:-$(digest)}"
|
|
;;
|
|
fetch)
|
|
[[ -z "$(digest optional)" ]] || { echo "$(basename "$secrets_env") sets $key already" >&2; exit 2; }
|
|
payload="{}"
|
|
# The value goes from the host straight into the local set, through pipes only.
|
|
# A plain assignment, so that set -e stops on a failed export; before "&&" it would not.
|
|
payload=$(retrying on_host export)
|
|
locally set
|
|
unset payload
|
|
;;
|
|
purge)
|
|
[[ "$4" =~ ^[0-9a-f]{64}$ ]] || { echo "no SHA-256: $4" >&2; exit 2; }
|
|
payload=$(printf %s "$4" | json sha256)
|
|
retrying on_host purge
|
|
locally purge
|
|
;;
|
|
set)
|
|
if [[ -t 0 ]]; then
|
|
IFS= read -rs -p "new value of $key: " value
|
|
echo >&2
|
|
else
|
|
value=$(cat)
|
|
fi
|
|
# Before any change on the host: a secrets file that cannot be read would fail only after it.
|
|
old=$(digest optional)
|
|
new=$(printf %s "$value" | python3 -c 'import hashlib, sys; print(hashlib.sha256(sys.stdin.read().encode()).hexdigest())')
|
|
[[ "$old" != "$new" ]] || { echo "the secrets file holds this value already" >&2; exit 2; }
|
|
payload=$(printf %s "$value" | python3 -c '
|
|
import json, sys
|
|
print(json.dumps({"value": sys.stdin.read(), "old_sha256": sys.argv[1] or None}))' "$old")
|
|
unset value
|
|
host=$(retrying on_host set)
|
|
printf '%s\n' "$host"
|
|
old=${old:-$(sed -n 's/^old value: sha256 //p' <<< "$host")}
|
|
locally set | sed '/^old value: /d'
|
|
check "$new"
|
|
[[ -z "$old" ]] || echo "after the old value is revoked: tools/rotate-secret purge $target $key $old"
|
|
;;
|
|
probe)
|
|
cd "$tools/.."
|
|
if [[ $# -eq 3 && -t 0 ]]; then
|
|
IFS= read -rs -p "value of $key: " value
|
|
echo >&2
|
|
printf %s "$value" | python3 -m werkrevisor.secretprobe "$key"
|
|
elif [[ $# -eq 3 ]]; then
|
|
python3 -m werkrevisor.secretprobe "$key"
|
|
else
|
|
from_secrets_file | python3 -m werkrevisor.secretprobe "$key"
|
|
fi
|
|
;;
|
|
esac
|