Compare commits

...

40 Commits

Author SHA1 Message Date
Justin Oros
395ab4ed0e add spoke registry, per-spoke crontab, and offboard-spoke.sh 2026-04-16 13:41:56 -07:00
Justin Oros
4c08f3b389 fix function ordering, hardcoded armbian user, and key name prefix in onboard-spoke.sh 2026-04-16 13:17:12 -07:00
Justin Oros
ccd324dc79 fix function ordering and RCLONE_CONF used before definition in setup-hub.sh 2026-04-16 13:15:40 -07:00
Justin Oros
664bdeaed4 fix function ordering, permission check chains, and known_hosts check timing in setup-spoke.sh 2026-04-16 13:14:27 -07:00
Justin Oros
ae49c58b13 add WiFi password extraction validation in setup-network.sh 2026-04-16 13:13:18 -07:00
Justin Oros
119b747dda fix BACKUP_FILE unbound variable and add ping to dep checks 2026-04-16 13:11:57 -07:00
Justin Oros
ea72b14696 fix function ordering, remove dead variable, fix netplan rollback approach 2026-04-16 13:10:59 -07:00
Justin Oros
26110ce8d3 add 30s connectivity check with auto-rollback to setup-network.sh 2026-04-16 13:09:40 -07:00
Justin Oros
58f6445c72 add check_deps function and dependency checks to all scripts 2026-04-16 13:05:45 -07:00
Justin Oros
08799f0f7f add SSH key permission checks with auto-fix to hub and spoke scripts 2026-04-16 12:58:06 -07:00
Justin Oros
a79b1c59b8 move password auth prompt to after SSH key setup in setup-spoke.sh 2026-04-16 10:44:12 -07:00
Justin Oros
7e64156026 fix double brace artifacts and missing SSHD_CONF in setup-spoke.sh 2026-04-16 10:42:58 -07:00
Justin Oros
3d366cd74a add disable password auth prompt with SSH restart warning to hub and spoke scripts 2026-04-16 10:42:04 -07:00
Justin Oros
d080db1db8 fix hardcoded armbian path in compose volume mount sed replacement 2026-04-16 10:37:55 -07:00
Justin Oros
37e3e91239 fix ARMBIAN_HOME unbound variable, retry_or_abort quoting, and hardcoded path in sed 2026-04-16 10:36:54 -07:00
Justin Oros
7676a907ee add SPOKE_USER prompt and replace all hardcoded armbian references in setup-spoke.sh 2026-04-16 10:35:50 -07:00
Justin Oros
e5ecdca3ff add multi-distro package manager support to setup-spoke.sh 2026-04-16 10:34:06 -07:00
Justin Oros
50fb313f9a fix hardcoded armbian string in user creation log message 2026-04-16 10:30:47 -07:00
Justin Oros
d21997af43 prompt for hub username with armbian as default, replace all hardcoded references 2026-04-16 10:29:53 -07:00
Justin Oros
95a56ef4f0 fix usermod group assignment to use if blocks instead of && chain 2026-04-16 10:26:28 -07:00
Justin Oros
b706dd211d fix pkg update handling, curl dependency, sudo group check, systemctl blocks 2026-04-16 10:25:26 -07:00
Justin Oros
f3a3f66982 rewrite setup-hub.sh with multi-distro package manager support 2026-04-16 10:22:52 -07:00
Justin Oros
384cf476ff replace hardcoded hub references with prompts, defaults: oily.dad / armbian / hubkey 2026-04-16 10:13:48 -07:00
Justin Oros
b8d2a3e5bc fix SPOKE_DIR path and replace hardcoded finn/oily.dad with dynamic HUB_HOST 2026-04-16 10:05:25 -07:00
Justin Oros
a49f830ed2 prompt user for hub hostname with oily.dad as default 2026-04-16 10:03:35 -07:00
Justin Oros
fe7f77171f fix wired DNS heredoc trailing newline 2026-04-16 09:59:23 -07:00
Justin Oros
288aa698d0 fix netplan file fallback assignment bug in setup-network.sh 2026-04-16 09:57:29 -07:00
Justin Oros
96c737709c restructure: add setup.sh entry point, move scripts to spoke/ and hub/ 2026-04-16 09:53:30 -07:00
Justin Oros
2c8df6993d add setup-network.sh and rename setup.sh to setup-spoke.sh 2026-04-16 09:44:29 -07:00
Justin Oros
c86dca283f add retry or abort prompt to all connection tests 2026-04-16 09:30:47 -07:00
Justin Oros
9015ff46c9 fix root check to use if block instead of fragile && chain 2026-04-16 09:23:36 -07:00
Justin Oros
87c08fb543 fix known_hosts ownership, ssh dir creation, and root user guard 2026-04-16 09:22:19 -07:00
Justin Oros
7bdafd316c fix keyscan validation to die early if spoke tunnel is not up 2026-04-16 09:18:40 -07:00
Justin Oros
114c97a1cb fix docker compose package name to docker-compose-plugin 2026-04-16 09:15:53 -07:00
Justin Oros
c71ad59629 fix windows line endings in pasted key and rclone remote mount hint 2026-04-16 09:13:17 -07:00
Justin Oros
2abd6ac6a4 fix tunnel reachability check to be safe with set -e 2026-04-16 09:09:45 -07:00
Justin Oros
ccd9b205b8 fix hub onboard-spoke script: keyscan, rclone check, dir creation, tunnel verify 2026-04-16 09:08:07 -07:00
Justin Oros
f6c2c79a70 add hub onboard-spoke script to automate new spoke registration 2026-04-16 09:05:41 -07:00
Justin Oros
cf8a10818a fix docker compose v2 compatibility 2026-04-16 08:27:01 -07:00
Justin Oros
fefd082af2 add zero-touch spoke setup script with auto port detection and key options 2026-04-15 21:07:30 -07:00
5 changed files with 1011 additions and 0 deletions

