Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ee67739f7 | ||
|
|
39f8f64351 | ||
|
|
e924579b2e | ||
|
|
912e553e06 | ||
|
|
98986e615b | ||
|
|
0e792be751 | ||
|
|
835793d396 | ||
|
|
11f9586c5e | ||
|
|
3e351f925d | ||
|
|
a197b7881b | ||
|
|
60feeca65e | ||
|
|
88fabcf25f | ||
|
|
51f661766f | ||
|
|
5326823b81 | ||
|
|
0f76283605 | ||
|
|
a02a83cae4 | ||
|
|
4a1983d46d | ||
|
|
395ab4ed0e | ||
|
|
4c08f3b389 | ||
|
|
ccd324dc79 | ||
|
|
664bdeaed4 | ||
|
|
ae49c58b13 | ||
|
|
119b747dda | ||
|
|
ea72b14696 | ||
|
|
26110ce8d3 | ||
|
|
58f6445c72 | ||
|
|
08799f0f7f | ||
|
|
a79b1c59b8 | ||
|
|
7e64156026 | ||
|
|
3d366cd74a | ||
|
|
d080db1db8 | ||
|
|
37e3e91239 | ||
|
|
7676a907ee | ||
|
|
e5ecdca3ff | ||
|
|
50fb313f9a | ||
|
|
d21997af43 | ||
|
|
95a56ef4f0 | ||
|
|
b706dd211d | ||
|
|
f3a3f66982 | ||
|
|
384cf476ff | ||
|
|
b8d2a3e5bc | ||
|
|
a49f830ed2 | ||
|
|
fe7f77171f | ||
|
|
288aa698d0 | ||
|
|
96c737709c | ||
|
|
2c8df6993d | ||
|
|
c86dca283f | ||
|
|
9015ff46c9 | ||
|
|
87c08fb543 | ||
|
|
7bdafd316c | ||
|
|
114c97a1cb | ||
|
|
c71ad59629 | ||
|
|
2abd6ac6a4 | ||
|
|
ccd9b205b8 | ||
|
|
f6c2c79a70 | ||
|
|
cf8a10818a | ||
|
|
fefd082af2 |
308
README.md
308
README.md
@@ -1,208 +1,194 @@
|
||||
# TinyBoard Hub-Spoke File Sharing System
|
||||
# TinyBoard
|
||||
|
||||
A hub-spoke architecture for secure file sharing over SSH tunnels using autossh and rclone.
|
||||
|
||||
## Architecture Overview
|
||||
Spokes are ARM devices (e.g. OrangePi, Raspberry Pi) running Armbian that establish reverse SSH tunnels to a central hub server. The hub mounts spoke filesystems via SFTP using rclone, making files accessible across all devices without exposing them to the internet.
|
||||
|
||||
This system implements a hub-and-spoke model where:
|
||||
- **Spokes**: Raspberry Pi devices running Armbian that establish reverse SSH tunnels to the hub
|
||||
- **Hub**: Central server that mounts spoke filesystems via SFTP using rclone
|
||||
---
|
||||
|
||||
### Key Components
|
||||
## Quickstart
|
||||
|
||||
1. **Spoke Side** (`spoke/` directory):
|
||||
- Docker-based autossh tunnel container
|
||||
- Configuration files for spoke setup
|
||||
- Hostname assignment based on MAC address
|
||||
### Setting up a new Hub
|
||||
|
||||
2. **Hub Side** (`hub/` directory):
|
||||
- Rclone SFTP mount configuration
|
||||
- Systemd user service for automatic mounting
|
||||
On a fresh Debian/Ubuntu VPS or server:
|
||||
|
||||
3. **Management Script** (`hubspoke-helper.sh`):
|
||||
- Unified interface for managing both hub and spoke components
|
||||
```bash
|
||||
apt install git
|
||||
git clone https://gut.oily.dad/justin/tinyboard
|
||||
cd tinyboard
|
||||
./setup.sh # option 4 (setup new hub)
|
||||
```
|
||||
|
||||
### Setting up a new Spoke
|
||||
|
||||
On a fresh Armbian device:
|
||||
|
||||
1. Modify `spoke/armbian.not_logged_in_yet` accordingly, then drop it onto the SD card as `/root/.not_logged_in_yet` before first boot (WiFi credentials) — see [Armbian Autoconfig docs](https://docs.armbian.com/User-Guide_Autoconfig/)
|
||||
2. Boot, SSH in as root
|
||||
3. Run:
|
||||
|
||||
```bash
|
||||
apt install git
|
||||
git clone https://gut.oily.dad/justin/tinyboard
|
||||
cd tinyboard
|
||||
./setup.sh # option 0 (configure network)
|
||||
./setup.sh # option 1 (configure new spoke)
|
||||
```
|
||||
|
||||
### Onboarding a Spoke from the Hub
|
||||
|
||||
Once the spoke tunnel is up, run on the hub:
|
||||
|
||||
```bash
|
||||
cd tinyboard
|
||||
./setup.sh # option 2 (onboard spoke)
|
||||
```
|
||||
|
||||
### Offboarding a Spoke from the Hub
|
||||
|
||||
```bash
|
||||
cd tinyboard
|
||||
./setup.sh # option 3 (offboard spoke)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
[ Spoke ] [ Hub ]
|
||||
OrangePi / RPi VPS / Server
|
||||
Armbian Any Linux
|
||||
|
||||
autossh container ──────────► sshd (GatewayPorts)
|
||||
reverse tunnel port 111xx
|
||||
|
||||
rclone SFTP mount
|
||||
~/mnt/<spoke-name>/
|
||||
```
|
||||
|
||||
Spokes initiate outbound SSH connections to the hub, creating reverse tunnels. The hub then uses rclone to mount each spoke's filesystem over SFTP through the tunnel. No inbound ports need to be open on the spoke.
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
tinyboard/
|
||||
├── hubspoke-helper.sh # Main management script
|
||||
├── hub/
|
||||
│ └── rclone.conf # Rclone SFTP configuration
|
||||
├── setup.sh ← entry point
|
||||
├── spoke/
|
||||
│ ├── compose.yaml # Docker Compose for autossh tunnel
|
||||
│ ├── Dockerfile # autossh container image
|
||||
│ ├── autohostname.sh # Hostname assignment by MAC address
|
||||
│ ├── aptprimary.sh # Initial package installation
|
||||
│ ├── clean_sensitive.sh # Clean WiFi/password from configs
|
||||
│ └── armb-not_logged_in_yet # Armbian first-boot configuration
|
||||
└── README.md # This file
|
||||
│ ├── setup-network.sh ← configure static IP before setup
|
||||
│ ├── setup-spoke.sh ← automated spoke setup
|
||||
│ ├── compose.yaml ← Docker Compose for autossh + syncthing
|
||||
│ ├── Dockerfile ← autossh container
|
||||
│ └── armbian.not_logged_in_yet ← Armbian first-boot WiFi config template
|
||||
└── hub/
|
||||
├── setup-hub.sh ← automated hub setup
|
||||
├── onboard-spoke.sh ← add a new spoke to the hub
|
||||
└── offboard-spoke.sh ← remove a spoke from the hub
|
||||
```
|
||||
|
||||
## Key File Handling (Manual Setup)
|
||||
---
|
||||
|
||||
**IMPORTANT**: The following files must be manually created/configured as they contain sensitive information:
|
||||
## Setup Scripts
|
||||
|
||||
### SSH Keys
|
||||
- `~/.ssh/oilykey2026` on spokes (referenced in `spoke/compose.yaml`)
|
||||
- `~/.ssh/armbian-brie-202604` on hub (referenced in `hub/rclone.conf`)
|
||||
- These keys must be manually generated and distributed
|
||||
### `setup.sh`
|
||||
Entry point. Presents a menu:
|
||||
0. Reconfigure network (static IP via netplan — SSH session will drop, reconnect)
|
||||
1. Set up this device as a new spoke
|
||||
2. Onboard a new spoke from the hub
|
||||
3. Offboard a spoke from the hub
|
||||
4. Set up this device as a new hub
|
||||
|
||||
### Rclone Configuration
|
||||
- `~/.config/rclone/rclone.conf` on hub must be manually created
|
||||
- Use `hub/rclone.conf` as a template
|
||||
- Update host, port, and key_file paths as needed
|
||||
- Manually create rclone mount and permission it (`/mnt/hub` for example)
|
||||
### `spoke/setup-network.sh`
|
||||
Run as root on a new spoke before `setup.sh`. Configures a static IP via netplan. Supports both WiFi and wired interfaces. Automatically reverts if network connectivity is lost after applying the new config.
|
||||
|
||||
### Systemd Service Files
|
||||
- `~/.config/systemd/user/rclone-mount@.service` must be manually copied from `hub/rclone-mount@.service`
|
||||
### `spoke/setup-spoke.sh`
|
||||
Run as root on a new spoke. Handles:
|
||||
- Package installation (apt/dnf/yum/pacman)
|
||||
- Docker installation
|
||||
- SSH server setup
|
||||
- Hostname configuration
|
||||
- SSH key generation and hub authorization
|
||||
- Tunnel port auto-detection on the hub
|
||||
- Docker image build and container start
|
||||
- Optional password auth disable
|
||||
|
||||
## Spoke Setup (Raspberry Pi / Armbian)
|
||||
### `hub/setup-hub.sh`
|
||||
Run as root on a new hub server. Handles:
|
||||
- Package installation (apt/dnf/yum/pacman)
|
||||
- rclone installation
|
||||
- Hub user creation
|
||||
- SSH server configuration (GatewayPorts, AllowTcpForwarding)
|
||||
- FUSE configuration
|
||||
- rclone config directory setup
|
||||
- Optional password auth disable
|
||||
|
||||
### Initial Setup
|
||||
1. Write Armbian minimal image to SD card
|
||||
2. Copy `spoke/armb-not_logged_in_yet` to SD card root `/root/.not_logged_in_yet` (contains WiFi credentials)
|
||||
3. Boot device, SSH in as root with password "1234"
|
||||
4. After first login and setup tasks, `.not_logged_in_yet` will be processed for root and armbian user credentials
|
||||
5. Clone this repository: `git clone <repo-url>`
|
||||
6. Run `spoke/aptprimary.sh` to install required packages
|
||||
7. Run `spoke/autohostname.sh` to assign hostname based on MAC address
|
||||
8. Reboot and test as armbian user
|
||||
### `hub/onboard-spoke.sh`
|
||||
Run as the hub user after a spoke connects. Handles:
|
||||
- SSH key generation and deployment to spoke
|
||||
- rclone remote configuration
|
||||
- Spoke registration in `~/.config/tinyboard/spokes`
|
||||
- Per-spoke crontab entry for auto-mount on reboot
|
||||
|
||||
### SSH Key Setup
|
||||
1. Generate SSH key pair on hub: `ssh-keygen -t ed25519 -f ~/.ssh/armbian-brie-202604`
|
||||
2. Copy public key to spoke: `ssh-copy-id -i ~/.ssh/armbian-brie-202604.pub armbian@<spoke-ip>`
|
||||
3. Generate spoke key: `ssh-keygen -t ed25519 -f ~/.ssh/oilykey2026`
|
||||
4. Copy public key to hub for reverse tunnel authentication
|
||||
### `hub/offboard-spoke.sh`
|
||||
Run as the hub user to remove a spoke. Handles:
|
||||
- Unmounting the spoke filesystem
|
||||
- Removing the crontab entry
|
||||
- Removing the rclone remote
|
||||
- Optionally removing the hub SSH key
|
||||
- Removing from the spoke registry
|
||||
|
||||
### Docker Tunnel Setup
|
||||
```bash
|
||||
# Build the autossh container
|
||||
./hubspoke-helper.sh spoke build
|
||||
---
|
||||
|
||||
# Start the tunnel
|
||||
./hubspoke-helper.sh spoke start
|
||||
## Spoke Registry
|
||||
|
||||
# Check status
|
||||
./hubspoke-helper.sh spoke status
|
||||
The hub maintains a registry of connected spokes at `~/.config/tinyboard/spokes`:
|
||||
|
||||
# View logs
|
||||
./hubspoke-helper.sh spoke logs
|
||||
```
|
||||
rocky 11113 /home/armbian/.ssh/armbian-rocky-202504 /home/armbian/mnt/rocky
|
||||
gouda 11114 /home/armbian/.ssh/armbian-gouda-202504 /home/armbian/mnt/gouda
|
||||
```
|
||||
|
||||
## Hub Setup (Central Server)
|
||||
Each spoke gets its own mount point at `~/mnt/<spoke-name>/` and a dedicated rclone crontab entry.
|
||||
|
||||
### Rclone Configuration
|
||||
1. Install rclone: `apt install rclone fuse`
|
||||
2. Create config directory: `mkdir -p ~/.config/rclone`
|
||||
3. Copy and customize `hub/rclone.conf` to `~/.config/rclone/rclone.conf`
|
||||
4. Update key_file path to point to your SSH private key
|
||||
---
|
||||
|
||||
### FUSE Configuration
|
||||
```bash
|
||||
# Allow other users to access mounts (if needed)
|
||||
sudo sed -i 's/^#user_allow_other/user_allow_other/' /etc/fuse.conf
|
||||
## Security
|
||||
|
||||
# Add user to fuse group
|
||||
sudo groupadd fuse
|
||||
sudo usermod -aG fuse $USER
|
||||
```
|
||||
- All communication is over SSH tunnels — no spoke ports exposed to the internet
|
||||
- SSH keys are used for all authentication
|
||||
- Scripts check and auto-fix unsafe file permissions (600/400)
|
||||
- Password authentication can be disabled during setup
|
||||
- Scripts refuse to disable password auth if no authorized keys are present (lockout prevention)
|
||||
- Netplan changes are verified with a 30-second connectivity check before being made permanent
|
||||
|
||||
## Usage
|
||||
---
|
||||
|
||||
### Managing Spoke Tunnels
|
||||
- Docker on spoke should handle autostart of spoke tunnel
|
||||
- Syncthing can be combined in this image
|
||||
- Rename syncthing image and host names per-device in the compose file.
|
||||
## Sensitive Files
|
||||
|
||||
```bash
|
||||
# Build autossh container
|
||||
./hubspoke-helper.sh spoke build
|
||||
Before committing, ensure the following do not contain real credentials:
|
||||
|
||||
# Start/stop/restart tunnel
|
||||
./hubspoke-helper.sh spoke start
|
||||
./hubspoke-helper.sh spoke stop
|
||||
./hubspoke-helper.sh spoke restart
|
||||
- `spoke/armbian.not_logged_in_yet` — contains WiFi SSID, password, and user passwords
|
||||
|
||||
# Check status and logs
|
||||
./hubspoke-helper.sh spoke status
|
||||
./hubspoke-helper.sh spoke logs
|
||||
|
||||
# Show manual autossh command
|
||||
./hubspoke-helper.sh spoke show-cmd
|
||||
```
|
||||
|
||||
### Managing Hub Mounts
|
||||
|
||||
#### Crontab entry:
|
||||
```
|
||||
@reboot /home/armbian/tinyboard/hubspoke-helper.sh hub start-background
|
||||
```
|
||||
|
||||
#### Deprecated: systemd
|
||||
```bash
|
||||
# Install systemd service (after manual file placement)
|
||||
./hubspoke-helper.sh hub install
|
||||
|
||||
# Start/stop rclone mount
|
||||
./hubspoke-helper.sh hub start
|
||||
./hubspoke-helper.sh hub stop
|
||||
|
||||
# Check service status
|
||||
./hubspoke-helper.sh hub status
|
||||
|
||||
# Manual mount/unmount for testing
|
||||
./hubspoke-helper.sh hub mount
|
||||
./hubspoke-helper.sh hub unmount
|
||||
```
|
||||
|
||||
## Configuration Variables
|
||||
|
||||
Environment variables can override defaults:
|
||||
- `TUNNEL_DIR`: Directory containing spoke Docker files (default: `~/tinyboard/spoke`)
|
||||
- `COMPOSE_FILE`: Docker compose file path (default: `$TUNNEL_DIR/compose.yaml`)
|
||||
- `RCLONE_REMOTE`: Rclone remote name (default: `brie-remote`)
|
||||
- `MOUNT_POINT`: Mount point on hub (default: `~/mnt/hub`)
|
||||
|
||||
## Security Notes
|
||||
|
||||
1. **SSH Keys**: Always use strong key pairs and protect private keys
|
||||
2. **Configuration Files**: Use `spoke/clean_sensitive.sh` to remove WiFi credentials before committing
|
||||
3. **Firewall**: Ensure proper firewall rules on hub (port 11111 for reverse tunnels)
|
||||
4. **User Permissions**: Run services with minimal required privileges
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Spoke Tunnel Issues
|
||||
- Check Docker container logs: `./hubspoke-helper.sh spoke logs`
|
||||
- Verify SSH key permissions: `chmod 600 ~/.ssh/oilykey2026`
|
||||
- Test SSH connection manually: `ssh -p 11111 armbian@localhost`
|
||||
### `apt update` fails with beta.armbian.com error
|
||||
|
||||
### Hub Mount Issues
|
||||
- Check service status: `./hubspoke-helper.sh hub status`
|
||||
- Test rclone manually: `rclone lsd brie-sftp:`
|
||||
- Verify fuse configuration: `ls -la /etc/fuse.conf`
|
||||
- Check user groups: `groups $USER`
|
||||
On some Armbian images, a beta apt repository is enabled by default and may cause `apt update` to fail. Comment it out:
|
||||
|
||||
### Network Issues
|
||||
- Ensure spokes can reach hub on SSH port (22)
|
||||
- Verify reverse tunnel port (11111) is not blocked by firewall
|
||||
- Check DNS resolution on spokes for hub hostname
|
||||
```bash
|
||||
grep -r "beta.armbian" /etc/apt/sources.list /etc/apt/sources.list.d/
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
Open the file that contains it (usually `/etc/apt/sources.list.d/armbian.sources`) and comment out or remove the line referencing `beta.armbian.com`, then run `apt update` again.
|
||||
|
||||
### Updating Configuration
|
||||
1. Update `spoke/compose.yaml` for new spoke hostnames
|
||||
2. Update `hub/rclone.conf` for new spoke connections
|
||||
3. Update `spoke/autohostname.sh` for new MAC addresses
|
||||
---
|
||||
|
||||
### Adding New Spokes
|
||||
1. Follow Spoke Setup steps for new device
|
||||
2. Add MAC address to `spoke/autohostname.sh`
|
||||
3. Update hub's SSH authorized_keys with new spoke public key
|
||||
4. Add new rclone remote configuration if needed
|
||||
## Requirements
|
||||
|
||||
## License
|
||||
|
||||
This project is for personal use. Adapt as needed for your environment.
|
||||
**Spoke:** Armbian (Debian-based), ARM device, Docker, autossh, git
|
||||
|
||||
**Hub:** Any Linux server (Debian/Ubuntu/RHEL/Arch), rclone, fuse, openssh-server
|
||||
|
||||
129
hub/offboard-spoke.sh
Executable file
129
hub/offboard-spoke.sh
Executable file
@@ -0,0 +1,129 @@
|
||||
#!/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 ""
|
||||
139
hub/onboard-spoke.sh
Executable file
139
hub/onboard-spoke.sh
Executable 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 ""
|
||||
@@ -1,18 +0,0 @@
|
||||
[brie-remote]
|
||||
type = sftp
|
||||
host = localhost
|
||||
port = 11111
|
||||
key_file = /home/armbian/.ssh/armbian-brie-202604
|
||||
shell_type = unix
|
||||
md5sum_command = md5sum
|
||||
sha1sum_command = sha1sum
|
||||
|
||||
#[new-remote]
|
||||
#type = sftp
|
||||
#host = localhost
|
||||
#port = 11112
|
||||
#key_file = /home/armbian/.ssh/a new priv key for tunnel back to new spoke
|
||||
#shell_type = unix
|
||||
#md5sum_command = md5sum
|
||||
#sha1sum_command = sha1sum
|
||||
|
||||
264
hub/setup-hub.sh
Executable file
264
hub/setup-hub.sh
Executable 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
|
||||
|
||||
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 ""
|
||||
@@ -1,221 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# hubspoke-helper.sh - Manage hub/spoke rclone mounts
|
||||
# Assumes spoke Docker files exist in ~/autossh-tunnel/
|
||||
# Simplified hub mount uses direct rclone commands (no systemd services)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Configuration (override with env vars if needed)
|
||||
# ------------------------------------------------------------
|
||||
TUNNEL_DIR="${TUNNEL_DIR:-$HOME/tinyboard/spoke}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-$TUNNEL_DIR/compose.yaml}"
|
||||
RCLONE_REMOTE="${RCLONE_REMOTE:-brie-remote}"
|
||||
MOUNT_POINT="${MOUNT_POINT:-/mnt/hub/$RCLONE_REMOTE}"
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Usage
|
||||
# ------------------------------------------------------------
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 {hub|spoke} {action}
|
||||
|
||||
SPOKE ACTIONS (docker-based, no systemd):
|
||||
build Build the autossh image (run once)
|
||||
start Start the tunnel container
|
||||
stop Stop the tunnel container
|
||||
restart Restart the tunnel container
|
||||
status Show container status
|
||||
logs Show container logs
|
||||
show-cmd Show manual autossh command (non-docker)
|
||||
|
||||
HUB ACTIONS (simplified rclone mount - no systemd):
|
||||
install Show simplified setup instructions
|
||||
start Start rclone mount in background (uses nohup)
|
||||
start-background Start rclone mount in background (for crontab)
|
||||
stop Stop rclone mount
|
||||
status Check mount status
|
||||
mount Manual foreground mount (testing)
|
||||
unmount Unmount manually
|
||||
|
||||
EXAMPLES:
|
||||
$0 spoke build
|
||||
$0 spoke start
|
||||
$0 hub install
|
||||
$0 hub start
|
||||
EOF
|
||||
}
|
||||
|
||||
die() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Spoke actions (docker)
|
||||
# ------------------------------------------------------------
|
||||
spoke_build() {
|
||||
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||
die "docker-compose.yaml not found at $COMPOSE_FILE"
|
||||
fi
|
||||
cd "$TUNNEL_DIR"
|
||||
docker build --build-arg UID=$(id -u armbian) --build-arg GID=$(id -g armbian) -t spoke-autossh .
|
||||
echo "Image built. Use '$0 spoke start' to run."
|
||||
}
|
||||
|
||||
spoke_start() {
|
||||
cd "$TUNNEL_DIR"
|
||||
docker-compose up -d
|
||||
}
|
||||
|
||||
spoke_stop() {
|
||||
cd "$TUNNEL_DIR"
|
||||
docker-compose down
|
||||
}
|
||||
|
||||
spoke_restart() {
|
||||
cd "$TUNNEL_DIR"
|
||||
docker-compose restart
|
||||
}
|
||||
|
||||
spoke_status() {
|
||||
docker ps --filter name=spoke-autossh --format "table {{.Names}}\t{{.Status}}"
|
||||
}
|
||||
|
||||
spoke_logs() {
|
||||
cd "$TUNNEL_DIR"
|
||||
docker-compose logs --tail=50 -f
|
||||
}
|
||||
|
||||
spoke_show_cmd() {
|
||||
cat <<EOF
|
||||
Manual autossh command (run on spoke):
|
||||
autossh -M 0 -NT -o "ServerAliveInterval=60" -o "ServerAliveCountMax=3" \\
|
||||
-R 11111:localhost:22 -i ~/.ssh/oilykey2026 armbian@oily.dad
|
||||
EOF
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Hub actions (simplified - no systemd templates)
|
||||
# ------------------------------------------------------------
|
||||
hub_install() {
|
||||
echo "Simplified hub setup:"
|
||||
echo ""
|
||||
echo "1. Ensure /etc/fuse.conf has 'user_allow_other' uncommented:"
|
||||
echo " sudo sed -i 's/^#user_allow_other/user_allow_other/' /etc/fuse.conf"
|
||||
echo ""
|
||||
echo "2. Ensure you're in the 'fuse' group:"
|
||||
echo " sudo usermod -aG fuse $USER"
|
||||
echo " (You may need to log out and back in for this to take effect)"
|
||||
echo ""
|
||||
echo "3. Create mount point directory:"
|
||||
echo " mkdir -p \"$MOUNT_POINT\""
|
||||
echo ""
|
||||
echo "4. Test manual mount:"
|
||||
echo " $0 hub mount"
|
||||
echo ""
|
||||
echo "5. For auto-start, consider adding to crontab with @reboot:"
|
||||
echo " crontab -e"
|
||||
echo " Add: @reboot $0 hub start-background"
|
||||
echo ""
|
||||
echo "Note: This simplified version doesn't use systemd services."
|
||||
}
|
||||
|
||||
hub_start() {
|
||||
echo "Starting rclone mount in background..."
|
||||
mkdir -p "$MOUNT_POINT"
|
||||
nohup rclone mount "${RCLONE_REMOTE}:" "$MOUNT_POINT" \
|
||||
--config "${HOME}/.config/rclone/rclone.conf" \
|
||||
--vfs-cache-mode writes \
|
||||
--allow-other \
|
||||
--daemon >/dev/null 2>&1 &
|
||||
echo "Mount started in background (PID: $!)"
|
||||
echo "Check status with: $0 hub status"
|
||||
}
|
||||
|
||||
hub_start_background() {
|
||||
# Internal function for crontab/auto-start
|
||||
mkdir -p "$MOUNT_POINT"
|
||||
rclone mount "${RCLONE_REMOTE}:" "$MOUNT_POINT" \
|
||||
--config "${HOME}/.config/rclone/rclone.conf" \
|
||||
--vfs-cache-mode writes \
|
||||
--allow-other \
|
||||
--daemon
|
||||
}
|
||||
|
||||
hub_stop() {
|
||||
echo "Stopping rclone mount..."
|
||||
if hub_unmount; then
|
||||
echo "Mount stopped."
|
||||
else
|
||||
echo "Could not unmount. Trying force unmount..."
|
||||
fusermount -uz "$MOUNT_POINT" 2>/dev/null && echo "Force unmounted." || echo "Still could not unmount."
|
||||
fi
|
||||
}
|
||||
|
||||
hub_status() {
|
||||
if mountpoint -q "$MOUNT_POINT" 2>/dev/null; then
|
||||
echo "Mount point $MOUNT_POINT is mounted."
|
||||
mount | grep "$MOUNT_POINT"
|
||||
else
|
||||
echo "Mount point $MOUNT_POINT is NOT mounted."
|
||||
echo "Check if rclone process is running:"
|
||||
pgrep -af rclone || echo "No rclone mount processes found."
|
||||
fi
|
||||
}
|
||||
|
||||
hub_mount() {
|
||||
mkdir -p "$MOUNT_POINT"
|
||||
echo "Mounting in foreground. Press Ctrl+C to unmount."
|
||||
rclone mount "${RCLONE_REMOTE}:" "$MOUNT_POINT" \
|
||||
--config "${HOME}/.config/rclone/rclone.conf" \
|
||||
--vfs-cache-mode writes \
|
||||
--allow-other
|
||||
}
|
||||
|
||||
hub_unmount() {
|
||||
fusermount -u "$MOUNT_POINT" 2>/dev/null && echo "Unmounted." || echo "Not mounted."
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ------------------------------------------------------------
|
||||
if [ $# -lt 2 ]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROLE="$1"
|
||||
ACTION="$2"
|
||||
|
||||
case "$ROLE" in
|
||||
spoke)
|
||||
case "$ACTION" in
|
||||
build) spoke_build ;;
|
||||
start) spoke_start ;;
|
||||
stop) spoke_stop ;;
|
||||
restart) spoke_restart ;;
|
||||
status) spoke_status ;;
|
||||
logs) spoke_logs ;;
|
||||
show-cmd) spoke_show_cmd ;;
|
||||
*) die "Unknown action for spoke: $ACTION" ;;
|
||||
esac
|
||||
;;
|
||||
hub)
|
||||
case "$ACTION" in
|
||||
install) hub_install ;;
|
||||
start) hub_start ;;
|
||||
start-background) hub_start_background ;;
|
||||
stop) hub_stop ;;
|
||||
status) hub_status ;;
|
||||
mount) hub_mount ;;
|
||||
unmount) hub_unmount ;;
|
||||
*) die "Unknown action for hub: $ACTION" ;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
53
setup.sh
Executable file
53
setup.sh
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/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 " 0) Reconfigure network"
|
||||
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 [0/1/2/3/4]: " CHOICE
|
||||
|
||||
case "$CHOICE" in
|
||||
0)
|
||||
[ "$(id -u)" -eq 0 ] || die "Network reconfiguration must be run as root"
|
||||
info "Starting network reconfiguration..."
|
||||
exec "$SCRIPT_DIR/spoke/setup-network.sh"
|
||||
;;
|
||||
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
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Need armbian-config?
|
||||
|
||||
apt install -y vim
|
||||
apt install -y autossh
|
||||
apt install -y docker.io docker-cli docker-compose
|
||||
usermod -aG docker armbian
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Copy this along with .not_logged_in_yet to armbian root dir, then run after successful login
|
||||
|
||||
# Refresh: extract MAC address of wlan0
|
||||
MAC=$(netplan status -f json | jq -r '.wlan0.macaddress')
|
||||
|
||||
# Check that we actually got a MAC address
|
||||
if [[ -z "$MAC" ]]; then
|
||||
echo "Error: Could not retrieve MAC address from netplan." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Detected MAC address: $MAC"
|
||||
|
||||
# Assign cheese hostname based on MAC address
|
||||
case "$MAC" in
|
||||
38:9c:80:46:26:c8) # ← Replace with your first real MAC
|
||||
HOSTNAME="brie"
|
||||
;;
|
||||
68:f8:ea:22:e1:3d) # ← Replace with your second real MAC
|
||||
HOSTNAME="gouda"
|
||||
;;
|
||||
99:88:77:66:55:44) # ← Replace with your third real MAC
|
||||
HOSTNAME="camembert"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown MAC address: $MAC ... hostname not changed." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Setting hostname to: $HOSTNAME"
|
||||
sudo hostnamectl set-hostname "$HOSTNAME"
|
||||
|
||||
# Optional: also update /etc/hostname (hostnamectl usually does this, but to be safe)
|
||||
echo "$HOSTNAME" | sudo tee /etc/hostname >/dev/null
|
||||
|
||||
echo "Hostname changed. Reboot or start a new shell to see the change."
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to clean sensitive WiFi credentials and passwords from configuration files
|
||||
# Usage: ./clean_sensitive.sh [filename]
|
||||
|
||||
FILE="${1:-/home/finn/code/tinyboard/armb-not_logged_in_yet}"
|
||||
|
||||
echo "Cleaning sensitive data from: $FILE"
|
||||
|
||||
# Clean WiFi SSID (both commented and uncommented lines)
|
||||
sed -i 's/^\(#*PRESET_NET_WIFI_SSID=\).*$/\1"[REDACTED]"/' "$FILE"
|
||||
|
||||
# Clean WiFi KEY (both commented and uncommented lines)
|
||||
sed -i 's/^\(#*PRESET_NET_WIFI_KEY=\).*$/\1"[REDACTED]"/' "$FILE"
|
||||
|
||||
# Clean root password
|
||||
sed -i 's/^\(PRESET_ROOT_PASSWORD=\).*$/\1"[REDACTED]"/' "$FILE"
|
||||
|
||||
# Clean user password
|
||||
sed -i 's/^\(PRESET_USER_PASSWORD=\).*$/\1"[REDACTED]"/' "$FILE"
|
||||
|
||||
echo "wiped fields"
|
||||
202
spoke/setup-network.sh
Executable file
202
spoke/setup-network.sh
Executable 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: cd .. && ./setup.sh${NC}"
|
||||
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
359
spoke/setup-spoke.sh
Executable file
359
spoke/setup-spoke.sh
Executable 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 ""
|
||||
Reference in New Issue
Block a user