#!/usr/bin/env bash # Keep an account off vibescrobble.com, or let it back on. # # scripts/denylist.sh list # scripts/denylist.sh add [why] # scripts/denylist.sh remove # # An entry starting `did:` is one account, or one person, who is then # nobody's operator. Anything else is a domain: every handle at or under it, # every did:web whose host is, and every account whose document names a PDS # whose host is. Entries are compared as the index compares them, so case and # a domain's trailing dot don't matter. `why` is kept beside the entry as a # comment. # # The list is one object in the artifacts bucket, which this edits and nothing # deploys. The instance writes it where the index reads it within thirty # seconds, and the index reads it again every five, so an account added here # leaves the board within the minute, with everything it said. An edit then # waits, up to DENYLIST_WAIT seconds (90), for the board's /health to count # the entries it wrote, and fails with status 4 if it never does. An edit is # written only over the version it read, so two operators editing at once # both land: the later one reads again and retries. Credentials are the # caller's, as for scripts/deploy.sh, and have to be the did.bot account's. set -euo pipefail account=226086920337 profile=jmm-did-bot-admin region=us-east-1 bucket="${DENYLIST_BUCKET:-vibescrobble-artifacts-$account}" key="${DENYLIST_KEY:-moderation/denylist.txt}" health="${DENYLIST_HEALTH:-https://vibescrobble.com/health}" wait="${DENYLIST_WAIT:-90}" attempts=5 usage() { echo "usage: scripts/denylist.sh list | add [why] | remove " } for tool in aws curl jq; do command -v "$tool" >/dev/null || { echo "denylist: needs $tool" >&2 exit 1 } done caller="$(aws sts get-caller-identity --query Account --output text 2>/dev/null)" || { echo "denylist: no working AWS credentials; aws sso login --profile $profile" >&2 exit 1 } if [ "$caller" != "$account" ]; then echo "denylist: signed in to account $caller, not $account (did.bot); AWS_PROFILE=$profile" >&2 exit 1 fi work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT # Reads the list into $work/list, and the version read into $etag. read_list() { etag="$(aws s3api get-object --region "$region" --bucket "$bucket" --key "$key" \ "$work/list" --query ETag --output text)" # The last line ends in a newline, so an entry appended to it is a line. sed -i -e '$a\' "$work/list" } # Writes $work/next over the list, if the list is still the version read. # Status 3 means somebody else wrote it first. write_list() { local err if err="$(aws s3api put-object --region "$region" --bucket "$bucket" --key "$key" \ --body "$work/next" --content-type 'text/plain; charset=utf-8' \ --if-match "$etag" 2>&1 >/dev/null)"; then return 0 fi case "$err" in *PreconditionFailed* | *ConditionalRequestConflict*) return 3 ;; esac printf '%s\n' "$err" >&2 return 1 } # An entry as the index compares it: a did:plc and a domain in lowercase, a # domain without dots at either end, and a did:web with its host in lowercase # and its escaped port as written. Any line's entry, its comment dropped. canon=' function canon(e, rest, n) { sub(/#.*/, "", e) gsub(/^[[:space:]]+|[[:space:]]+$/, "", e) if (e ~ /^did:plc:/) return tolower(e) if (e ~ /^did:web:/) { rest = substr(e, 9) n = match(rest, /[%:]/) if (n == 0) return "did:web:" tolower(rest) return "did:web:" tolower(substr(rest, 1, n - 1)) substr(rest, n) } if (e ~ /^did:/) return e e = tolower(e) sub(/^\.+/, "", e) sub(/\.+$/, "", e) return e }' canonical() { printf '%s\n' "$1" | awk "$canon"' { print canon($0) }' } # Whether a canonical entry is a did:plc, a did:web or a domain. A did:plc is # 24 base32 characters, so a typo'd one is refused rather than matching # nothing. valid() { case "$1" in did:plc:*) [[ "$1" =~ ^did:plc:[a-z2-7]{24}$ ]] ;; did:web:*) [[ "$1" =~ ^did:web:[a-z0-9-]+(\.[a-z0-9-]+)*(%3A[0-9]+)?$ ]] ;; did:*) return 1 ;; *) [[ "$1" =~ ^[a-z0-9-]+(\.[a-z0-9-]+)+$ ]] ;; esac } # Whether $work/list holds the canonical entry $1. holds() { awk -v want="$1" "$canon"' canon($0) == want { found = 1 } END { exit !found }' "$work/list" } # Waits for the board to count the entries in $work/next, as the index # counts them. confirm() { local want have="" waited=0 want="$(awk "$canon"' { print canon($0) }' "$work/next" | sort -u | grep -c . || true)" while :; do have="$(curl -fsS --max-time 10 "$health" 2>/dev/null | jq -r '.moderation.denylist // empty' 2>/dev/null || true)" if [ "$have" = "$want" ]; then echo "denylist: in force; $health says denylist=$want" return 0 fi if [ "$waited" -ge "$wait" ]; then break fi sleep 5 waited=$((waited + 5)) done echo "denylist: written, but $health says denylist=${have:-nothing}, not $want, after ${wait}s;" \ "check the vibescrobble-denylist unit on the instance (journalctl -u vibescrobble-denylist)" >&2 return 4 } command="${1:-}" case "$command" in list) read_list cat "$work/list" exit 0 ;; add) given="${2:-}" why="${3:-}" entry="$(canonical "$given")" if [ -z "$entry" ] || ! valid "$entry"; then echo "denylist: \`$given\` is not a did:plc, a did:web or a domain" >&2 exit 2 fi line="$entry" if [ -n "$why" ]; then line="$entry # ${why//$'\n'/ }" fi for _ in $(seq "$attempts"); do read_list if holds "$entry"; then echo "denylist: $entry is already on it" exit 0 fi { cat "$work/list"; printf '%s\n' "$line"; } >"$work/next" status=0 write_list || status=$? case "$status" in 0) echo "denylist: added $entry; off the board within the minute" confirm exit ;; 3) ;; *) exit 1 ;; esac done ;; remove) entry="$(canonical "${2:-}")" if [ -z "$entry" ]; then usage >&2 exit 2 fi for _ in $(seq "$attempts"); do read_list awk -v want="$entry" "$canon"' canon($0) != want' "$work/list" >"$work/next" if cmp -s "$work/list" "$work/next"; then echo "denylist: $entry is not on it" >&2 exit 1 fi status=0 write_list || status=$? case "$status" in 0) echo "denylist: removed $entry; it is drawn again the next time it writes" confirm exit ;; 3) ;; *) exit 1 ;; esac done ;; -h | --help) usage exit 0 ;; *) usage >&2 exit 2 ;; esac echo "denylist: the list changed under every one of $attempts tries; nothing written" >&2 exit 1