139
hub/onboard-spoke.sh Normal file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env bash
set -euo pipefail
RCLONE_CONF="${HOME}/.config/rclone/rclone.conf"
SSH_DIR="${HOME}/.ssh"
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
}
retry_or_abort() {
local test_cmd="$1"
local fail_msg="$2"
while true; do
if eval "$test_cmd" 2>/dev/null; then
return 0
fi
echo ""
warn "$fail_msg"
echo -e " ${YELLOW}[R]${NC} Retry ${RED}[A]${NC} Abort"
read -rp "Choice: " CHOICE
case "${CHOICE,,}" in
r) info "Retrying..." ;;
a) die "Aborted." ;;
*) warn "Press R to retry or A to abort." ;;
esac
done
}
if [ "$(id -u)" -eq 0 ]; then
die "Running as root — keys will be written to /root/.ssh. Run as the hub user instead."
fi
mkdir -p "$SSH_DIR"
touch "$SSH_DIR/known_hosts"
chmod 700 "$SSH_DIR"
chmod 600 "$SSH_DIR/known_hosts"
check_deps ssh ssh-keygen ssh-keyscan ssh-copy-id rclone
header "TinyBoard Hub — Onboard New Spoke"
read -rp "Spoke local user [armbian]: " SPOKE_USER
SPOKE_USER="${SPOKE_USER:-armbian}"
read -rp "Spoke name (e.g. rocky): " SPOKE_NAME
[ -n "$SPOKE_NAME" ] || die "Spoke name cannot be empty"
read -rp "Tunnel port for $SPOKE_NAME: " TUNNEL_PORT
[[ "$TUNNEL_PORT" =~ ^[0-9]+$ ]] || die "Invalid port"
KEY_NAME="${SPOKE_USER}-${SPOKE_NAME}-$(date +%Y%m)"
KEY_PATH="$SSH_DIR/$KEY_NAME"
mkdir -p "$(dirname "$RCLONE_CONF")"
header "Checking Tunnel"
info "Scanning spoke host key..."
KEYSCAN=$(ssh-keyscan -p "$TUNNEL_PORT" -H localhost 2>/dev/null)
[ -n "$KEYSCAN" ] || die "Spoke not reachable on port $TUNNEL_PORT — is the tunnel up?"
echo "$KEYSCAN" >> "$SSH_DIR/known_hosts"
info "Verifying spoke is reachable on port $TUNNEL_PORT..."
retry_or_abort \
"ssh -o BatchMode=yes -o ConnectTimeout=10 -p \"$TUNNEL_PORT\" \"$SPOKE_USER\"@localhost exit" \
"Spoke not reachable on port $TUNNEL_PORT. Make sure the tunnel is up."
header "Generating Hub SSH Key"
if [ -f "$KEY_PATH" ]; then
warn "Key $KEY_PATH already exists, skipping generation."
else
ssh-keygen -t ed25519 -f "$KEY_PATH" -N ""
info "Key generated: $KEY_PATH"
fi
header "Copying Hub Key to Spoke"
info "Running ssh-copy-id to $SPOKE_USER@localhost:$TUNNEL_PORT..."
info "(You will be prompted for the $SPOKE_USER password on the spoke)"
ssh-copy-id -i "$KEY_PATH.pub" -p "$TUNNEL_PORT" "$SPOKE_USER"@localhost
info "Key copied."
header "Testing Hub -> Spoke Key Auth"
retry_or_abort \
"ssh -i \"$KEY_PATH\" -o BatchMode=yes -o ConnectTimeout=10 -p \"$TUNNEL_PORT\" \"$SPOKE_USER\"@localhost exit" \
"Key auth failed. Check authorized_keys on the spoke."
info "Key auth to spoke successful."
header "Adding rclone Remote"
if grep -q "\[${SPOKE_NAME}-remote\]" "$RCLONE_CONF" 2>/dev/null; then
warn "Remote [${SPOKE_NAME}-remote] already exists in $RCLONE_CONF, skipping."
else
cat >> "$RCLONE_CONF" <<EOF
[${SPOKE_NAME}-remote]
type = sftp
host = localhost
port = $TUNNEL_PORT
key_file = $KEY_PATH
shell_type = unix
md5sum_command = md5sum
sha1sum_command = sha1sum
EOF
info "Remote [${SPOKE_NAME}-remote] added to $RCLONE_CONF."
fi
header "Testing rclone Connection"
if rclone lsd "${SPOKE_NAME}-remote:" --config "$RCLONE_CONF" 2>/dev/null; then
info "rclone connection to $SPOKE_NAME successful."
else
warn "rclone test failed. Check the remote config in $RCLONE_CONF."
fi
header "Onboarding Complete"
echo -e " Spoke: ${GREEN}$SPOKE_NAME${NC}"
echo -e " Port: ${GREEN}$TUNNEL_PORT${NC}"
echo -e " Hub key: ${GREEN}$KEY_PATH${NC}"
echo -e " rclone: ${GREEN}${SPOKE_NAME}-remote${NC}"
echo ""
echo -e "${YELLOW}To mount this spoke:${NC}"
echo " RCLONE_REMOTE=${SPOKE_NAME}-remote hubspoke-helper.sh hub start"
echo ""

264
hub/setup-hub.sh Normal file
View File

@@ -0,0 +1,264 @@
#!/usr/bin/env bash
set -euo pipefail
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
}
check_permissions() {
local file="$1"
local label="$2"
if [ ! -f "$file" ]; then
warn "Permission check: $label not found at $file"
return
fi
local perms
perms=$(stat -c "%a" "$file" 2>/dev/null || stat -f "%OLp" "$file" 2>/dev/null)
if [ -z "$perms" ]; then
warn "Could not read permissions for $label ($file)"
return
fi
local world="${perms: -1}"
local group="${perms: -2:1}"
if [ "$world" != "0" ] || [ "$group" != "0" ]; then
warn "UNSAFE PERMISSIONS on $label ($file): $perms — should be 600 or 400"
warn "Fixing permissions automatically..."
chmod 600 "$file"
info "Permissions fixed: $file is now 600"
else
info "Permissions OK: $label ($file) = $perms"
fi
}
[ "$(id -u)" -eq 0 ] || die "Run as root"
check_deps ssh ssh-keygen systemctl useradd groupadd crontab
header "TinyBoard Hub Setup"
read -rp "Hub username [armbian]: " HUB_USER
HUB_USER="${HUB_USER:-armbian}"
header "Detecting Package Manager"
if command -v apt-get >/dev/null 2>&1; then
PKG_MANAGER="apt"
PKG_INSTALL="apt-get install -y -q"
OPENSSH_PKG="openssh-server"
FUSE_PKG="fuse"
info "Detected: apt (Debian/Ubuntu)"
apt-get update -q
elif command -v dnf >/dev/null 2>&1; then
PKG_MANAGER="dnf"
PKG_INSTALL="dnf install -y -q"
OPENSSH_PKG="openssh-server"
FUSE_PKG="fuse"
info "Detected: dnf (Fedora/RHEL/Alma/Rocky)"
dnf check-update -q || true
elif command -v yum >/dev/null 2>&1; then
PKG_MANAGER="yum"
PKG_INSTALL="yum install -y -q"
OPENSSH_PKG="openssh-server"
FUSE_PKG="fuse"
info "Detected: yum (older RHEL/CentOS)"
yum check-update -q || true
elif command -v pacman >/dev/null 2>&1; then
PKG_MANAGER="pacman"
PKG_INSTALL="pacman -S --noconfirm --quiet"
OPENSSH_PKG="openssh"
FUSE_PKG="fuse3"
info "Detected: pacman (Arch)"
pacman -Sy --quiet
else
die "No supported package manager found (apt, dnf, yum, pacman)"
fi
header "Installing Packages"
info "Installing curl if missing..."
if ! command -v curl >/dev/null 2>&1; then
$PKG_INSTALL curl
fi
$PKG_INSTALL "$OPENSSH_PKG" "$FUSE_PKG" git
if ! command -v rclone >/dev/null 2>&1; then
info "Installing rclone..."
if [ "$PKG_MANAGER" = "pacman" ]; then
$PKG_INSTALL rclone
else
curl -fsSL https://rclone.org/install.sh | bash
fi
else
warn "rclone already installed, skipping."
fi
header "Armbian User Setup"
if id "$HUB_USER" >/dev/null 2>&1; then
warn "User '$HUB_USER' already exists, skipping creation."
else
info "Creating $HUB_USER user..."
groupadd -g 1000 "$HUB_USER" 2>/dev/null || true
useradd -m -u 1000 -g 1000 -s /bin/bash "$HUB_USER"
ADDED_TO_GROUP=false
if getent group sudo >/dev/null 2>&1; then
if usermod -aG sudo "$HUB_USER" 2>/dev/null; then
ADDED_TO_GROUP=true
fi
fi
if [ "$ADDED_TO_GROUP" = false ] && getent group wheel >/dev/null 2>&1; then
if usermod -aG wheel "$HUB_USER" 2>/dev/null; then
ADDED_TO_GROUP=true
fi
fi
if [ "$ADDED_TO_GROUP" = false ]; then
warn "Neither sudo nor wheel group found — $HUB_USER user has no sudo access."
fi
info "$HUB_USER user created."
echo ""
warn "Set a password for the $HUB_USER user:"
passwd "$HUB_USER"
fi
ARMBIAN_HOME="/home/$HUB_USER"
SSH_DIR="$ARMBIAN_HOME/.ssh"
mkdir -p "$SSH_DIR"
touch "$SSH_DIR/authorized_keys"
chown -R "$HUB_USER":"$HUB_USER" "$SSH_DIR"
chmod 700 "$SSH_DIR"
chmod 600 "$SSH_DIR/authorized_keys"
header "SSH Server Configuration"
SSHD_CONF="/etc/ssh/sshd_config"
[ -f "$SSHD_CONF" ] || die "sshd_config not found at $SSHD_CONF"
for DIRECTIVE in "GatewayPorts yes" "AllowTcpForwarding yes"; do
KEY="${DIRECTIVE%% *}"
if grep -q "^$KEY" "$SSHD_CONF"; then
sed -i "s/^$KEY.*/$DIRECTIVE/" "$SSHD_CONF"
else
echo "$DIRECTIVE" >> "$SSHD_CONF"
fi
info "$DIRECTIVE set."
done
if systemctl enable ssh 2>/dev/null; then
systemctl restart ssh
elif systemctl enable sshd 2>/dev/null; then
systemctl restart sshd
else
warn "Could not enable/restart SSH service — please start it manually."
fi
info "SSH server restarted."
header "Password Authentication"
read -rp "Disable password auth for $HUB_USER and use keys only? [Y/n]: " DISABLE_PASS
DISABLE_PASS="${DISABLE_PASS:-y}"
if [[ "${DISABLE_PASS,,}" == "y" ]]; then
if [ ! -s "$SSH_DIR/authorized_keys" ]; then
warn "No keys found in $SSH_DIR/authorized_keys — skipping password auth disable to avoid lockout."
else
if grep -q "^PasswordAuthentication" "$SSHD_CONF"; then
sed -i "s/^PasswordAuthentication.*/PasswordAuthentication no/" "$SSHD_CONF"
else
echo "PasswordAuthentication no" >> "$SSHD_CONF"
fi
if grep -q "^PubkeyAuthentication" "$SSHD_CONF"; then
sed -i "s/^PubkeyAuthentication.*/PubkeyAuthentication yes/" "$SSHD_CONF"
else
echo "PubkeyAuthentication yes" >> "$SSHD_CONF"
fi
info "Password authentication disabled for $HUB_USER."
echo ""
warn "Restarting SSH will apply the new settings."
warn "If you are connected via SSH, your session may drop."
warn "Make sure you can reconnect using your key before continuing."
read -rp "Press ENTER to restart SSH or CTRL+C to abort..."
if systemctl restart ssh 2>/dev/null; then
info "SSH restarted."
elif systemctl restart sshd 2>/dev/null; then
info "SSH restarted."
else
warn "Could not restart SSH — please restart it manually."
fi
fi
else
info "Password authentication left enabled."
fi
header "FUSE Configuration"
FUSE_CONF="/etc/fuse.conf"
if [ -f "$FUSE_CONF" ]; then
if grep -q "^#user_allow_other" "$FUSE_CONF"; then
sed -i 's/^#user_allow_other/user_allow_other/' "$FUSE_CONF"
info "user_allow_other enabled in $FUSE_CONF."
elif grep -q "^user_allow_other" "$FUSE_CONF"; then
warn "user_allow_other already enabled."
else
echo "user_allow_other" >> "$FUSE_CONF"
info "user_allow_other added to $FUSE_CONF."
fi
else
echo "user_allow_other" > "$FUSE_CONF"
info "$FUSE_CONF created with user_allow_other."
fi
groupadd fuse 2>/dev/null || true
usermod -aG fuse "$HUB_USER" 2>/dev/null || true
info "$HUB_USER added to fuse group."
header "Rclone Setup"
RCLONE_CONF="$ARMBIAN_HOME/.config/rclone/rclone.conf"
mkdir -p "$(dirname "$RCLONE_CONF")"
chown -R "$HUB_USER":"$HUB_USER" "$ARMBIAN_HOME/.config"
if [ ! -f "$RCLONE_CONF" ]; then
touch "$RCLONE_CONF"
chown "$HUB_USER":"$HUB_USER" "$RCLONE_CONF"
info "Empty rclone.conf created at $RCLONE_CONF."
else
warn "rclone.conf already exists, skipping."
fi
header "Permission Checks"
info "Checking SSH directory permissions..."
check_permissions "$SSH_DIR/authorized_keys" "authorized_keys"
check_permissions "$RCLONE_CONF" "rclone.conf"
header "Mount Point Setup"
read -rp "Mount point for spoke filesystems [/mnt/hub]: " MOUNT_POINT
MOUNT_POINT="${MOUNT_POINT:-/mnt/hub}"
mkdir -p "$MOUNT_POINT"
chown "$HUB_USER":"$HUB_USER" "$MOUNT_POINT"
info "Mount point created at $MOUNT_POINT."
header "Hub Setup Complete"
echo -e " Hub user: ${GREEN}$HUB_USER${NC}"
echo -e " SSH config: ${GREEN}GatewayPorts yes, AllowTcpForwarding yes${NC}"
echo -e " FUSE: ${GREEN}user_allow_other enabled${NC}"
echo -e " rclone config: ${GREEN}$RCLONE_CONF${NC}"
echo -e " Mount point: ${GREEN}$MOUNT_POINT${NC}"
echo ""
echo -e "${YELLOW}Next steps:${NC}"
echo " For each spoke that connects, run:"
echo " ./setup.sh (choose option 2)"
echo ""

