#!/usr/bin/env bash set -euo pipefail RCLONE_CONF="${HOME}/.config/rclone/rclone.conf" SSH_DIR="${HOME}/.ssh" REGISTRY="${HOME}/.config/tinyboard/spokes" RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' NC='\033[0m' info() { echo -e "${GREEN}[+]${NC} $*"; } warn() { echo -e "${YELLOW}[!]${NC} $*"; } die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } header() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}══════════════════════════════════════════${NC}"; } check_deps() { local missing=() for cmd in "$@"; do if ! command -v "$cmd" >/dev/null 2>&1; then missing+=("$cmd") fi done if [ ${#missing[@]} -gt 0 ]; then die "Missing required dependencies: ${missing[*]}" fi } if [ "$(id -u)" -eq 0 ]; then die "Run as the hub user, not root." fi check_deps rclone crontab fusermount python3 header "TinyBoard Hub — Offboard Spoke" [ -f "$REGISTRY" ] || die "No spoke registry found at $REGISTRY. No spokes to offboard." echo "Registered spokes:" echo "" awk '{print " " $1 " (port " $2 ", mount " $4 ")"}' "$REGISTRY" echo "" read -rp "Spoke name to offboard: " SPOKE_NAME [ -n "$SPOKE_NAME" ] || die "Spoke name cannot be empty" SPOKE_LINE=$(grep "^$SPOKE_NAME " "$REGISTRY" 2>/dev/null || true) [ -n "$SPOKE_LINE" ] || die "Spoke '$SPOKE_NAME' not found in registry." TUNNEL_PORT=$(echo "$SPOKE_LINE" | awk '{print $2}') KEY_PATH=$(echo "$SPOKE_LINE" | awk '{print $3}') MOUNT_POINT=$(echo "$SPOKE_LINE" | awk '{print $4}') echo "" echo -e " Spoke: ${YELLOW}$SPOKE_NAME${NC}" echo -e " Port: ${YELLOW}$TUNNEL_PORT${NC}" echo -e " Key: ${YELLOW}$KEY_PATH${NC}" echo -e " Mount: ${YELLOW}$MOUNT_POINT${NC}" echo "" read -rp "Are you sure you want to offboard $SPOKE_NAME? [y/N]: " CONFIRM [[ "${CONFIRM,,}" == "y" ]] || die "Aborted." header "Unmounting Spoke" if mountpoint -q "$MOUNT_POINT" 2>/dev/null; then if fusermount -u "$MOUNT_POINT" 2>/dev/null; then info "Unmounted $MOUNT_POINT." else warn "Could not unmount $MOUNT_POINT — may already be unmounted." fi else warn "$MOUNT_POINT is not currently mounted." fi header "Removing Crontab Entry" EXISTING=$(crontab -l 2>/dev/null || true) UPDATED=$(echo "$EXISTING" | grep -v "${SPOKE_NAME}-remote:" || true) if [ "$EXISTING" = "$UPDATED" ]; then warn "No crontab entry found for $SPOKE_NAME." elif [ -z "$UPDATED" ]; then crontab -r 2>/dev/null || true info "Crontab entry for $SPOKE_NAME removed (crontab now empty)." else echo "$UPDATED" | crontab - info "Crontab entry for $SPOKE_NAME removed." fi header "Removing rclone Remote" if grep -q "\[${SPOKE_NAME}-remote\]" "$RCLONE_CONF" 2>/dev/null; then python3 - "$RCLONE_CONF" "$SPOKE_NAME" <<'PYEOF' import sys path, name = sys.argv[1], sys.argv[2] lines = open(path).readlines() out, skip = [], False for line in lines: if line.strip() == f"[{name}-remote]": skip = True elif skip and line.strip().startswith("["): skip = False if not skip: out.append(line) open(path, 'w').writelines(out) PYEOF info "rclone remote [${SPOKE_NAME}-remote] removed from $RCLONE_CONF." else warn "rclone remote [${SPOKE_NAME}-remote] not found in $RCLONE_CONF." fi header "Removing SSH Key" read -rp "Remove hub SSH key for $SPOKE_NAME ($KEY_PATH)? [y/N]: " REMOVE_KEY if [[ "${REMOVE_KEY,,}" == "y" ]]; then if [ -f "$KEY_PATH" ]; then rm -f "$KEY_PATH" "$KEY_PATH.pub" info "SSH key removed." else warn "Key not found at $KEY_PATH." fi else info "SSH key left in place." fi header "Removing from Registry" (grep -v "^$SPOKE_NAME " "$REGISTRY" || true) > "${REGISTRY}.tmp" && mv "${REGISTRY}.tmp" "$REGISTRY" info "$SPOKE_NAME removed from registry." header "Offboarding Complete" echo -e " Spoke ${GREEN}$SPOKE_NAME${NC} has been offboarded." echo ""