Last active
February 16, 2026 00:34
-
-
Save fxstein/50ab414add783f4a67afa78d92ca5b47 to your computer and use it in GitHub Desktop.
pion-remote: Remote secret rotation CLI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # pion-remote β Remote infrastructure CLI for pionizer.ai | |
| # Manage your Pi instance from any machine over SSH + 1Password | |
| # | |
| # Install (requires gh CLI authenticated to pionizer org): | |
| # gh api repos/pionizer/pi-infra/contents/scripts/pion-remote.sh --jq .content | base64 -d \ | |
| # | sudo tee /usr/local/bin/pion-remote > /dev/null && sudo chmod +x /usr/local/bin/pion-remote | |
| # shellcheck disable=SC2310,SC2029,SC2015,SC2312 # Functions in conditions and SSH command expansions are intentional | |
| # | |
| # First run: | |
| # pion-remote setup | |
| # | |
| # Requires: bash 4+, op (1Password CLI), ssh, curl | |
| # Works on: macOS, Linux, WSL, Chromebook (Crostini), Raspberry Pi | |
| { | |
| set -euo pipefail | |
| VERSION="1.0.0" | |
| # --- Config --- | |
| CONFIG_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/pion" | |
| CONFIG_FILE="${CONFIG_DIR}/remote.env" | |
| # shellcheck source=/dev/null # user config | |
| [[ -f "${CONFIG_FILE}" ]] && source "${CONFIG_FILE}" | |
| SSH_HOST="${PION_SSH_HOST:-}" | |
| INSTANCE="${PION_INSTANCE:?PION_INSTANCE not set β run setup.sh or set in ~/.zshenv}" | |
| OP_VAULT="${PION_OP_VAULT:-pi-${INSTANCE}}" | |
| REGISTRY_URL="${PION_REGISTRY_URL:-https://raw.githubusercontent.com/pionizer/pi-infra/main/secrets-registry.yml}" | |
| # --- Colors (disabled if not a terminal) --- | |
| if [[ -t 1 ]]; then | |
| RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' | |
| BLUE='\033[0;34m' BOLD='\033[1m' NC='\033[0m' | |
| else | |
| RED='' GREEN='' YELLOW='' BLUE='' BOLD='' NC='' | |
| fi | |
| ok() { echo -e "${GREEN}β ${NC} ${1}"; } | |
| warn() { echo -e "${YELLOW}β οΈ${NC} ${1}"; } | |
| fail() { echo -e "${RED}β${NC} ${1}" >&2; } | |
| info() { echo -e "${BLUE}βΉοΈ${NC} ${1}"; } | |
| die() { fail "${1}"; exit 1; } | |
| # --- Dependency checks --- | |
| _check_dep() { | |
| local cmd="${1}" install_hint="${2}" | |
| if ! command -v "${cmd}" &>/dev/null; then | |
| fail "${cmd} not found" | |
| echo " Install: ${install_hint}" >&2 | |
| return 1 | |
| fi | |
| } | |
| _check_deps() { | |
| local missing=0 | |
| _check_dep "ssh" "included with your OS (OpenSSH)" || ((missing++)) | |
| _check_dep "op" "https://developer.1password.com/docs/cli/get-started/" || ((missing++)) | |
| _check_dep "curl" "apt install curl / brew install curl" || ((missing++)) | |
| _check_dep "yq" "brew install yq / pip install yq / snap install yq" || ((missing++)) | |
| [[ ${missing} -eq 0 ]] || die "Missing ${missing} required tool(s). Install them and retry." | |
| } | |
| _check_ssh_host() { | |
| if [[ -z "${SSH_HOST}" ]]; then | |
| die "SSH host not configured. Run: pion-remote setup" | |
| fi | |
| } | |
| _check_op_auth() { | |
| if ! op account list &>/dev/null 2>&1; then | |
| die "1Password CLI not authenticated. Run: op signin" | |
| fi | |
| } | |
| # --- Registry lookup (fetched from GitHub, cached per invocation) --- | |
| _REGISTRY_CACHE="" | |
| _reg() { | |
| local name="${1}" field="${2}" | |
| if [[ -z "${_REGISTRY_CACHE}" ]]; then | |
| # Try gh api first (works for private repos), fall back to curl (public) | |
| if command -v gh &>/dev/null; then | |
| _REGISTRY_CACHE=$(gh api repos/pionizer/pi-infra/contents/secrets-registry.yml --jq .content 2>/dev/null | base64 -d 2>/dev/null) | |
| fi | |
| if [[ -z "${_REGISTRY_CACHE}" ]]; then | |
| _REGISTRY_CACHE=$(curl -fsSL "${REGISTRY_URL}" 2>/dev/null) || die "Cannot fetch secrets registry β ensure 'gh' is authenticated or registry URL is accessible" | |
| fi | |
| fi | |
| # Compatible with both Go yq (mikefarah) and Python yq (kislyuk) | |
| local val | |
| val=$(echo "${_REGISTRY_CACHE}" | yq -r ".secrets.${name}.${field}" 2>/dev/null) || val="" | |
| [[ "${val}" == "null" ]] && val="" | |
| echo "${val}" | |
| } | |
| # --- Commands --- | |
| cmd_rotate() { | |
| local name="${1:-}" | |
| [[ -n "${name}" ]] || die "Usage: pion-remote rotate <secret-name>" | |
| _check_deps | |
| _check_ssh_host | |
| _check_op_auth | |
| local title | |
| title=$(_reg "${name}" "title") | |
| local rotation_url | |
| rotation_url=$(_reg "${name}" "rotation_url") | |
| local rotation_mode | |
| rotation_mode=$(_reg "${name}" "rotation_mode") | |
| local type | |
| type=$(_reg "${name}" "type") | |
| local provider_key_name | |
| provider_key_name=$(_reg "${name}" "provider_key_name") | |
| local notes | |
| notes=$(_reg "${name}" "notes") | |
| [[ -n "${title}" ]] || die "Secret '${name}' not found in registry" | |
| echo "" | |
| echo -e "${BOLD}π Remote Secret Rotation: ${name}${NC}" | |
| echo -e " 1Password item: ${title}" | |
| echo -e " SSH host: ${SSH_HOST}" | |
| [[ -n "${notes}" ]] && echo -e " ${YELLOW}${notes}${NC}" | |
| echo "" | |
| # --- Phase 1: Provider + 1Password (local, with biometrics) --- | |
| local new_value="" file_path="" | |
| if [[ "${rotation_url}" == "self-generated" ]]; then | |
| if [[ "${name}" == "openclaw_gateway_token" ]]; then | |
| new_value=$(openssl rand -hex 24) | |
| info "Generated new gateway token" | |
| else | |
| die "Don't know how to self-generate '${name}'" | |
| fi | |
| else | |
| if [[ "${rotation_mode}" == "reissue" ]]; then | |
| echo -e " ${YELLOW}This key must be REISSUED (cannot update in place).${NC}" | |
| echo "" | |
| echo -e " ${BOLD}URL:${NC} ${GREEN}${rotation_url}${NC}" | |
| echo "" | |
| [[ -n "${provider_key_name}" ]] && echo -e " ${BOLD}Key name:${NC} ${GREEN}${provider_key_name}${NC}" | |
| echo "" | |
| echo " Steps:" | |
| echo " 1. Open the URL above" | |
| echo " 2. Revoke/delete the old key" | |
| echo " 3. Create a new key with the name above" | |
| echo " 4. Paste the new value below" | |
| else | |
| echo -e " ${BOLD}URL:${NC} ${GREEN}${rotation_url}${NC}" | |
| echo "" | |
| echo " Steps:" | |
| echo " 1. Open the URL above" | |
| echo " 2. Rotate or regenerate the key" | |
| echo " 3. Paste the new value below" | |
| fi | |
| echo "" | |
| if [[ "${type}" == "file" ]]; then | |
| echo -n " Path to new credential file: " | |
| read -r file_path | |
| [[ -f "${file_path}" ]] || die "File not found: ${file_path}" | |
| new_value=$(cat "${file_path}") | |
| else | |
| echo -n " New secret value: " | |
| read -rs new_value | |
| echo "" | |
| fi | |
| fi | |
| [[ -n "${new_value}" ]] || die "No value provided" | |
| # Expiration | |
| echo "" | |
| echo -n " Expiration date (YYYY-MM-DD or 'never') [never]: " | |
| read -r expires_input | |
| local expires="${expires_input:-never}" | |
| # Update 1Password (local β uses biometrics) | |
| info "Updating 1Password: ${title}..." | |
| local full_notes | |
| full_notes="${notes} | |
| Rotation URL: ${rotation_url} | |
| Rotated: $(date -u +%Y-%m-%dT%H:%M:%SZ) via pion-remote rotate | |
| Expires: ${expires}" | |
| op item edit "${title}" \ | |
| --vault="${OP_VAULT}" \ | |
| "credential[password]=${new_value}" \ | |
| "notesPlain=${full_notes}" \ | |
| "expires[text]=${expires}" 2>&1 | grep -v "^$" || die "Failed to update 1Password" | |
| ok "1Password updated" | |
| # Clean up source file if used | |
| [[ -n "${file_path}" && -f "${file_path}" ]] && rm -f "${file_path}" && ok "Deleted source file" | |
| # --- Phase 2: Deploy + Verify (remote, via SSH) --- | |
| echo "" | |
| info "Deploying to ${SSH_HOST} via SSH..." | |
| echo "" | |
| ssh "${SSH_HOST}" "pion secret reload ${name}" | |
| local exit_code | |
| exit_code=$? | |
| echo "" | |
| if [[ ${exit_code} -eq 0 ]]; then | |
| ok "Remote rotation complete for ${BOLD}${name}${NC} β verified end-to-end" | |
| else | |
| die "Remote deployment failed (exit ${exit_code}) β check server logs" | |
| fi | |
| } | |
| cmd_verify() { | |
| local name="${1:-}" | |
| _check_dep "ssh" "included with your OS" || exit 1 | |
| _check_ssh_host | |
| info "Running remote verification on ${SSH_HOST}..." | |
| if [[ -n "${name}" ]]; then | |
| ssh "${SSH_HOST}" "pion secret verify ${name}" | |
| else | |
| ssh "${SSH_HOST}" "pion secret verify" | |
| fi | |
| } | |
| cmd_status() { | |
| _check_dep "ssh" "included with your OS" || exit 1 | |
| _check_ssh_host | |
| info "Checking remote system on ${SSH_HOST}..." | |
| ssh "${SSH_HOST}" "pion status" | |
| } | |
| cmd_setup() { | |
| echo "" | |
| echo -e "${BOLD}π§ pion-remote setup${NC}" | |
| echo "" | |
| # Check and install dependencies | |
| echo "Checking dependencies..." | |
| local missing=() | |
| for cmd_info in "ssh:included with your OS:" "op:https://developer.1password.com/docs/cli/get-started/:brew install --cask 1password-cli" "curl:apt install curl / brew install curl:" "yq:brew install yq:brew install yq"; do | |
| local cmd="${cmd_info%%:*}" | |
| local rest="${cmd_info#*:}" | |
| local hint="${rest%%:*}" | |
| local brew_cmd="${rest#*:}" | |
| if command -v "${cmd}" &>/dev/null; then | |
| ok "${cmd}" | |
| else | |
| fail "${cmd} β ${hint}" | |
| # Offer to install via brew if available | |
| if [[ -n "${brew_cmd}" ]] && command -v brew &>/dev/null; then | |
| echo -n " Install ${cmd} now? [Y/n] " | |
| read -r yn | |
| if [[ "${yn:-Y}" =~ ^[Yy]$ ]]; then | |
| ${brew_cmd} && ok "${cmd} installed" || { fail "${cmd} install failed"; missing+=("${cmd}"); } | |
| else | |
| missing+=("${cmd}") | |
| fi | |
| else | |
| missing+=("${cmd}") | |
| fi | |
| fi | |
| done | |
| if [[ ${#missing[@]} -gt 0 ]]; then | |
| echo "" | |
| die "Missing required tools: ${missing[*]}. Install them and re-run: pion-remote setup" | |
| fi | |
| # Create/update config | |
| mkdir -p "${CONFIG_DIR}" | |
| local current_host="${PION_SSH_HOST:-}" | |
| echo "" | |
| echo -n "SSH host for your Pi server [${current_host:-e.g. o-gluon}]: " | |
| read -r input_host | |
| local ssh_host="${input_host:-${current_host}}" | |
| if [[ -z "${ssh_host}" ]]; then | |
| die "SSH host is required. Configure it in ~/.ssh/config first." | |
| fi | |
| # Test SSH connection β try bare name, then .pionizer.ai suffix | |
| echo "" | |
| info "Testing SSH connection to ${ssh_host}..." | |
| if ssh -o ConnectTimeout=10 "${ssh_host}" "echo ok" &>/dev/null; then | |
| ok "SSH connection to ${ssh_host} works" | |
| elif [[ "${ssh_host}" != *.* ]]; then | |
| # Bare hostname β try with .pionizer.ai suffix | |
| local fqdn="${ssh_host}.pionizer.ai" | |
| info "Bare name failed, trying ${fqdn}..." | |
| if ssh -o ConnectTimeout=10 "${fqdn}" "echo ok" &>/dev/null; then | |
| ok "SSH connection to ${fqdn} works" | |
| ssh_host="${fqdn}" | |
| else | |
| warn "SSH connection failed for both ${input_host} and ${fqdn}" | |
| echo -n " Save ${ssh_host} anyway and fix SSH config later? [Y/n] " | |
| read -r yn | |
| [[ "${yn:-Y}" =~ ^[Yy]$ ]] || die "Setup cancelled. Configure SSH and re-run: pion-remote setup" | |
| fi | |
| else | |
| warn "SSH connection to ${ssh_host} failed" | |
| echo -n " Save anyway and fix SSH config later? [Y/n] " | |
| read -r yn | |
| [[ "${yn:-Y}" =~ ^[Yy]$ ]] || die "Setup cancelled. Configure SSH and re-run: pion-remote setup" | |
| fi | |
| cat > "${CONFIG_FILE}" <<EOF | |
| # pion-remote configuration | |
| # Generated: $(date -u +%Y-%m-%dT%H:%M:%SZ) | |
| # SSH host for Pi server (must be configured in ~/.ssh/config) | |
| PION_SSH_HOST=${ssh_host} | |
| # 1Password vault name | |
| PION_OP_VAULT=${PION_OP_VAULT:-pi-${PION_INSTANCE}} | |
| # Secrets registry URL | |
| PION_REGISTRY_URL=${PION_REGISTRY_URL:-https://raw.githubusercontent.com/pionizer/pi-infra/main/secrets-registry.yml} | |
| EOF | |
| ok "Config saved: ${CONFIG_FILE}" | |
| echo "" | |
| echo "You're all set. Try: pion-remote status" | |
| } | |
| cmd_update() { | |
| info "Updating pion-remote..." | |
| local tmp | |
| tmp=$(mktemp) | |
| # Primary: gh api (private repo, always fresh) | |
| # Fallback: gist (public, may be CDN-cached) | |
| if command -v gh &>/dev/null && gh api repos/pionizer/pi-infra/contents/scripts/pion-remote.sh --jq .content 2>/dev/null | base64 -d > "${tmp}" 2>/dev/null && [[ -s "${tmp}" ]]; then | |
| info "Updated from pi-infra repo" | |
| else | |
| curl -fsSL "https://gist.githubusercontent.com/fxstein/50ab414add783f4a67afa78d92ca5b47/raw/pion-remote.sh" -o "${tmp}" || die "Download failed" | |
| info "Updated from gist (may be cached β if stale, run: gh api repos/pionizer/pi-infra/contents/scripts/pion-remote.sh --jq .content | base64 -d | sudo tee /usr/local/bin/pion-remote > /dev/null)" | |
| fi | |
| sudo cp "${tmp}" /usr/local/bin/pion-remote | |
| sudo chmod +x /usr/local/bin/pion-remote | |
| rm -f "${tmp}" | |
| ok "Updated to latest version" | |
| } | |
| # --- Main --- | |
| case "${1:-}" in | |
| rotate) | |
| shift; cmd_rotate "$@" ;; | |
| verify) | |
| shift; cmd_verify "$@" ;; | |
| status) | |
| cmd_status ;; | |
| setup) | |
| cmd_setup ;; | |
| update) | |
| cmd_update ;; | |
| version|--version|-v) | |
| echo "pion-remote ${VERSION}" ;; | |
| help|--help|-h) | |
| echo "pion-remote ${VERSION} β Remote infrastructure CLI for pionizer.ai" | |
| echo "" | |
| echo -e "${BOLD}COMMANDS${NC}" | |
| echo " rotate <name> Rotate a secret end-to-end" | |
| echo " Phase 1: Update provider + 1Password (local, biometrics)" | |
| echo " Phase 2: Deploy + verify on server (remote, via SSH)" | |
| echo " verify [<name>] Verify secret chain integrity on server" | |
| echo " status Check server health" | |
| echo " setup Configure pion-remote (SSH host, dependencies)" | |
| echo " update Self-update to latest version from GitHub" | |
| echo " version Show version" | |
| echo " help Show this help" | |
| echo "" | |
| echo -e "${BOLD}HOW IT WORKS${NC}" | |
| echo " Secrets never travel over SSH. The flow is:" | |
| echo " You (local) β 1Password Cloud β Server (via service account)" | |
| echo "" | |
| echo " Your machine handles writes (1Password, biometrics)." | |
| echo " The server handles reads (chezmoi, deploy, verify, liveness)." | |
| echo "" | |
| echo -e "${BOLD}FIRST TIME SETUP${NC}" | |
| echo " 1. Install (requires gh CLI):" | |
| echo " gh api repos/pionizer/pi-infra/contents/scripts/pion-remote.sh --jq .content | base64 -d \\" | |
| echo " | sudo tee /usr/local/bin/pion-remote > /dev/null && sudo chmod +x /usr/local/bin/pion-remote" | |
| echo " 2. Configure: pion-remote setup" | |
| echo " 3. Use: pion-remote rotate <secret-name>" | |
| echo "" | |
| echo -e "${BOLD}REQUIREMENTS${NC}" | |
| echo " bash 4+, op (1Password CLI), ssh, curl, yq" | |
| echo "" | |
| echo -e "${BOLD}CONFIG${NC}" | |
| echo " ${CONFIG_FILE}" | |
| if [[ -f "${CONFIG_FILE}" ]]; then | |
| echo " SSH host: ${PION_SSH_HOST:-not set}" | |
| echo " Vault: ${PION_OP_VAULT:-pi-${PION_INSTANCE}}" | |
| else | |
| echo " (not configured β run: pion-remote setup)" | |
| fi | |
| ;; | |
| "") | |
| echo "pion-remote ${VERSION} β run 'pion-remote help' for usage" | |
| exit 1 | |
| ;; | |
| *) | |
| die "Unknown command: ${1} β run 'pion-remote help' for usage" | |
| ;; | |
| esac | |
| exit | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment