diff --git a/.changeset/pdsadmin-wrapper.md b/.changeset/pdsadmin-wrapper.md new file mode 100644 index 00000000..26077012 --- /dev/null +++ b/.changeset/pdsadmin-wrapper.md @@ -0,0 +1,20 @@ +--- +'ePDS': patch +--- + +Add an ePDS-native `pdsadmin` CLI under `scripts/pdsadmin/`, so operators get the familiar Bluesky `pdsadmin` admin commands against an ePDS deployment. + +**Affects:** ePDS operators + +The upstream Bluesky `pdsadmin` script is not bundled with ePDS and assumes the `/pds` docker-compose layout (`/pds/pds.env`, a container named `pds`, systemd-based `update`), so running `./pdsadmin …` on an ePDS box fails with `command not found`. This adds a wrapper that talks to the PDS purely over its public XRPC API and reads ePDS's own env file. + +- `scripts/pdsadmin/pdsadmin.sh` dispatches `account {list,create,delete,takedown,untakedown,reset-password}`, `create-invite-code [useCount]`, `request-crawl [relay]`, and `help`. +- Reads `PDS_HOSTNAME` / `PDS_ADMIN_PASSWORD` from `PDS_ENV_FILE` (default `/opt/epds/.env`; override with `PDS_ENV_FILE=./.env` for local use). +- The upstream `update` subcommand is intentionally omitted — it pulls Bluesky's image and restarts via systemd, which would break ePDS's `docker compose build/up` flow. Update ePDS the normal way. +- Because it depends only on stable AT Protocol lexicon endpoints plus those two core env vars (not on any package code), it keeps working across future repo changes. + +Usage on the server: + +```bash +sudo /opt/epds/scripts/pdsadmin/pdsadmin.sh account list +``` diff --git a/scripts/pdsadmin/account.sh b/scripts/pdsadmin/account.sh new file mode 100755 index 00000000..3178b841 --- /dev/null +++ b/scripts/pdsadmin/account.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +set -o errexit +set -o nounset +set -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "${SCRIPT_DIR}/lib.sh" + +SUBCOMMAND="${1:-}" + +# +# account list +# +if [[ "${SUBCOMMAND}" == "list" ]]; then + # Optional limit: "account list 10" or "account list --limit 10" returns + # the N most recently created accounts. + LIMIT="" + if [[ "${2:-}" == "--limit" ]]; then + LIMIT="${3:-}" + elif [[ "${2:-}" =~ ^[0-9]+$ ]]; then + LIMIT="${2}" + fi + if [[ -n "${LIMIT}" && ! "${LIMIT}" =~ ^[0-9]+$ ]]; then + echo "ERROR: limit must be a positive integer." >/dev/stderr + echo "Usage: pdsadmin account list [--limit ]" >/dev/stderr + exit 1 + fi + + # Read the PDS account DB directly rather than via com.atproto.sync.listRepos. + # listRepos inner-joins repo_root and filters by account status, so it omits + # accounts with no repo yet or that are deactivated, and it exposes only an + # indexedAt (re-index time) — not creation time. The actor table has the + # authoritative createdAt for every account. + # + # The DB lives inside the pds-core container (WAL mode), so we query it there + # rather than copying the file out and risking missing un-checkpointed data. + PDS_CORE_CONTAINER="${PDS_CORE_CONTAINER:-epds-core}" + PDS_ACCOUNT_DB="${PDS_ACCOUNT_DB:-/data/account.sqlite}" + + SQL="SELECT actor.createdAt, actor.handle, account.email, actor.deactivatedAt, actor.did + FROM actor + LEFT JOIN account ON account.did = actor.did + ORDER BY actor.createdAt DESC" + if [[ -n "${LIMIT}" ]]; then + SQL="${SQL} LIMIT ${LIMIT}" + fi + SQL="${SQL};" + + # sqlite3 isn't in the node:alpine image by default; install it ephemerally + # if missing, then run a read-only query. Use a real tab as the column + # separator: we pass it through a $TAB env var (a literal tab from printf) + # because a "\t" inside the double-quoted sh -c would stay backslash-t. + TAB="$(printf '\t')" + ROWS="$(docker exec -e "TAB=${TAB}" "${PDS_CORE_CONTAINER}" sh -c " + command -v sqlite3 >/dev/null 2>&1 || apk add --no-cache sqlite >/dev/null 2>&1 + sqlite3 -readonly -noheader -separator \"\$TAB\" '${PDS_ACCOUNT_DB}' \"${SQL}\" + ")" + + # Format as an aligned table with awk (more portable than column --separator, + # whose tab handling differs across implementations). Two passes over the + # data held in a flat cell[] array (no gawk-only 2D arrays): substitute "-" + # for blank fields, measure column widths, then left-pad each cell. + printf 'Created\tHandle\tEmail\tDeactivated\tDID\n%s\n' "${ROWS}" \ + | awk -F'\t' ' + { nf[NR] = NF + for (i = 1; i <= NF; i++) { + v = ($i == "") ? "-" : $i + cell[NR SUBSEP i] = v + if (length(v) > w[i]) w[i] = length(v) + } + } + END { + for (r = 1; r <= NR; r++) { + line = "" + for (i = 1; i <= nf[r]; i++) { + c = cell[r SUBSEP i] + line = line c + if (i < nf[r]) { pad = w[i] - length(c) + 2; while (pad-- > 0) line = line " " } + } + print line + } + }' + +# +# account create +# +elif [[ "${SUBCOMMAND}" == "create" ]]; then + EMAIL="${2:-}" + HANDLE="${3:-}" + + if [[ "${EMAIL}" == "" ]]; then + read -r -p "Enter an email address (e.g. alice@${PDS_HOSTNAME}): " EMAIL + fi + if [[ "${HANDLE}" == "" ]]; then + read -r -p "Enter a handle (e.g. alice.${PDS_HOSTNAME}): " HANDLE + fi + + if [[ "${EMAIL}" == "" || "${HANDLE}" == "" ]]; then + echo "ERROR: missing EMAIL and/or HANDLE parameters." >/dev/stderr + echo "Usage: pdsadmin account ${SUBCOMMAND} " >/dev/stderr + exit 1 + fi + + PASSWORD="$(openssl rand -base64 30 | tr -d "=+/" | cut -c1-24)" + INVITE_CODE="$(curl_cmd_post \ + --user "admin:${PDS_ADMIN_PASSWORD}" \ + --data '{"useCount": 1}' \ + "https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createInviteCode" | jq --raw-output '.code' + )" + RESULT="$(curl_cmd_post_nofail \ + --data "{\"email\":\"${EMAIL}\", \"handle\":\"${HANDLE}\", \"password\":\"${PASSWORD}\", \"inviteCode\":\"${INVITE_CODE}\"}" \ + "https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createAccount" + )" + + DID="$(echo "${RESULT}" | jq --raw-output '.did')" + if [[ "${DID}" != did:* ]]; then + ERR="$(echo "${RESULT}" | jq --raw-output '.message')" + echo "ERROR: ${ERR}" >/dev/stderr + echo "Usage: pdsadmin account ${SUBCOMMAND} " >/dev/stderr + exit 1 + fi + + echo + echo "Account created successfully!" + echo "-----------------------------" + echo "Handle : ${HANDLE}" + echo "DID : ${DID}" + echo "Password : ${PASSWORD}" + echo "-----------------------------" + echo "Save this password, it will not be displayed again." + echo + +# +# account delete +# +elif [[ "${SUBCOMMAND}" == "delete" ]]; then + DID="${2:-}" + + if [[ "${DID}" == "" ]]; then + echo "ERROR: missing DID parameter." >/dev/stderr + echo "Usage: pdsadmin account ${SUBCOMMAND} " >/dev/stderr + exit 1 + fi + + if [[ "${DID}" != did:* ]]; then + echo "ERROR: DID parameter must start with \"did:\"." >/dev/stderr + echo "Usage: pdsadmin account ${SUBCOMMAND} " >/dev/stderr + exit 1 + fi + + echo "This action is permanent." + read -r -p "Are you sure you'd like to delete ${DID}? [y/N] " response + if [[ ! "${response}" =~ ^([yY][eE][sS]|[yY])$ ]]; then + exit 0 + fi + + curl_cmd_post \ + --user "admin:${PDS_ADMIN_PASSWORD}" \ + --data "{\"did\": \"${DID}\"}" \ + "https://${PDS_HOSTNAME}/xrpc/com.atproto.admin.deleteAccount" >/dev/null + + echo "${DID} deleted" + +# +# account takedown +# +elif [[ "${SUBCOMMAND}" == "takedown" ]]; then + DID="${2:-}" + TAKEDOWN_REF="$(date +%s)" + + if [[ "${DID}" == "" ]]; then + echo "ERROR: missing DID parameter." >/dev/stderr + echo "Usage: pdsadmin account ${SUBCOMMAND} " >/dev/stderr + exit 1 + fi + + if [[ "${DID}" != did:* ]]; then + echo "ERROR: DID parameter must start with \"did:\"." >/dev/stderr + echo "Usage: pdsadmin account ${SUBCOMMAND} " >/dev/stderr + exit 1 + fi + + PAYLOAD="$(cat </dev/null + + echo "${DID} taken down" + +# +# account untakedown +# +elif [[ "${SUBCOMMAND}" == "untakedown" ]]; then + DID="${2:-}" + + if [[ "${DID}" == "" ]]; then + echo "ERROR: missing DID parameter." >/dev/stderr + echo "Usage: pdsadmin account ${SUBCOMMAND} " >/dev/stderr + exit 1 + fi + + if [[ "${DID}" != did:* ]]; then + echo "ERROR: DID parameter must start with \"did:\"." >/dev/stderr + echo "Usage: pdsadmin account ${SUBCOMMAND} " >/dev/stderr + exit 1 + fi + + PAYLOAD="$(cat </dev/null + + echo "${DID} untaken down" + +# +# account reset-password +# +elif [[ "${SUBCOMMAND}" == "reset-password" ]]; then + DID="${2:-}" + PASSWORD="$(openssl rand -base64 30 | tr -d "=+/" | cut -c1-24)" + + if [[ "${DID}" == "" ]]; then + echo "ERROR: missing DID parameter." >/dev/stderr + echo "Usage: pdsadmin account ${SUBCOMMAND} " >/dev/stderr + exit 1 + fi + + if [[ "${DID}" != did:* ]]; then + echo "ERROR: DID parameter must start with \"did:\"." >/dev/stderr + echo "Usage: pdsadmin account ${SUBCOMMAND} " >/dev/stderr + exit 1 + fi + + curl_cmd_post \ + --user "admin:${PDS_ADMIN_PASSWORD}" \ + --data "{ \"did\": \"${DID}\", \"password\": \"${PASSWORD}\" }" \ + "https://${PDS_HOSTNAME}/xrpc/com.atproto.admin.updateAccountPassword" >/dev/null + + echo + echo "Password reset for ${DID}" + echo "New password: ${PASSWORD}" + echo + +else + echo "Unknown account subcommand: ${SUBCOMMAND:-(none)}" >/dev/stderr + echo "Valid: list, create, delete, takedown, untakedown, reset-password" >/dev/stderr + exit 1 +fi diff --git a/scripts/pdsadmin/create-invite-code.sh b/scripts/pdsadmin/create-invite-code.sh new file mode 100755 index 00000000..9b28792b --- /dev/null +++ b/scripts/pdsadmin/create-invite-code.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -o errexit +set -o nounset +set -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "${SCRIPT_DIR}/lib.sh" + +USE_COUNT="${1:-1}" + +curl_cmd_post \ + --user "admin:${PDS_ADMIN_PASSWORD}" \ + --data "{\"useCount\": ${USE_COUNT}}" \ + "https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createInviteCode" | jq --raw-output '.code' diff --git a/scripts/pdsadmin/help.sh b/scripts/pdsadmin/help.sh new file mode 100755 index 00000000..70554aa3 --- /dev/null +++ b/scripts/pdsadmin/help.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -o errexit +set -o nounset +set -o pipefail + +cat <] + List accounts (created-at, handle, email, deactivated, DID), + newest first. Reads the PDS account DB directly (via the pds-core + container), so it includes accounts that listRepos omits and sorts + by true creation time. Optional limit returns the N newest accounts. + Requires Docker access (run with sudo). Override the container name + with PDS_CORE_CONTAINER and DB path with PDS_ACCOUNT_DB if non-default. + e.g. pdsadmin account list + e.g. pdsadmin account list --limit 10 + create + Create a new account with a random password. + NOTE: bypasses ePDS OTP / community-DID provisioning. + e.g. pdsadmin account create alice@example.com alice.example.com + delete + Delete an account specified by DID. + e.g. pdsadmin account delete did:plc:xyz123abc456 + takedown + Takedown an account specified by DID. + e.g. pdsadmin account takedown did:plc:xyz123abc456 + untakedown + Remove a takedown from an account specified by DID. + e.g. pdsadmin account untakedown did:plc:xyz123abc456 + reset-password + Reset the password for an account specified by DID. + e.g. pdsadmin account reset-password did:plc:xyz123abc456 + +create-invite-code [] + Create a new invite code (default useCount 1). + e.g. pdsadmin create-invite-code + +request-crawl [] + Request a crawl from a relay host (defaults to PDS_CRAWLERS). + e.g. pdsadmin request-crawl bsky.network + +help + Display this help information. + +Config: reads PDS_HOSTNAME / PDS_ADMIN_PASSWORD from PDS_ENV_FILE +(default /opt/epds/.env). Override with PDS_ENV_FILE=./.env for local use. + +There is no 'update' command: update ePDS via its docker compose build/up +flow, not the upstream pdsadmin updater. +HELP diff --git a/scripts/pdsadmin/lib.sh b/scripts/pdsadmin/lib.sh new file mode 100755 index 00000000..871bbad7 --- /dev/null +++ b/scripts/pdsadmin/lib.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Shared helpers for the ePDS pdsadmin scripts. Sourced, not executed. + +# Load PDS_HOSTNAME / PDS_ADMIN_PASSWORD from the ePDS env file. +# Default path matches the dappnode deployment (/opt/epds/.env); override +# with PDS_ENV_FILE for local checkouts or other deployments. +PDS_ENV_FILE="${PDS_ENV_FILE:-/opt/epds/.env}" + +if [[ ! -f "${PDS_ENV_FILE}" ]]; then + echo "ERROR: env file not found: ${PDS_ENV_FILE}" >/dev/stderr + echo "Set PDS_ENV_FILE to your ePDS .env (e.g. PDS_ENV_FILE=./.env)." >/dev/stderr + exit 1 +fi + +# shellcheck disable=SC1090 +set -o allexport +source "${PDS_ENV_FILE}" +set +o allexport + +if [[ -z "${PDS_HOSTNAME:-}" || -z "${PDS_ADMIN_PASSWORD:-}" ]]; then + echo "ERROR: PDS_HOSTNAME and PDS_ADMIN_PASSWORD must be set in ${PDS_ENV_FILE}." >/dev/stderr + exit 1 +fi + +for bin in curl jq; do + if ! command -v "${bin}" >/dev/null 2>&1; then + echo "ERROR: '${bin}' is required but not installed." >/dev/stderr + exit 1 + fi +done + +# curl a URL and fail if the request fails. +function curl_cmd_get { + curl --fail --silent --show-error "$@" +} + +# POST and fail if the request fails. +function curl_cmd_post { + curl --fail --silent --show-error --request POST --header "Content-Type: application/json" "$@" +} + +# POST but do not fail on non-2xx (so we can read the error body). +function curl_cmd_post_nofail { + curl --silent --show-error --request POST --header "Content-Type: application/json" "$@" +} diff --git a/scripts/pdsadmin/pdsadmin.sh b/scripts/pdsadmin/pdsadmin.sh new file mode 100755 index 00000000..fb1b5fd5 --- /dev/null +++ b/scripts/pdsadmin/pdsadmin.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -o errexit +set -o nounset +set -o pipefail + +# ePDS-native pdsadmin dispatcher. +# +# Unlike the upstream Bluesky pdsadmin (which assumes the /pds docker-compose +# install and a /pds/pds.env file), this reads ePDS's .env and talks to the +# PDS purely over its public XRPC API. It therefore keeps working across repo +# changes: it depends only on PDS_HOSTNAME and PDS_ADMIN_PASSWORD plus stable +# AT Protocol lexicon endpoints, not on any code in this repo. +# +# The upstream "update" subcommand is intentionally NOT included: it pulls +# Bluesky's image and restarts via systemd, which would break ePDS's custom +# docker compose build/up flow. Update ePDS the normal way instead. +# +# Config: reads PDS_ENV_FILE (default /opt/epds/.env). Override for local/dev: +# PDS_ENV_FILE=./.env ./scripts/pdsadmin/pdsadmin.sh account list + +# Resolve symlinks so a symlink in e.g. /usr/local/bin/pdsadmin still finds +# its sibling scripts in the real scripts/pdsadmin/ directory. +SOURCE="${BASH_SOURCE[0]}" +while [[ -h "${SOURCE}" ]]; do + DIR="$(cd -P "$(dirname "${SOURCE}")" && pwd)" + SOURCE="$(readlink "${SOURCE}")" + [[ "${SOURCE}" != /* ]] && SOURCE="${DIR}/${SOURCE}" +done +SCRIPT_DIR="$(cd -P "$(dirname "${SOURCE}")" && pwd)" + +COMMAND="${1:-help}" +shift || true + +case "${COMMAND}" in + account) + exec "${SCRIPT_DIR}/account.sh" "$@" + ;; + create-invite-code) + exec "${SCRIPT_DIR}/create-invite-code.sh" "$@" + ;; + request-crawl) + exec "${SCRIPT_DIR}/request-crawl.sh" "$@" + ;; + help | -h | --help) + exec "${SCRIPT_DIR}/help.sh" + ;; + *) + echo "Unknown command: ${COMMAND}" >/dev/stderr + echo >/dev/stderr + exec "${SCRIPT_DIR}/help.sh" + ;; +esac diff --git a/scripts/pdsadmin/request-crawl.sh b/scripts/pdsadmin/request-crawl.sh new file mode 100755 index 00000000..3dc34c53 --- /dev/null +++ b/scripts/pdsadmin/request-crawl.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -o errexit +set -o nounset +set -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "${SCRIPT_DIR}/lib.sh" + +RELAY_HOSTS="${1:-}" +if [[ "${RELAY_HOSTS}" == "" ]]; then + RELAY_HOSTS="${PDS_CRAWLERS:-}" +fi + +if [[ "${RELAY_HOSTS}" == "" ]]; then + echo "ERROR: missing RELAY HOST parameter (and PDS_CRAWLERS is unset)." >/dev/stderr + echo "Usage: pdsadmin request-crawl [,,...]" >/dev/stderr + exit 1 +fi + +for host in ${RELAY_HOSTS//,/ }; do + echo "Requesting crawl from ${host}" + if [[ "${host}" != https:* && "${host}" != http:* ]]; then + host="https://${host}" + fi + curl_cmd_post \ + --data "{\"hostname\": \"${PDS_HOSTNAME}\"}" \ + "${host}/xrpc/com.atproto.sync.requestCrawl" >/dev/null +done + +echo "done"