202
setup-network.sh Executable file
View File

@@ -0,0 +1,202 @@
#!/usr/bin/env bash
set -euo pipefail
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
}
[ "$(id -u)" -eq 0 ] || die "Run as root"
check_deps ip netplan systemctl ping
header "TinyBoard Network Setup"
info "Available interfaces:"
ip -o link show | awk -F': ' 'NR>1 {print " " $2}'
echo ""
read -rp "Enter interface name to configure (e.g. wlan0, eth0, end0): " IFACE
[ -n "$IFACE" ] || die "Interface name cannot be empty"
ip link show "$IFACE" >/dev/null 2>&1 || die "Interface $IFACE not found"
IS_WIFI=false
if [[ "$IFACE" == wl* ]]; then
IS_WIFI=true
info "Wireless interface detected."
else
info "Wired interface detected — skipping WiFi credential setup."
fi
CURRENT_IP=$(ip -o -4 addr show "$IFACE" 2>/dev/null | awk '{print $4}' | head -1)
CURRENT_GW=$(ip route show default 2>/dev/null | awk '/default/ {print $3}' | head -1)
echo ""
info "Current IP: ${CURRENT_IP:-none}"
info "Current gateway: ${CURRENT_GW:-none}"
echo ""
read -rp "Set a static IP for this spoke? [Y/n]: " SET_STATIC
SET_STATIC="${SET_STATIC:-y}"
if [[ "${SET_STATIC,,}" != "y" ]]; then
info "Keeping DHCP. No changes made."
exit 0
fi
header "Static IP Configuration"
read -rp "Enter static IP with prefix (e.g. 192.168.1.69/24): " STATIC_IP
[ -n "$STATIC_IP" ] || die "IP address cannot be empty"
DEFAULT_GW="${CURRENT_GW:-192.168.1.1}"
read -rp "Gateway [${DEFAULT_GW}]: " GATEWAY
GATEWAY="${GATEWAY:-$DEFAULT_GW}"
read -rp "DNS servers (comma-separated) [${GATEWAY},8.8.8.8]: " DNS_INPUT
DNS_INPUT="${DNS_INPUT:-${GATEWAY},8.8.8.8}"
DNS_YAML=""
IFS=',' read -ra DNS_LIST <<< "$DNS_INPUT"
for DNS in "${DNS_LIST[@]}"; do
DNS=$(echo "$DNS" | tr -d ' ')
DNS_YAML="${DNS_YAML} - ${DNS}\n"
done
info "Current netplan configs:"
ls /etc/netplan/ | sed 's/^/ /'
echo ""
NETPLAN_FILE=$(ls /etc/netplan/*.yaml 2>/dev/null | head -1)
read -rp "Netplan file to update [${NETPLAN_FILE}]: " INPUT_FILE
NETPLAN_FILE="${INPUT_FILE:-$NETPLAN_FILE}"
NETPLAN_FILE="${NETPLAN_FILE:-$(ls /etc/netplan/*.yaml 2>/dev/null | head -1)}"
[ -n "$NETPLAN_FILE" ] || die "No netplan file specified"
if $IS_WIFI; then
header "WiFi Credentials"
CURRENT_SSID=""
if [ -f "$NETPLAN_FILE" ]; then
CURRENT_SSID=$(grep -A1 'access-points:' "$NETPLAN_FILE" 2>/dev/null | tail -1 | tr -d ' "' | sed 's/:$//' || true)
fi
KEEP_WIFI="n"
if [ -n "$CURRENT_SSID" ]; then
warn "Existing WiFi config found for: $CURRENT_SSID"
read -rp "Keep existing WiFi credentials? [Y/n]: " KEEP_WIFI
KEEP_WIFI="${KEEP_WIFI:-y}"
fi
if [[ "${KEEP_WIFI,,}" != "y" ]]; then
read -rp "WiFi SSID: " WIFI_SSID
[ -n "$WIFI_SSID" ] || die "SSID cannot be empty"
read -rsp "WiFi password: " WIFI_PASS
echo ""
[ -n "$WIFI_PASS" ] || die "Password cannot be empty"
else
WIFI_SSID="$CURRENT_SSID"
WIFI_PASS=$(grep -A2 "\"${WIFI_SSID}\"" "$NETPLAN_FILE" 2>/dev/null | grep password | awk -F': ' '{print $2}' | tr -d '"' || true)
[ -n "$WIFI_PASS" ] || die "Could not extract WiFi password from existing config — please re-enter credentials."
fi
fi
header "Writing Netplan Config"
BACKUP_FILE=""
if [ -f "$NETPLAN_FILE" ]; then
BACKUP_FILE="/root/$(basename "${NETPLAN_FILE}").bak"
cp "$NETPLAN_FILE" "$BACKUP_FILE"
info "Backup saved to $BACKUP_FILE"
fi
if $IS_WIFI; then
cat > "$NETPLAN_FILE" <<NETEOF
network:
version: 2
wifis:
${IFACE}:
dhcp4: no
addresses:
- ${STATIC_IP}
routes:
- to: default
via: ${GATEWAY}
nameservers:
addresses:
$(printf '%b' "$DNS_YAML") access-points:
"${WIFI_SSID}":
password: "${WIFI_PASS}"
NETEOF
else
cat > "$NETPLAN_FILE" <<NETEOF
network:
version: 2
ethernets:
${IFACE}:
dhcp4: no
addresses:
- ${STATIC_IP}
routes:
- to: default
via: ${GATEWAY}
nameservers:
addresses:
$(printf '%b' "$DNS_YAML")
NETEOF
fi
info "Netplan config written to $NETPLAN_FILE"
header "Applying Configuration"
warn "Applying netplan config — will revert automatically if network is lost..."
netplan apply
CONNECTED=false
for i in $(seq 1 6); do
sleep 5
if ping -c 1 -W 2 "$GATEWAY" >/dev/null 2>&1; then
CONNECTED=true
break
fi
warn "Network check $i/6 failed, retrying..."
done
if $CONNECTED; then
info "Network connectivity confirmed — config applied permanently."
else
warn "No network connectivity detected after 30 seconds — reverting to backup config."
if [ -f "$BACKUP_FILE" ]; then
cp "$BACKUP_FILE" "$NETPLAN_FILE"
netplan apply
die "Config reverted to backup. Check your settings and try again."
else
die "No backup found to revert to. Restore $NETPLAN_FILE manually."
fi
fi
STATIC_ADDR="${STATIC_IP%%/*}"
echo ""
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
echo -e "${YELLOW} Network reconfigured.${NC}"
echo -e "${YELLOW} If you are connected via SSH, your session${NC}"
echo -e "${YELLOW} may drop. Reconnect to: ${STATIC_ADDR}${NC}"
echo -e "${YELLOW} Then run: ./setup.sh${NC}"
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
echo ""

47
setup.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "${GREEN}[+]${NC} $*"; }
die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
header() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}══════════════════════════════════════════${NC}"; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
header "TinyBoard Setup"
echo ""
echo " 1) Set up this device as a new spoke"
echo " 2) Onboard a new spoke from the hub"
echo " 3) Offboard a spoke from the hub"
echo " 4) Set up this device as a new hub"
echo ""
read -rp "Choose [1/2/3/4]: " CHOICE
case "$CHOICE" in
1)
[ "$(id -u)" -eq 0 ] || die "Spoke setup must be run as root"
info "Starting spoke setup..."
exec "$SCRIPT_DIR/spoke/setup-spoke.sh"
;;
2)
info "Starting hub onboarding..."
exec "$SCRIPT_DIR/hub/onboard-spoke.sh"
;;
3)
info "Starting hub offboarding..."
exec "$SCRIPT_DIR/hub/offboard-spoke.sh"
;;
4)
[ "$(id -u)" -eq 0 ] || die "Hub setup must be run as root"
info "Starting hub setup..."
exec "$SCRIPT_DIR/hub/setup-hub.sh"
;;
*)
die "Invalid choice"
;;
esac

359
spoke/setup-spoke.sh Normal file
View File

@@ -0,0 +1,359 @@
#!/usr/bin/env bash
set -euo pipefail
HUB_HOST=""
HUB_USER=""
SPOKE_USER=""
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SPOKE_DIR="$SCRIPT_DIR"
COMPOSE="$SPOKE_DIR/compose.yaml"
START_PORT=11111
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
}
retry_or_abort() {
local test_cmd="$1"
local fail_msg="$2"
while true; do
if eval "$test_cmd" 2>/dev/null; then
return 0
fi
echo ""
warn "$fail_msg"
echo -e " ${YELLOW}[R]${NC} Retry ${RED}[A]${NC} Abort"
read -rp "Choice: " CHOICE
case "${CHOICE,,}" in
r) info "Retrying..." ;;
a) die "Aborted." ;;
*) warn "Press R to retry or A to abort." ;;
esac
done
}
check_permissions() {
local file="$1"
local label="$2"
if [ ! -f "$file" ]; then
warn "Permission check: $label not found at $file"
return
fi
local perms
perms=$(stat -c "%a" "$file" 2>/dev/null || stat -f "%OLp" "$file" 2>/dev/null)
if [ -z "$perms" ]; then
warn "Could not read permissions for $label ($file)"
return
fi
local world="${perms: -1}"
local group="${perms: -2:1}"
if [ "$world" != "0" ] || [ "$group" != "0" ]; then
warn "UNSAFE PERMISSIONS on $label ($file): $perms — should be 600 or 400"
warn "Fixing permissions automatically..."
chmod 600 "$file"
info "Permissions fixed: $file is now 600"
else
info "Permissions OK: $label ($file) = $perms"
fi
}
[ "$(id -u)" -eq 0 ] || die "Run as root"
check_deps ip ssh ssh-keygen ssh-keyscan systemctl hostnamectl
read -rp "Hub hostname [oily.dad]: " HUB_HOST
HUB_HOST="${HUB_HOST:-oily.dad}"
read -rp "Hub SSH user [armbian]: " HUB_USER
HUB_USER="${HUB_USER:-armbian}"
read -rp "Spoke local user [armbian]: " SPOKE_USER
SPOKE_USER="${SPOKE_USER:-armbian}"
ARMBIAN_HOME="/home/$SPOKE_USER"
SSH_DIR="$ARMBIAN_HOME/.ssh"
header "TinyBoard Spoke Setup"
header "Detecting Package Manager"
if command -v apt-get >/dev/null 2>&1; then
PKG_MANAGER="apt"
PKG_INSTALL="apt-get install -y -q"
OPENSSH_PKG="openssh-server"
AUTOSSH_PKG="autossh"
info "Detected: apt (Debian/Ubuntu)"
apt-get update -q
elif command -v dnf >/dev/null 2>&1; then
PKG_MANAGER="dnf"
PKG_INSTALL="dnf install -y -q"
OPENSSH_PKG="openssh-server"
AUTOSSH_PKG="autossh"
info "Detected: dnf (Fedora/RHEL/Alma/Rocky)"
dnf check-update -q || true
elif command -v yum >/dev/null 2>&1; then
PKG_MANAGER="yum"
PKG_INSTALL="yum install -y -q"
OPENSSH_PKG="openssh-server"
AUTOSSH_PKG="autossh"
info "Detected: yum (older RHEL/CentOS)"
yum check-update -q || true
elif command -v pacman >/dev/null 2>&1; then
PKG_MANAGER="pacman"
PKG_INSTALL="pacman -S --noconfirm --quiet"
OPENSSH_PKG="openssh"
AUTOSSH_PKG="autossh"
info "Detected: pacman (Arch)"
pacman -Sy --quiet
else
die "No supported package manager found (apt, dnf, yum, pacman)"
fi
header "Installing Packages"
if ! command -v curl >/dev/null 2>&1; then
$PKG_INSTALL curl
fi
$PKG_INSTALL vim "$AUTOSSH_PKG" "$OPENSSH_PKG" git
info "Installing Docker..."
if ! command -v docker >/dev/null 2>&1; then
if [ "$PKG_MANAGER" = "apt" ]; then
$PKG_INSTALL docker.io docker-compose-plugin
else
curl -fsSL https://get.docker.com | bash
fi
else
warn "Docker already installed, skipping."
fi
if ! docker compose version >/dev/null 2>&1; then
if [ "$PKG_MANAGER" = "apt" ]; then
$PKG_INSTALL docker-compose-plugin
else
warn "docker compose not available — Docker install script should have included it."
fi
fi
info "Adding $SPOKE_USER to docker group..."
usermod -aG docker "$SPOKE_USER" 2>/dev/null || true
info "Enabling SSH server..."
if systemctl enable ssh 2>/dev/null; then
systemctl start ssh
elif systemctl enable sshd 2>/dev/null; then
systemctl start sshd
else
warn "Could not enable SSH service — please start it manually."
fi
SSHD_CONF="/etc/ssh/sshd_config"
header "Hostname Setup"
CURRENT_HOSTNAME=$(hostname)
echo -e "Current hostname: ${YELLOW}$CURRENT_HOSTNAME${NC}"
read -rp "Enter a hostname for this spoke (e.g. rocky, gouda, camembert): " SPOKE_NAME
SPOKE_NAME="${SPOKE_NAME:-$CURRENT_HOSTNAME}"
hostnamectl set-hostname "$SPOKE_NAME"
echo "$SPOKE_NAME" > /etc/hostname
info "Hostname set to: $SPOKE_NAME"
header "SSH Key Setup"
echo "How would you like to handle the SSH key for the tunnel to $HUB_HOST?"
echo " 1) Generate a new key automatically"
echo " 2) Use an existing key (paste the private key)"
echo ""
read -rp "Choose [1/2]: " KEY_CHOICE
case "$KEY_CHOICE" in
1)
read -rp "Key name [hubkey]: " KEY_NAME
KEY_NAME="${KEY_NAME:-hubkey}"
KEY_PATH="$SSH_DIR/$KEY_NAME"
mkdir -p "$SSH_DIR"
chown "$SPOKE_USER":"$SPOKE_USER" "$SSH_DIR"
chmod 700 "$SSH_DIR"
if [ -f "$KEY_PATH" ]; then
warn "Key $KEY_PATH already exists, using it."
else
info "Generating new ED25519 key..."
sudo -u "$SPOKE_USER" ssh-keygen -t ed25519 -f "$KEY_PATH" -N ""
chown "$SPOKE_USER":"$SPOKE_USER" "$KEY_PATH" "$KEY_PATH.pub"
chmod 600 "$KEY_PATH"
fi
echo ""
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
echo -e "${YELLOW} Send this public key to the hub owner${NC}"
echo -e "${YELLOW} and ask them to add it to ${HUB_USER}@${HUB_HOST} authorized_keys:${NC}"
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
cat "$KEY_PATH.pub"
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
echo ""
read -rp "Press ENTER once the key has been added to ${HUB_HOST}..."
;;
2)
read -rp "Enter a name for the key file [hubkey]: " KEY_NAME
KEY_NAME="${KEY_NAME:-hubkey}"
KEY_PATH="$SSH_DIR/$KEY_NAME"
mkdir -p "$SSH_DIR"
chown "$SPOKE_USER":"$SPOKE_USER" "$SSH_DIR"
chmod 700 "$SSH_DIR"
echo "Paste the private key content below, then press ENTER and CTRL+D:"
KEY_CONTENT=$(cat | tr -d '\r')
printf '%s\n' "$KEY_CONTENT" > "$KEY_PATH"
chown "$SPOKE_USER":"$SPOKE_USER" "$KEY_PATH"
chmod 600 "$KEY_PATH"
info "Key saved to $KEY_PATH"
;;
*)
die "Invalid choice"
;;
esac
header "Password Authentication"
read -rp "Disable password auth for $SPOKE_USER and use keys only? [Y/n]: " DISABLE_PASS
DISABLE_PASS="${DISABLE_PASS:-y}"
if [[ "${DISABLE_PASS,,}" == "y" ]]; then
if [ ! -f "$KEY_PATH" ]; then
warn "No key found at $KEY_PATH — skipping password auth disable to avoid lockout."
else
if grep -q "^PasswordAuthentication" "$SSHD_CONF"; then
sed -i "s/^PasswordAuthentication.*/PasswordAuthentication no/" "$SSHD_CONF"
else
echo "PasswordAuthentication no" >> "$SSHD_CONF"
fi
if grep -q "^PubkeyAuthentication" "$SSHD_CONF"; then
sed -i "s/^PubkeyAuthentication.*/PubkeyAuthentication yes/" "$SSHD_CONF"
else
echo "PubkeyAuthentication yes" >> "$SSHD_CONF"
fi
info "Password authentication disabled for $SPOKE_USER."
echo ""
warn "Restarting SSH will apply the new settings."
warn "If you are connected via SSH, your session may drop."
warn "Make sure you can reconnect using your key before continuing."
read -rp "Press ENTER to restart SSH or CTRL+C to abort..."
if systemctl restart ssh 2>/dev/null; then
info "SSH restarted."
elif systemctl restart sshd 2>/dev/null; then
info "SSH restarted."
else
warn "Could not restart SSH — please restart it manually."
fi
fi
else
info "Password authentication left enabled."
fi
info "Checking SSH key permissions..."
check_permissions "$KEY_PATH" "spoke SSH private key"
if [ -f "$KEY_PATH.pub" ]; then
check_permissions "$KEY_PATH.pub" "spoke SSH public key"
fi
info "Scanning hub host key..."
sudo -u "$SPOKE_USER" touch "$SSH_DIR/known_hosts"
chown "$SPOKE_USER":"$SPOKE_USER" "$SSH_DIR/known_hosts"
chmod 600 "$SSH_DIR/known_hosts"
sudo -u "$SPOKE_USER" ssh-keyscan -H "$HUB_HOST" >> "$SSH_DIR/known_hosts" 2>/dev/null
check_permissions "$SSH_DIR/known_hosts" "known_hosts"
header "Testing SSH Connection"
info "Testing connection to $HUB_HOST..."
retry_or_abort \
"sudo -u \"$SPOKE_USER\" ssh -i \"$KEY_PATH\" -o BatchMode=yes -o ConnectTimeout=10 \"$HUB_USER@$HUB_HOST\" exit" \
"SSH connection to $HUB_HOST failed. Check that the hub owner added your public key."
header "Finding Available Tunnel Port"
info "Scanning for a free port on $HUB_HOST starting from $START_PORT..."
TUNNEL_PORT=""
for PORT in $(seq "$START_PORT" $((START_PORT + 20))); do
RESULT=$(sudo -u "$SPOKE_USER" ssh -i "$KEY_PATH" "$HUB_USER@$HUB_HOST" "ss -tlnp | grep :$PORT" 2>/dev/null || true)
if [ -z "$RESULT" ]; then
TUNNEL_PORT="$PORT"
info "Port $TUNNEL_PORT is available."
break
else
warn "Port $PORT is in use, trying next..."
fi
done
[ -n "$TUNNEL_PORT" ] || die "Could not find a free port between $START_PORT and $((START_PORT + 20)). Ask the hub owner to free up a port."
header "Configuring compose.yaml"
info "Setting port to $TUNNEL_PORT and key to $KEY_NAME..."
sed -i "s|-R [0-9]*:localhost:22|-R ${TUNNEL_PORT}:localhost:22|g" "$COMPOSE"
sed -i "s|-i /home/[^ ]*/\.ssh/[^ ]*|-i ${SSH_DIR}/${KEY_NAME}|g" "$COMPOSE"
sed -i "s|/home/[^/]*/\.ssh/[^:]*:/home/[^/]*/\.ssh/[^:]*|${SSH_DIR}/${KEY_NAME}:${SSH_DIR}/${KEY_NAME}|g" "$COMPOSE"
sed -i "s|container_name: spoke-autossh|container_name: ${SPOKE_NAME}-autossh|g" "$COMPOSE"
sed -i "s|container_name: spoke-syncthing|container_name: ${SPOKE_NAME}-syncthing|g" "$COMPOSE"
sed -i "s|hostname: spoke-syncthing|hostname: ${SPOKE_NAME}-syncthing|g" "$COMPOSE"
sed -i '/^version:/d' "$COMPOSE"
SYNCTHING_MOUNT="$ARMBIAN_HOME/st"
mkdir -p "$SYNCTHING_MOUNT"
chown "$SPOKE_USER":"$SPOKE_USER" "$SYNCTHING_MOUNT"
header "Building Docker Image"
cd "$SPOKE_DIR"
docker build \
--build-arg UID="$(id -u "$SPOKE_USER")" \
--build-arg GID="$(id -g "$SPOKE_USER")" \
-t spoke-autossh .
header "Starting Containers"
docker compose up -d
info "Waiting for tunnel to establish..."
sleep 6
LOGS=$(docker logs "${SPOKE_NAME}-autossh" 2>&1 || docker logs spoke-autossh 2>&1 || true)
if echo "$LOGS" | grep -q "remote port forwarding failed"; then
warn "Tunnel failed — port $TUNNEL_PORT may have been taken between check and connect."
warn "Try running: docker compose down && docker compose up -d"
warn "Or re-run this script."
else
info "Tunnel is up on port $TUNNEL_PORT."
fi
header "Setup Complete"
echo -e " Spoke name: ${GREEN}$SPOKE_NAME${NC}"
echo -e " Tunnel port: ${GREEN}$TUNNEL_PORT${NC} on $HUB_HOST"
echo -e " SSH key: ${GREEN}$KEY_PATH${NC}"
echo ""
echo -e "${YELLOW}The hub owner needs to do the following on ${HUB_HOST}:${NC}"
echo ""
echo " 1. Generate a hub->spoke key:"
echo " ssh-keygen -t ed25519 -f ~/.ssh/armbian-${SPOKE_NAME}-$(date +%Y%m)"
echo ""
echo " 2. Copy it to this spoke through the tunnel:"
echo " ssh-copy-id -i ~/.ssh/armbian-${SPOKE_NAME}-$(date +%Y%m).pub -p $TUNNEL_PORT ${HUB_USER}@localhost"
echo ""
echo " 3. Add an rclone remote in ~/.config/rclone/rclone.conf:"
echo " [${SPOKE_NAME}-remote]"
echo " type = sftp"
echo " host = localhost"
echo " port = $TUNNEL_PORT"
echo " key_file = /home/$HUB_USER/.ssh/armbian-${SPOKE_NAME}-$(date +%Y%m)"
echo " shell_type = unix"
echo " md5sum_command = md5sum"
echo " sha1sum_command = sha1sum"
echo ""