From 36190c965ae4ae8e9a3a9d416570edb10de22a3d Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Fri, 22 May 2026 18:15:25 -0400 Subject: [PATCH] Add Gitea SSH setup script and integrate Gitea into partnership/failover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partnership/gitea_ssh_setup.sh: generates ed25519 keypair, registers public key with Gitea API (POST /api/v1/user/keys), tests SSH connection, writes ~/.ssh/config entry. Idempotent — skips steps already done. --force regenerates and re-registers. Resolves Gitea endpoint from container IP first, falls back to GITEA_DOMAIN. common.sh: alias HOST*_GITEA_API_TOKEN → GITEA_API_TOKEN in detect_hosts(). Add to detect_hosts() doc comment. master.conf: add GITEA_HTTP_PORT=3000 for API endpoint construction. host1.conf: add HOST1_GITEA_API_TOKEN (fill in from Gitea Settings → Applications). Add my-Gitea.xml to HOST1_PARTNERSHIP_AUTH_STACK — onboard pushes it to HOST2. host2.conf: add Gitea to FALLBACK_HOST2_COVERS_HOST1_TIER1 — starts immediately when HOST1 goes down, making the source of truth reachable independently of HOST1's auth stack. --- Partnership/gitea_ssh_setup.sh | 469 +++++++++++++++++++++++++++++++++ common.sh | 4 + host1.conf | 8 + host2.conf | 1 + master.conf | 1 + 5 files changed, 483 insertions(+) create mode 100755 Partnership/gitea_ssh_setup.sh diff --git a/Partnership/gitea_ssh_setup.sh b/Partnership/gitea_ssh_setup.sh new file mode 100755 index 0000000..32789ea --- /dev/null +++ b/Partnership/gitea_ssh_setup.sh @@ -0,0 +1,469 @@ +#!/bin/bash +# ============================================================================================== +# ============================= Gitea SSH Setup ================================================ +# ============================================================================================== +# +# PURPOSE +# ───────────────────────────────────────────────────────────────────────────── +# Generates an SSH keypair for Gitea authentication and registers the public +# key with Gitea via the API. Run after the Gitea container is up — typically +# called during onboard setup or when re-keying a server. +# +# Idempotent: skips key generation if key already exists and skips registration +# if the same public key is already registered in Gitea. Use --force to +# regenerate and re-register. +# +# ============================================================================================== +# OPERATIONAL MODEL +# ============================================================================================== +# +# 1. Resolve Gitea API endpoint +# Container running locally → use container IP + GITEA_HTTP_PORT +# Container not local → fall back to GITEA_DOMAIN +# +# 2. Validate GITEA_API_TOKEN is set +# Token needed for API calls — create once in Gitea UI (see CONFIGURATION) +# +# 3. Generate SSH keypair (ed25519) at GITEA_SSH_KEY +# Skipped if key already exists — use --force to regenerate +# +# 4. Check if public key already registered in Gitea +# Compares fingerprints — skips POST if already registered +# +# 5. Register public key via POST /api/v1/user/keys +# Title: "unraid-{host_id}" (e.g. unraid-host1) +# +# 6. Test SSH connection to Gitea +# ssh -i GITEA_SSH_KEY -p SSH_PORT git@ +# Success confirms key is accepted and auth works end-to-end +# +# 7. Write ~/.ssh/config Host entry for gitea +# Skipped if entry already exists — use --force to overwrite +# +# ============================================================================================== +# OPERATIONAL SAFEGUARDS +# ============================================================================================== +# +# Root Required +# ssh-keygen and ~/.ssh/ operations require consistent permissions. +# +# Single Instance Lock +# acquire_lock prevents concurrent runs. +# +# Token Validated Before Any API Call +# If HOST*_GITEA_API_TOKEN is empty, exits with a clear message pointing to +# Gitea Settings → Applications → Generate Token (scope: write:user). +# +# Key Never Clobbered Without --force +# Existing key at GITEA_SSH_KEY is left untouched unless --force is passed. +# Prevents accidentally invalidating working auth. +# +# Duplicate Registration Safe +# Checks existing keys before POST — will not create duplicate entries in Gitea. +# +# ============================================================================================== +# CONFIGURATION +# ============================================================================================== +# +# host*.conf +# +# HOST*_GITEA_API_TOKEN +# Personal access token for the Gitea user who owns the repository. +# Create in Gitea: Settings → Applications → Generate Token → scope: write:user +# Aliased by detect_hosts() → GITEA_API_TOKEN. +# +# master.conf +# +# GITEA_CONTAINER Container name for local IP detection (default: "Gitea") +# GITEA_DOMAIN Domain fallback if container is remote or not running +# GITEA_SSH_KEY Path for the SSH keypair (default: /root/.ssh/unraid_gitea) +# SSH_PORT Gitea SSH port (default: 221) +# GITEA_HTTP_PORT Gitea API port (default: 3000) +# +# ============================================================================================== +# STATE FILES +# ============================================================================================== +# +# ~/.ssh/config Updated with Host entry for gitea on successful setup +# GITEA_SSH_KEY Private key (permissions: 600) +# GITEA_SSH_KEY.pub Public key — registered with Gitea +# +# ============================================================================================== +# RUNTIME MODES +# ============================================================================================== +# +# gitea_ssh_setup.sh +# Generate key if missing, register with Gitea, test connection. +# +# gitea_ssh_setup.sh --force +# Regenerate key and re-register even if already set up. +# +# gitea_ssh_setup.sh --status +# Show key state, fingerprint, registration status, SSH test. No changes. +# +# gitea_ssh_setup.sh --dry-run +# Show what would be done without generating, registering, or writing files. +# +# gitea_ssh_setup.sh --log +# Verbose output for each step. +# +# ============================================================================================== + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPTS_ROOT="$SCRIPT_DIR/.." + +source "$SCRIPTS_ROOT/load_config.sh" + +# ── Parse --force before parse_args ─────────────────────────────────────────────────────────── +FORCE=false +FILTERED_ARGS=() + +for arg in "$@"; do + case "$arg" in + --force) FORCE=true ;; + *) FILTERED_ARGS+=("$arg") ;; + esac +done + +parse_args "${FILTERED_ARGS[@]}" + +# ============================================================================================== +# ━━━ Setup ━━━ +# ============================================================================================== +if [[ "$EUID" -ne 0 ]]; then + error "Must be run as root" + exit 1 +fi + +validate_unraid_cmd \ + "/usr/local/emhttp/plugins/dynamix/scripts/notify" \ + "" "" \ + "unRAID notify script" || warn "unRAID notify script not found — native notifications disabled" + +acquire_lock + +detect_hosts + +[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made" +[[ "$FORCE" == true ]] && warn "FORCE mode — existing key and registration will be replaced" + +log "Identity: $MY_ID ($LOCAL_SERVER_NAME)" +log "SSH key: $GITEA_SSH_KEY" +log "SSH port: $SSH_PORT" + +# ============================================================================================== +# ━━━ Resolve Gitea API Endpoint ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_GEAR Gitea API Endpoint ━━━" + +GITEA_HOST="" +GITEA_API="" + +# Try local container first +CONTAINER_IP=$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \ + "$GITEA_CONTAINER" 2>/dev/null | awk 'NF{print $1; exit}') + +if [[ -n "$CONTAINER_IP" ]]; then + GITEA_HOST="$CONTAINER_IP" + GITEA_API="http://${CONTAINER_IP}:${GITEA_HTTP_PORT}/api/v1" + log "Gitea container running locally: $CONTAINER_IP:$GITEA_HTTP_PORT" +elif [[ -n "$GITEA_DOMAIN" ]]; then + GITEA_HOST="$GITEA_DOMAIN" + GITEA_API="https://${GITEA_DOMAIN}/api/v1" + warn "Gitea container not running locally — using domain: $GITEA_DOMAIN" +else + error "Cannot resolve Gitea API endpoint" + error "Gitea container ($GITEA_CONTAINER) is not running and GITEA_DOMAIN is not set in master.conf" + error "Start the Gitea container first, or set GITEA_DOMAIN" + exit 1 +fi + +log "API endpoint: $GITEA_API" + +# ============================================================================================== +# ━━━ Validate API Token ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_SHIELD API Token ━━━" + +# Alias: detect_hosts() maps HOST*_GITEA_API_TOKEN → GITEA_API_TOKEN +if [[ -z "${GITEA_API_TOKEN:-}" ]]; then + error "HOST${MY_ID#HOST}_GITEA_API_TOKEN is not set in host${MY_ID#HOST}.conf" + error "" + error "Create a token in Gitea:" + error " 1. Log into Gitea" + error " 2. Top-right profile → Settings → Applications" + error " 3. Generate New Token → name: unraid-setup → scope: write:user" + error " 4. Copy the token (shown once) → paste into HOST${MY_ID#HOST}_GITEA_API_TOKEN in host${MY_ID#HOST}.conf" + exit 1 +fi + +# Test token against the API +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: token $GITEA_API_TOKEN" \ + "$GITEA_API/user" 2>/dev/null) + +if [[ "$HTTP_CODE" == "200" ]]; then + log "API token valid ✅" +elif [[ "$HTTP_CODE" == "401" ]]; then + error "API token rejected (HTTP 401) — token may be expired or have wrong scope" + error "Regenerate the token in Gitea: Settings → Applications → Generate Token → scope: write:user" + exit 1 +else + error "Gitea API not reachable (HTTP $HTTP_CODE) at $GITEA_API" + error "Check that the Gitea container is healthy and the port is correct (GITEA_HTTP_PORT=$GITEA_HTTP_PORT)" + exit 1 +fi + +# ── Status mode exits here after full state display ─────────────────────────────────────────── +if [[ "$SHOW_STATUS" == true ]]; then + echo "" + echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━" + echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" + echo "$ICON_GEAR API endpoint: $GITEA_API" + echo "" + + if [[ -f "${GITEA_SSH_KEY}.pub" ]]; then + FINGERPRINT=$(ssh-keygen -lf "${GITEA_SSH_KEY}.pub" 2>/dev/null | awk '{print $2}') + echo " $ICON_GEAR Key file: ${GITEA_SSH_KEY}.pub" + echo " $ICON_GEAR Fingerprint: $FINGERPRINT" + + # Check registration + REGISTERED_KEYS=$(curl -s \ + -H "Authorization: token $GITEA_API_TOKEN" \ + "$GITEA_API/user/keys" 2>/dev/null) + LOCAL_PUB=$(awk '{print $1" "$2}' "${GITEA_SSH_KEY}.pub") + if echo "$REGISTERED_KEYS" | grep -qF "$LOCAL_PUB"; then + echo " $ICON_GEAR Registered: YES ✅" + else + echo " $ICON_GEAR Registered: NO — run without --status to register" + fi + else + echo " $ICON_GEAR Key file: NOT FOUND at $GITEA_SSH_KEY" + fi + + # SSH connection test + echo "" + SSH_OUTPUT=$(ssh -i "$GITEA_SSH_KEY" -p "$SSH_PORT" \ + -o StrictHostKeyChecking=no -o ConnectTimeout=5 \ + git@"$GITEA_HOST" 2>&1 || true) + if echo "$SSH_OUTPUT" | grep -q "successfully authenticated"; then + echo " $ICON_DONE SSH test: connected ✅" + echo " $ICON_INFO $(echo "$SSH_OUTPUT" | grep "successfully authenticated")" + else + echo " $ICON_ERROR SSH test: failed" + echo " $ICON_INFO $SSH_OUTPUT" + fi + + echo "━━━━━━━━━━━━━━━━━━━━━━━" + exit 0 +fi + +START=$(date +%s) +SETUP_SUCCESS=true + +# ============================================================================================== +# ━━━ Generate SSH Keypair ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_GEAR SSH Keypair ━━━" + +KEY_TITLE="unraid-$(echo "$MY_ID" | tr '[:upper:]' '[:lower:]')" +KEY_COMMENT="$KEY_TITLE@$(hostname)" + +if [[ -f "$GITEA_SSH_KEY" ]] && [[ "$FORCE" == false ]]; then + FINGERPRINT=$(ssh-keygen -lf "${GITEA_SSH_KEY}.pub" 2>/dev/null | awk '{print $2}') + log "Key already exists — skipping generation (use --force to regenerate)" + log " Path: $GITEA_SSH_KEY" + log " Fingerprint: $FINGERPRINT" +else + if [[ "$DRY_RUN" == true ]]; then + warn "DRY RUN — would generate ed25519 key at $GITEA_SSH_KEY" + warn "DRY RUN — key comment: $KEY_COMMENT" + else + mkdir -p "$(dirname "$GITEA_SSH_KEY")" + chmod 700 "$(dirname "$GITEA_SSH_KEY")" + + # Remove existing if --force + [[ "$FORCE" == true ]] && rm -f "$GITEA_SSH_KEY" "${GITEA_SSH_KEY}.pub" + + if ssh-keygen -t ed25519 -f "$GITEA_SSH_KEY" -C "$KEY_COMMENT" -N "" -q; then + chmod 600 "$GITEA_SSH_KEY" + chmod 644 "${GITEA_SSH_KEY}.pub" + FINGERPRINT=$(ssh-keygen -lf "${GITEA_SSH_KEY}.pub" 2>/dev/null | awk '{print $2}') + warn "SSH key generated ✅" + log " Path: $GITEA_SSH_KEY" + log " Fingerprint: $FINGERPRINT" + else + error "Failed to generate SSH key" + SETUP_SUCCESS=false + fi + fi +fi + +# ============================================================================================== +# ━━━ Register Public Key with Gitea ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_LINK Register Key with Gitea ━━━" + +if [[ "$DRY_RUN" == true ]]; then + warn "DRY RUN — would POST public key to $GITEA_API/user/keys" + warn "DRY RUN — key title: $KEY_TITLE" +elif [[ "$SETUP_SUCCESS" == true ]]; then + LOCAL_PUB=$(cat "${GITEA_SSH_KEY}.pub") + LOCAL_PUB_SHORT=$(awk '{print $1" "$2}' "${GITEA_SSH_KEY}.pub") + + # Check if already registered + REGISTERED_KEYS=$(curl -s \ + -H "Authorization: token $GITEA_API_TOKEN" \ + "$GITEA_API/user/keys" 2>/dev/null) + + if [[ "$FORCE" == false ]] && echo "$REGISTERED_KEYS" | grep -qF "$LOCAL_PUB_SHORT"; then + log "Public key already registered in Gitea — skipping (use --force to re-register)" + else + # If --force, delete the existing key with this title first + if [[ "$FORCE" == true ]]; then + EXISTING_ID=$(echo "$REGISTERED_KEYS" | \ + python3 -c "import json,sys; keys=json.load(sys.stdin); [print(k['id']) for k in keys if k.get('title','')=='$KEY_TITLE']" 2>/dev/null) + if [[ -n "$EXISTING_ID" ]]; then + curl -s -X DELETE \ + -H "Authorization: token $GITEA_API_TOKEN" \ + "$GITEA_API/user/keys/$EXISTING_ID" >/dev/null 2>&1 + log "Removed existing key '$KEY_TITLE' (ID: $EXISTING_ID)" + fi + fi + + RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ + -H "Authorization: token $GITEA_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"key\":$(echo "$LOCAL_PUB" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read().strip()))'),\"read_only\":false,\"title\":\"$KEY_TITLE\"}" \ + "$GITEA_API/user/keys" 2>/dev/null) + + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | head -n -1) + + if [[ "$HTTP_CODE" == "201" ]]; then + KEY_ID=$(echo "$BODY" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])" 2>/dev/null) + warn "Public key registered in Gitea ✅ (ID: $KEY_ID, title: $KEY_TITLE)" + elif [[ "$HTTP_CODE" == "422" ]]; then + warn "Key already registered under a different title — skipping" + log "Response: $BODY" + else + error "Failed to register key (HTTP $HTTP_CODE)" + log "Response: $BODY" + SETUP_SUCCESS=false + fi + fi +fi + +# ============================================================================================== +# ━━━ SSH Connection Test ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_GEAR SSH Connection Test ━━━" + +if [[ "$DRY_RUN" == true ]]; then + warn "DRY RUN — would test: ssh -i $GITEA_SSH_KEY -p $SSH_PORT git@$GITEA_HOST" +else + SSH_OUTPUT=$(ssh -i "$GITEA_SSH_KEY" -p "$SSH_PORT" \ + -o StrictHostKeyChecking=no \ + -o ConnectTimeout=10 \ + git@"$GITEA_HOST" 2>&1 || true) + + if echo "$SSH_OUTPUT" | grep -q "successfully authenticated"; then + AUTHED_AS=$(echo "$SSH_OUTPUT" | grep "successfully authenticated" | sed 's/.*authenticated as //') + warn "SSH connection successful ✅ — authenticated as $AUTHED_AS" + else + error "SSH connection test failed" + log "Output: $SSH_OUTPUT" + warn "Key may not yet be active — Gitea sometimes takes a moment to pick up new keys" + warn "Run --status in 30 seconds to recheck" + SETUP_SUCCESS=false + fi +fi + +# ============================================================================================== +# ━━━ SSH Config Entry ━━━ +# ============================================================================================== +echo "" +echo "━━━ $ICON_GEAR SSH Config Entry ━━━" + +SSH_CONFIG="$HOME/.ssh/config" +ENTRY_MARKER="Host gitea-${MY_ID,,}" + +if [[ "$DRY_RUN" == true ]]; then + warn "DRY RUN — would add Host entry to $SSH_CONFIG:" + warn " Host gitea-${MY_ID,,}" + warn " HostName $GITEA_HOST" + warn " Port $SSH_PORT" + warn " User git" + warn " IdentityFile $GITEA_SSH_KEY" +elif grep -qF "$ENTRY_MARKER" "$SSH_CONFIG" 2>/dev/null && [[ "$FORCE" == false ]]; then + log "SSH config entry already exists — skipping (use --force to update)" +else + if [[ "$FORCE" == true ]]; then + # Remove old entry block if present + if grep -qF "$ENTRY_MARKER" "$SSH_CONFIG" 2>/dev/null; then + python3 - "$SSH_CONFIG" "$ENTRY_MARKER" <<'PYEOF' +import sys +path, marker = sys.argv[1], sys.argv[2] +with open(path) as f: + lines = f.readlines() +out, skip = [], False +for line in lines: + if line.strip() == marker: + skip = True + elif skip and line.startswith('Host '): + skip = False + if not skip: + out.append(line) +with open(path, 'w') as f: + f.writelines(out) +PYEOF + log "Removed old SSH config entry" + fi + fi + + mkdir -p "$(dirname "$SSH_CONFIG")" + { + echo "" + echo "Host gitea-${MY_ID,,}" + echo " HostName $GITEA_HOST" + echo " Port $SSH_PORT" + echo " User git" + echo " IdentityFile $GITEA_SSH_KEY" + } >> "$SSH_CONFIG" + chmod 600 "$SSH_CONFIG" + log "SSH config entry added: Host gitea-${MY_ID,,} ✅" +fi + +END=$(date +%s) + +# ============================================================================================== +# ━━━ Summary ━━━ +# ============================================================================================== +echo "" +echo "━━━━━ $ICON_SUMMARY GITEA SSH SETUP SUMMARY ━━━━━" +echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" +echo "$ICON_GEAR Key: $GITEA_SSH_KEY" +echo "$ICON_LINK Gitea: $GITEA_HOST:$SSH_PORT" +echo "$ICON_GEAR Key title: $KEY_TITLE" +echo "$ICON_TIME Duration: $(format_duration $(( END - START )))" +echo "" + +if [[ "$DRY_RUN" == true ]]; then + warn "DRY RUN — no changes made" +elif [[ "$SETUP_SUCCESS" == true ]]; then + echo "$ICON_DONE Status: done ✅" + echo "" + echo "Git remote push:" + echo " GIT_SSH_COMMAND='ssh -i $GITEA_SSH_KEY -p $SSH_PORT' git push" +else + echo "$ICON_ERROR Status: SETUP HAD ERRORS — check output above" + notify "Gitea SSH setup errors on $(hostname) ($MY_ID)" \ + "Gitea SSH Setup" "warning" + exit 1 +fi +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" diff --git a/common.sh b/common.sh index cf58c62..c994aff 100644 --- a/common.sh +++ b/common.sh @@ -391,6 +391,7 @@ validate_int() { # EMBY_CONTAINER ← HOST*_EMBY_CONTAINER # EMBY_URL ← HOST*_EMBY_URL # EMBY_API_KEY ← HOST*_EMBY_API_KEY +# GITEA_API_TOKEN ← HOST*_GITEA_API_TOKEN # # Arr-specific vars set by detect_hosts() when needed by arr scripts: # LIDARR_URL / LIDARR_API_KEY / LIDARR_MUSIC_ROOT (MY_ID only — Lidarr is HOST1 only) @@ -465,6 +466,9 @@ detect_hosts() { EMBY_API_KEY_VAR="${MY_ID}_EMBY_API_KEY" EMBY_API_KEY="${!EMBY_API_KEY_VAR:-}" + GITEA_API_TOKEN_VAR="${MY_ID}_GITEA_API_TOKEN" + GITEA_API_TOKEN="${!GITEA_API_TOKEN_VAR:-}" + local _prov_var for _prov_var in \ PARTNERSHIP_PROVISION_EMBY_ADMIN \ diff --git a/host1.conf b/host1.conf index 233de75..ab88282 100644 --- a/host1.conf +++ b/host1.conf @@ -87,6 +87,12 @@ HOST1_EMBY_URL="http://localhost:8096" HOST1_EMBY_API_KEY="0c27448d93a7431f9ac63569f7655829" +# ━━━ Gitea ━━━ +# Personal access token for gitea_ssh_setup.sh — registers this server's SSH public key +# with Gitea so git operations use key auth instead of passwords. +# Create in Gitea: Settings → Applications → Generate Token → scope: write:user + HOST1_GITEA_API_TOKEN="" + # ━━━ Notifications ━━━ # Discord webhook — leave blank to disable. # Per-host so HOST1 and HOST2 can post to different channels or only one server notifies. @@ -121,6 +127,8 @@ "my-Authelia-Secondary.xml" "my-NginxProxyManager.xml" "my-Lldap-Gmer4Lfe.xml" + # Source of truth — must be available on HOST2 independently of the auth stack + "my-Gitea.xml" ) # XML templates pushed to mirror for the arr stack during onboard. diff --git a/host2.conf b/host2.conf index 8c7f572..481caf9 100644 --- a/host2.conf +++ b/host2.conf @@ -337,6 +337,7 @@ # Higher tiers activate after HOST1_TIER*_DELAY minutes (set in host1.conf). FALLBACK_HOST2_COVERS_HOST1_TIER1=( "Gmer4Lfe.com" + "Gitea" # source of truth — must be reachable even when HOST1 auth stack is down "Emby" "VaultWarden-Gmer4Lfe" "Dispatcharr" diff --git a/master.conf b/master.conf index 56b7eea..14cfb35 100644 --- a/master.conf +++ b/master.conf @@ -249,6 +249,7 @@ TARGET_DIR="/mnt/user/appdata/unraid_scripts" GITEA_SSH_KEY="/root/.ssh/unraid_gitea" SSH_PORT=221 # Gitea SSH port (default 22, Gitea often uses 221/222) + GITEA_HTTP_PORT=3000 # Gitea web/API port — used by gitea_ssh_setup.sh # ============================================================================================== # ── ORCHESTRATORS ─────────────────────────────────────────────────────────────────────────────